diff --git a/packages/async/LICENSE b/packages/async/LICENSE new file mode 100644 index 000000000..38b41d975 --- /dev/null +++ b/packages/async/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Solid Primitives Working Group + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/packages/async/README.md b/packages/async/README.md new file mode 100644 index 000000000..37ee55d09 --- /dev/null +++ b/packages/async/README.md @@ -0,0 +1,41 @@ +

+ Solid Primitives async +

+ +# @solid-primitives/async + +[![size](https://img.shields.io/bundlephobia/minzip/@solid-primitives/async?style=for-the-badge&label=size)](https://bundlephobia.com/package/@solid-primitives/async) +[![version](https://img.shields.io/npm/v/@solid-primitives/async?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/async) +[![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) + +A collection of primitves for handling of asynchronous memos, optimistic signals, stores and actions: + +- [`makeStreamable`](#makeStreamable) - wraps a fetch request to support web streams in memos or optimistic signals +- [`makeAbortable`](#makeabortable) - sets up an AbortSignal with auto-abort on re-fetch or timeout +- [`createAbortable`](#createabortable) - like `makeAbortable`, but with automatic abort on cleanup +- [`makeCache`](#makecache) - wraps the fetcher to cache the responses for a certain amount of time +- [`makeRetrying`](#makeretrying) - wraps the fetcher to retry requests after a delay + +## Installation + +```bash +npm install @solid-primitives/async +# or +yarn add @solid-primitives/async +# or +pnpm add @solid-primitives/async +``` + +## How to use it + +```ts +// TODO +``` + +## Demo + +You can use this template for publishing your demo on CodeSandbox: https://codesandbox.io/s/solid-primitives-demo-template-sz95h + +## Changelog + +See [CHANGELOG.md](./CHANGELOG.md) diff --git a/packages/async/dev/index.tsx b/packages/async/dev/index.tsx new file mode 100644 index 000000000..cff773f64 --- /dev/null +++ b/packages/async/dev/index.tsx @@ -0,0 +1,20 @@ +import { type Component, createSignal } from "solid-js"; + +const App: Component = () => { + const [count, setCount] = createSignal(0); + const increment = () => setCount(count() + 1); + + return ( +
+
+

Counter component

+

it's very important...

+ +
+
+ ); +}; + +export default App; diff --git a/packages/async/package.json b/packages/async/package.json new file mode 100644 index 000000000..145997ee1 --- /dev/null +++ b/packages/async/package.json @@ -0,0 +1,64 @@ +{ + "name": "@solid-primitives/async", + "version": "0.0.100", + "description": "A template primitive example.", + "author": "Your Name ", + "contributors": [], + "license": "MIT", + "homepage": "https://primitives.solidjs.community/package/async", + "repository": { + "type": "git", + "url": "git+https://github.com/solidjs-community/solid-primitives.git" + }, + "bugs": { + "url": "https://github.com/solidjs-community/solid-primitives/issues" + }, + "primitive": { + "name": "async", + "stage": 0, + "list": [ + "fromStream", + "fromJSONStream", + "makeAbortable", + "createAbortable", + "makeRetrying", + "createAggregated" + ], + "category": "Display & Media" + }, + "keywords": [ + "solid", + "primitives" + ], + "private": false, + "sideEffects": false, + "files": [ + "dist" + ], + "type": "module", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "import": { + "@solid-primitives/source": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "typesVersions": {}, + "scripts": { + "dev": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/dev.ts", + "build": "node --import=@nothing-but/node-resolve-ts --experimental-transform-types ../../scripts/build.ts", + "vitest": "vitest -c ../../configs/vitest.config.ts", + "vitest2": "vitest -c ../../configs/vitest.config.solid2.ts", + "test": "pnpm run vitest", + "test:ssr": "pnpm run vitest --mode ssr" + }, + "peerDependencies": { + "solid-js": "2.0.0-beta.10" + }, + "devDependencies": { + "solid-js": "2.0.0-beta.10" + } +} diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts new file mode 100644 index 000000000..7aa43548f --- /dev/null +++ b/packages/async/src/index.ts @@ -0,0 +1,279 @@ +import { onCleanup, createMemo } from "solid-js"; +import type { Accessor, ComputeFunction } from "solid-js"; + +const chained = new Map<() => AbortSignal, (() => void)[]>(); + +/** + * **aggregates web stream chunks into a memo** + * ```ts + * // from Response: + * const streamed = createMemo(fromStream(() => fetch(url()))); + * + * // from another ReadableStream: + * const streamed = createMemo(fromStream(() => getStream())); + * ``` + */ +export function fromStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { + return async function*(...args: Args) { + let parts = '', decoder; + const source = await fetcher(...args); + const stream = source instanceof Response ? source.body : source; + const reader = stream?.getReader(); + if (!reader) { + console.warn('No ReadableStream found!') + return; + } + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value) { + if (typeof value !== 'string') { + parts += (decoder ??= new TextDecoder()).decode(value, { stream: true }); + } else { + parts += value; + } + } + yield parts; + } + } +} + +const endMatcher = /(?:\W)(t|tru?|f|fa|fals?|n|nul?)$/; +const endLiterals: Record = { + t: "rue", tr: "ue", tru: "e", + f: "alse", fa: "lse", fal: "se", fals: "e", + n: "ull", nu: "ll", nul: "l" +}; + +const closeJSONPart = (json: string) => + json.replace(/[,:]\s*$/, "") + + (endMatcher.test(json) && endLiterals[RegExp.$1] || "") + + [...json].reduce((stack: string[], char: string) => { + const close = ({ '"': '"', "[": "]", "{": "}" })[char]; + if (char === stack[0]) stack.shift(); + else if (close) stack.unshift(close); + return stack; + }, []).join(""); + +/** + * **aggregates web stream chunks into a memo supporting partial JSON** + * ```ts + * // from Response: + * const streamed = createMemo(fromStream(() => fetch(url()))); + * + * // from another ReadableStream: + * const streamed = createMemo(fromStream(() => getStream())); + * ``` + */ +export function fromJSONStream(fetcher: (...args: Args) => Promise | Response | ReadableStream) { + return async function*(...args: Args) { + let parts = '', decoder; + const source = await fetcher(...args); + const stream = source instanceof Response ? source.body : source; + const reader = stream?.getReader(); + if (!reader) { + console.warn('No ReadableStream found!') + return; + } + while (true) { + const { done, value } = await reader.read(); + if (done) return; + if (value) { + if (typeof value !== 'string') { + parts += (decoder ??= new TextDecoder()).decode(value, { stream: true }); + } else { + parts += value; + } + } + try { + const parsed = JSON.parse(closeJSONPart(parts)) + yield parsed; + } catch (e) { /* ignore erroneous states, recover later */ } + } + } +} + + +export type AbortableReturn = [ + signal: () => AbortSignal, + abort: (reason?: string) => void, + filterAbortError: (err: any) => void, +] + +export type AbortableOptions = { + /** Should abort when a new signal is requested, default is true */ + autoAbort?: boolean; + /** Automatically abort after a timeout in ms if set */ + timeout?: number; + /** Aborts if a parent signal is aborted (e.g. first optimistic update after a second write) */ + chainTo?: () => AbortSignal; +}; + +/** + * **Creates and handles an AbortSignal** + * ```ts + * const [signal, abort, filterAbortError] = + * makeAbortable({ timeout: 10000 }); + * const fetcher = (url) => fetch(url, { signal: signal() }) + * .catch(filterAbortError); // filters abort errors + * ``` + * Returns an accessor for the signal and the abort callback. + * + * Options are optional and include: + * - `timeout`: time in Milliseconds after which the fetcher aborts automatically + * - `autoAbort`: can be set to true to make a new source not automatically abort a previous request + * - `chainTo`: listen to another abort signal to abort this signal + */ +export function makeAbortable( + options: AbortableOptions = {}, +): AbortableReturn { + let controller: AbortController; + let timeout: ReturnType | undefined; + const abort = (reason?: string) => { + timeout && clearTimeout(timeout); + controller?.abort(reason); + }; + if (options.chainTo) { + chained.set(options.chainTo, [...(chained.get(options.chainTo) || []), () => abort("chain abort")]); + } + function signal() { + if (options.autoAbort !== false && controller?.signal.aborted === false) + abort("retry"); + controller = new AbortController(); + if (options.timeout) { + timeout = setTimeout(() => abort("timeout"), options.timeout); + } + controller.signal.addEventListener('abort', () => chained.get(signal)?.forEach(a => a())); + return controller.signal; + }; + return [ + signal, + abort, + err => { + if (err.name === "AbortError") { + return undefined; + } + throw err; + }, + ]; +} + +/** + * **Creates and handles an AbortSignal with automated cleanup** + * ```ts + * const [signal, abort, filterAbortError] = + * createAbortable(); + * const fetcher = (url) => fetch(url, { signal: signal() }) + * .catch(filterAbortError); // filters abort errors + * ``` + * Returns an accessor for the signal and the abort callback. + * + * Options are optional and include: + * - `timeout`: time in Milliseconds after which the fetcher aborts automatically + * - `noAutoAbort`: can be set to true to make a new source not automatically abort a previous request + * - `chainTo`: listen to another abort signal to abort this signal + */ +export function createAbortable( + options?: AbortableOptions, +): [() => AbortSignal, () => void, (err: any) => void] { + const [signal, abort, filterAbortError] = makeAbortable(options); + onCleanup(abort); + return [signal, abort, filterAbortError]; +} + +const isPromiseLike = (obj: unknown): obj is PromiseLike => !!obj && + ['object', 'function'].includes(typeof obj) && typeof (obj as PromiseLike).then === 'function'; + +const isIterable = (obj: unknown): obj is Iterable => !!obj && Object.hasOwn(obj, Symbol.iterator); + +const isAsyncIterable = (obj: unknown): obj is AsyncIterable => !!obj && Object.hasOwn(obj, Symbol.asyncIterator); + +export type RetryOptions = { + delay?: number; + retries?: number; +}; + +/** + * **Creates a fetcher that retries multiple times in case of errors** + * ```ts + * const data = createMemo(makeRetrying(() => fetch(url()), { retries: 5 })); + * ``` + * Receives the fetcher and an optional options object and returns a wrapped fetcher that retries on error after a delay multiple times. + * + * The optional options object contains the following optional properties: + * - `delay` - number of Milliseconds to wait before retrying; default is 5s + * - `retries` - number of times a request should be repeated before giving up throwing the last error; default is 3 times + */ +export function makeRetrying, T>>( + fetcher: C, + options: RetryOptions = {}, +): () => AsyncGenerator { + const delay = options.delay ?? 5000; + let retries = options.retries || 3; + + return async function* retrying(v?: T): AsyncGenerator { + let result: T | PromiseLike | AsyncIterable | undefined; + while (true) { + try { + result ??= fetcher(v); + if (isPromiseLike(result)) { + yield await result; + result = undefined; + } else if (isIterable(result)) { + for (const item of result) + if (isPromiseLike(item)) yield await item as PromiseLike; + else yield Promise.resolve(item) as Promise; + return; + } else { + yield Promise.resolve(result) as Promise; + result = undefined; + } + } catch(error) { + if (retries-- <= 0) { + retries = options.retries || 3; + throw error; + } + if (delay) await new Promise(resolve => setTimeout(resolve, delay)); + } + } + };} + + +function toArray(item: any) { + return Array.isArray(item) ? item : item ? [item] : []; +} + +/** + * **Automatically aggregates resource changes** + * ```ts + * const pages = makeAggregated(currentPage, [], { id: "infinite-scroll" }); + * ``` + * @param res {Accessor} - The accessor that should be aggregated + * @param initialValue {I | undefined} - an optional initial value + * @param memoOptions - optional options for `createMemo` + * + * Depending on the content of the initialValue or the first response, this will aggregate the incoming responses: + * - null will not overwrite undefined + * - if the previous value is an Array, incoming values will be appended + * - if any of the values are Objects, the current one will be shallow-merged into the previous one + * - if the previous value is a string, more string data will be appended + * - otherwise the incoming data will be put into an array + * + * Objects and Arrays are re-created on each operation, but the values will be left untouched, so `` should work fine. + */ +export function createAggregated(res: Accessor, initialValue?: I, memoOptions?: Parameters>[1]) { + return createMemo((previous = initialValue) => { + const current = res(); + return current == null && previous == null + ? previous + : Array.isArray(previous || current) + ? [...toArray(previous), ...toArray(current)] + : typeof (previous || current) === "object" + ? { ...previous, ...current } + : typeof previous === "string" || typeof current === "string" + ? (previous?.toString() || "") + (current || "") + : previous + ? [previous, current] + : [current]; + }, memoOptions); +} diff --git a/packages/async/test/index.test.ts b/packages/async/test/index.test.ts new file mode 100644 index 000000000..51087cedc --- /dev/null +++ b/packages/async/test/index.test.ts @@ -0,0 +1,162 @@ +import { describe, test, expect } from "vitest"; +import { createEffect, createMemo, createRoot, flush } from "solid-js"; +import { fromStream, fromJSONStream, makeAbortable, createAbortable, makeRetrying } from "../src/index.js"; + +const delay = (ms = 50) => new Promise(resolve => setTimeout(resolve, ms)); + +describe("fromStream", () => { + const createStream = (data: string) => new ReadableStream({ + start(controller) { + const chars = data[Symbol.iterator](); + const encoder = new TextEncoder(); + const step = () => { + const { value, done } = chars.next(); + if (done) return; + controller.enqueue(encoder.encode(value)); + delay(15).then(step); + } + delay().then(step); + }, + pull(_controller) {}, + cancel: () => {}, + }); + + test("streams from response", () => new Promise(resolve => createRoot(dispose => { + const data = "solid is great!"; + const stream = createMemo(fromStream(() => delay().then(() => { + return new Response(createStream(data)); + }))); + + createEffect(stream, (parts) => { + expect(data.slice(0, parts.length)).toBe(parts); + if (parts.length === data.length) { + queueMicrotask(dispose); + resolve(); + } + }) + })), 2000); + + test("streams from web stream", () => new Promise(resolve => createRoot(dispose => { + const data = "solid is great!"; + const stream = createMemo(fromStream(() => delay().then(() => { + return createStream(data); + }))); + + createEffect(stream, (parts) => { + expect(data.slice(0, parts.length)).toBe(parts); + if (parts.length === data.length) { + queueMicrotask(dispose); + resolve(); + } + }) + })), 2000); +}); + +describe("fromJSONStream", () => { + const createStream = (data: string[]) => new ReadableStream({ + start(controller) { + const parts = data[Symbol.iterator](); + const encoder = new TextEncoder(); + const step = () => { + const { value, done } = parts.next(); + if (done) return; + controller.enqueue(encoder.encode(value)); + delay(15).then(step); + } + delay().then(step); + }, + pull(_controller) {}, + cancel: () => {}, + }); + + test("streams partial JSON from response", () => new Promise(resolve => createRoot(dispose => { + const data = [ + '{"test": tru', + 'e, "data": [1, 2, ', + '3], "solid": "is great!"}' + ]; + const expected = [ + { test: true }, + { test: true, data: [1, 2] }, + { test: true, data: [1, 2, 3], "solid": "is great!" }, + ]; + const stream = createMemo(fromJSONStream(() => createStream(data))); + createEffect(stream, (json) => { + expect(json).toEqual(expected.shift()); + if (!expected.length) { + queueMicrotask(dispose); + resolve(); + } + }) + }))); +}); + +describe("makeAbortable", () => { + test("makes a fetcher abortable", () => { + const [signal, abort] = makeAbortable(); + const signal1 = signal(); + expect(signal1.aborted, "first signal should not be initially aborted").toBeFalsy(); + const signal2 = signal(); + flush(); + expect(signal1.aborted, "first signal should be aborted after new request").toBeTruthy(); + expect(signal2, "already aborted signal should not be re-used").not.toBe(signal1); + expect(signal2.aborted, "second signal should not be initially aborted").toBeFalsy(); + abort(); + expect(signal2.aborted, "signal should be aborted when calling abort()").toBeTruthy(); + }); + + test("aborts on chained signal abort", () => { + const [sig1, abort] = makeAbortable(); + const [sig2] = makeAbortable({ chainTo: sig1 }); + const signal1 = sig1(), signal2 = sig2(); + expect(signal1.aborted, "first signal should not be initially aborted").toBeFalsy(); + abort(); + expect(signal2.aborted, "chained signal was not aborted by the chained signal abort").toBeTruthy(); + }); + + test("chained signal does not abort its parent", () => { + const [sig1] = makeAbortable(); + const [sig2, abort] = makeAbortable({ chainTo: sig1 }); + const signal1 = sig1(), signal2 = sig2(); + expect(signal2.aborted, "second signal should not be initially aborted").toBeFalsy(); + abort(); + expect(signal1.aborted, "signal chaining works in the wrong direction").toBeFalsy(); + }); + + test("filters (only) abort errors", async () => { + class AbortError extends Error { + constructor(msg: string) { + super(msg); + } + name = "AbortError"; + } + const [_signal, _abort, filterAbortError] = makeAbortable(); + await Promise.reject(new AbortError("test")) + .catch(filterAbortError) + .then(resolution => expect(resolution).toBeUndefined()) + .catch(err => expect.fail(err.message || "failed with error")); + const noAbortError = new Error("not an AbortError"); + await Promise.reject(noAbortError) + .catch(filterAbortError) + .then(() => expect.fail("filtered error that was not an AbortError")) + .catch(err => expect(err).toBe(noAbortError)); + }); +}); + +describe("createAbortable", () => { + test("aborts on cleanup", () => { + const [dispose, signal] = createRoot((dispose) => [dispose, createAbortable()[0]()]); + expect(signal.aborted).toBeFalsy(); + dispose(); + expect(signal.aborted).toBeTruthy(); + }); +}); + +describe("makeRetrying", () => { + test("makes a fetcher retry in case of error", async () => { + const responses: Promise[] = [Promise.reject(new Error("retry"))]; + const fetcher = (_prev: unknown) => responses.shift() || Promise.resolve(true); + const wrapped = makeRetrying(fetcher, { delay: 15 }); + expect(await wrapped()).toBe(true); + }); +}); \ No newline at end of file diff --git a/packages/async/test/server.test.ts b/packages/async/test/server.test.ts new file mode 100644 index 000000000..d0ba586ee --- /dev/null +++ b/packages/async/test/server.test.ts @@ -0,0 +1,9 @@ +import { describe, test, expect } from "vitest"; +import { createPrimitiveTemplate } from "../src/index.js"; + +describe("createPrimitiveTemplate", () => { + test("doesn't break in SSR", () => { + const [value, setValue] = createPrimitiveTemplate(true); + expect(value(), "initial value should be true").toBe(true); + }); +}); diff --git a/packages/async/tsconfig.json b/packages/async/tsconfig.json new file mode 100644 index 000000000..38c71ce71 --- /dev/null +++ b/packages/async/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src" + }, + "references": [], + "include": [ + "src" + ] +} \ No newline at end of file diff --git a/packages/resource/README.md b/packages/resource/README.md index 8297af413..3a574ebf8 100644 --- a/packages/resource/README.md +++ b/packages/resource/README.md @@ -8,6 +8,10 @@ [![version](https://img.shields.io/npm/v/@solid-primitives/resource?style=for-the-badge)](https://www.npmjs.com/package/@solid-primitives/resource) [![stage](https://img.shields.io/endpoint?style=for-the-badge&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives#contribution-process) +> [!TIP] +> solid-js@>=2.0.0` no longer uses resources. You can find most of these helpers for the new version in the `@solid-primitives/async` package. + + A collection of composable primitives to augment [`createResource`](https://www.solidjs.com/docs/latest/api#createresource) - [`createAggregated`](#createaggregated) - wraps the resource to aggregate data instead of overwriting it diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b707db0c4..6e522d1d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,12 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/async: + devDependencies: + solid-js: + specifier: 2.0.0-beta.10 + version: 2.0.0-beta.10 + packages/audio: dependencies: '@solid-primitives/static-store':