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..81ec2a8d8 --- /dev/null +++ b/packages/async/README.md @@ -0,0 +1,160 @@ +

+ 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: + +- [`fromStream`](#fromStream) - wraps a fetch request to support web streams in memos or optimistic signals +- [`fromJSONStream`](#fromJSONStream) - wraps a fetch request returning a web stream containing (incomplete) JSON for the use 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 +- [`makeRetrying`](#makeretrying) - wraps the fetcher to retry requests after a delay +- [`createAggregated`](#createAggregated) - aggregates the values of an accessor + +## Installation + +```bash +npm install @solid-primitives/async +# or +yarn add @solid-primitives/async +# or +pnpm add @solid-primitives/async +``` + +## `fromStream` + +Turns a function returning a [Web Stream API ReadableStream](https://streams.spec.whatwg.org/#rs-class) or a streaming response directly or in a promise into an async iterator function that buffers the stream and updates with each data package. Node.js Web Streams are also supported, but will only work on streaming SSR. + + +```ts +// definition +fromStream( + webStreamOrResponse: (...args: Args) => ReadableStream | Response +): (...args: Args) => AsyncGenerator; + +// on the client +const plainText = createMemo(fromStream(() => fetch(url()))); + +// on the server +const readme = createMemo(fromStream(Readable.toWeb(createReadStream('README.md')))); +``` + +If the packages were very small and contained only a few words from lorem ipsum, the result would be (one line per update): + +``` +Lorem ipsum +Lorem ipsum dolor sit amet, +Lorem ipsum dolor sit amet, consetetur sadipscing +``` + +and so on. Usual HTTP packets can transmit ~1.4kb including headers, so expect mutliple updates for larger data. + +## `fromJSONStream` + +The same as `fromStream`, but it auto-closes a partial JSON string to allow for successful parsing. + +```ts +// definition +fromStream( + webStreamOrResponse: (...args: Args) => ReadableStream | Response +): (...args: Args) => AsyncGenerator; + +// usage +const answer = createMemo(fromJSONStream(() => fetch(url()))); +``` + +The result looks like this: + +```js +// current data +// parsed JSON + +'[{"id":8429,"name":"fromStrea' +[{ id: 8429, name: "fromStrea" }] + +'[{"id":8429,"name":"fromStream","description":"tu' +[{ id: 8429, name: "fromStream", description: "tu" }] + +'[{"id":8429,"name":"fromStream","description":"turns web streams into' +[{ id: 8429, name: "fromStream", description: "turns web streams into" }] + +'[{"id":8429,"name":"fromStream","description":"turns web streams into async iterator"},{"id":294' +[{ id: 8429, name: "fromStream", description: "turns web streams into async iterator" }, { id: 294 }] + +'[{"id":8429,"name":"fromStream","description":"turns web streams into async iterator"},{"id":2947,"name":"fromJSONStream",' +[{ id: 8429, name: "fromStream", description: "turns web streams into async iterator" }, { id: 2947, name: "fromJSONStream }] + +// and so on +``` + +## `makeAbortable` + +Orchestrates AbortController creation and aborting of abortable fetchers, either on refetch or after a timeout, depending on configuration: + +```ts +// definition +const [ + signal: AbortSignal, + abort: () => void, + filterErrors: (err: E) => E instanceof AbortError ? void : E +] = makeAbortable({ + timeout?: 10000, + noAutoAbort?: true, +}); + +// usage +const [signal, abort, filterErrors] = makeAbortable(); +const data = createMemo(fromStream(() => fetch(url(), { signal: signal() }).catch(filterErrors)); +// use `createAbortable` if you do not want manual cleanup: +onCleanup(abort); +``` + +* The signal function always returns a signal that is not yet aborted; if noAutoAbort is not set to true, calling it will also abort a previous signal, if present +* The abort callback will always abort the current signal +* If timeout is set, the signal will be aborted after that many Milliseconds +* The filterErrors function can be used to filter out abort errors + +## `createAbortable` + +This function does exactly the same as makeAbortable, but also automatically aborts on cleanup. Only use within a reactive scope. + +## `makeRetrying` + +Wraps a fetcher and can catch errors and retry after a delay: + +```ts +// definition +const fetcher: () => AsyncGenerator = makeRetrying( + () => fetch(url()).then(r => r.body), + { + delay: 1000, // number of Milliseconds to wait before retrying; default is 5s + retries: 1, // number of times a rest should be repeated before throwing the last error; default is 3 times + } +); +``` + +If you want to retry for an infinite number of times, you can set `options.retries` to `Infinity`. + +## `createAggregated` + +Aggregates the output of any accessor/memo: + +```ts +const aggregated: Accessor = createAggregated( + accessor: Accessor, initialValue?: T | U +); +const pages = createAggregated(currentPage, []); +``` + +* `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 diff --git a/packages/async/package.json b/packages/async/package.json new file mode 100644 index 000000000..50819ede6 --- /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": "Reactivity" + }, + "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.14" + }, + "devDependencies": { + "solid-js": "2.0.0-beta.14" + } +} diff --git a/packages/async/src/index.ts b/packages/async/src/index.ts new file mode 100644 index 000000000..017e0d9c8 --- /dev/null +++ b/packages/async/src/index.ts @@ -0,0 +1,263 @@ +import { onCleanup, createMemo } from "solid-js"; +import type { Accessor, ComputeFunction } from "solid-js"; +import type { ReadableStream as NodeReadableStream } from "stream/web" + +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 | NodeReadableStream) { + 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 | NodeReadableStream) { + const wrappedFetcher = fromStream(fetcher); + return async function*(...args: Args) { + for await (const data of wrappedFetcher(...args)) { + try { + const parsed = JSON.parse(closeJSONPart(data)); + 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; + } else if (isIterable(result)) { + for (const item of result) + if (isPromiseLike(item)) yield item as PromiseLike; + else yield Promise.resolve(item) as Promise; + return; + } else if (isAsyncIterable(result)) { + for await (const item of result) yield Promise.resolve(item) as PromiseLike; + } else { + yield Promise.resolve(result) as PromiseLike; + } + } catch(error) { + if (retries-- <= 0) { + retries = options.retries || 3; + throw new Error(`retry failed ${options.retries || 3} times`); + } + 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 = createAggregated(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/stories/createAbortable.stories.tsx b/packages/async/stories/createAbortable.stories.tsx new file mode 100644 index 000000000..0487a39d9 --- /dev/null +++ b/packages/async/stories/createAbortable.stories.tsx @@ -0,0 +1,57 @@ +import { createAbortable } from "@solid-primitives/async"; +import preview from "../../../.storybook/preview.js"; +import data from "./data.json"; +import { createMemo, createSignal, For, Loading } from "solid-js"; + +const meta = preview.meta({ + title: "Reactivity", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +declare global { + class AbortError extends Error {} +} + +export const CreateAbortableAutoSuggest = meta.story({ + name: "createAbortable AutoSuggest", + parameters: { + docs: { + description: { + story: + "`createAbortable` automatically aborts subsequent requests and automatically aborts on next signal and cleanup, ideal for patterns like auto-suggest." + } + } + }, + render: () => { + if (!('AbortError' in globalThis)) { + (globalThis as any).AbortError = class AbortError extends Error { + constructor(msg: string) { super(msg); } + } + } + const autoSuggest = async (query: string, signal: AbortSignal) => { + await new Promise(r => setTimeout(r, 500)); + if (signal.aborted) throw new AbortError("aborted"); + const fuzzy = new RegExp(query.replace(/./g, "$1.*?"), "i"); + return data.filter(term => fuzzy.test(term)); + } + const [query, setQuery] = createSignal(""); + const [signal, abort, filterError] = createAbortable(); + const suggest = createMemo(() => autoSuggest(query(), signal())); + + return + { setQuery(ev.currentTarget.value)}} + /> +
    + no suggestions found}> + {(suggestion) =>
  • {suggestion}
  • } +
    +
+
+ }, +}); \ No newline at end of file diff --git a/packages/async/stories/data.json b/packages/async/stories/data.json new file mode 100644 index 000000000..dd78739fd --- /dev/null +++ b/packages/async/stories/data.json @@ -0,0 +1 @@ +["active-element","createActiveElement","analytics","createAnalytics","async","fromStream","fromJSONStream","makeAbortable","createAbortable","makeRetrying","createAggregated","audio","makeAudio","makeAudioPlayer","createAudio","bounds","createElementBounds","broadcast-channel","makeBroadcastChannel","createBroadcastChannel","clipboard","copyClipboard","writeClipboard","createClipboard","connectivity","createConnectivitySignal","context","createContextProvider","createOptionalContextProvider","createLayeredContext","MultiProvider","controlled-props","createControlledProp","controlled-signal","createControllableSignal","createControllableBooleanSignal","createControllableArraySignal","createControllableSetSignal","cookies","createServerCookie","createUserTheme","getCookiesString","cursor","makeBodyCursor","makeElementCursor","createBodyCursor","createElementCursor","createDragCursor","cursorRef","date","createDate","createDateNow","createTimeDifference","createTimeDifferenceFromNow","createTimeAgo","createCountdown","createCountdownFromNow","db-store","createDbStore","supabaseAdapter","deep","trackDeep","trackStore","captureStoreUpdates","destructure","destructure","devices","createDevices","createMicrophones","createSpeakers","createCameras","event-bus","createEventBus","createEmitter","createEventHub","createEventStack","event-dispatcher","createEventDispatcher","event-listener","createEventListener","createEventSignal","createEventListenerMap","WindowEventListener","DocumentEventListener","event-props","createEventProps","fetch","createFetch","filesystem","createFileSystem","createSyncFileSystem","createAsyncFileSystem","makeNoFileSystem","makeNoAsyncFileSystem","makeVirtualFileSystem","makeWebAccessFileSystem","makeNodeFileSystem","makeTauriFileSystem","makeChokidarWatcher","rsync","flux-store","createFluxStore","createFluxStoreFactory","createActions","createAction","focus","autofocus","createAutofocus","createFocusTrap","makeFocusListener","createFocusSignal","fullscreen","makeFullscreen","createFullscreen","fullscreen","geolocation","makeGeolocation","makeGeolocationWatcher","createGeolocation","createGeolocationWatcher","createDistance","createWithinRadius","gestures","graphql","createGraphQLClient","history","createUndoHistory","i18n","flatten","resolveTemplate","translator","scopedTranslator","chainedTranslator","idle","createIdleTimer","immutable","createImmutable","input-mask","createInputMask","createMaskPattern","interaction","makeInteractOutside","interactOutside","createInteractOutside","ariaHideOutside","createHideOutside","intersection-observer","createIntersectionObserver","createViewportObserver","createVisibilityObserver","jsx-tokenizer","createTokenizer","createToken","resolveTokens","isToken","keyboard","useKeyDownEvent","useKeyDownList","useCurrentlyHeldKey","useKeyDownSequence","createKeyHold","createShortcut","keyed","keyArray","Key","Entries","MapEntries","SetValues","lifecycle","createIsMounted","isHydrated","onElementConnect","list","listArray","List","list-state","createListState","createMultiSelectListState","map","ReactiveMap","ReactiveWeakMap","marker","createMarker","masonry","createMasonry","match","MatchTag","MatchValue","media","makeMediaQueryListener","createMediaQuery","createBreakpoints","usePrefersDark","mediastream","createStream","createAmplitudeStream","createAmplitudeFromStream","createMediaPermissionRequest","createScreen","memo","createLatest","createLatestMany","createWritableMemo","createLazyMemo","createPureReaction","createMemoCache","createReducer","mouse","createMousePosition","createPositionToElement","mutable","createMutable","modifyMutable","mutation-observer","createMutationObserver","notification","isNotificationSupported","makeNotification","createNotification","createNotificationPermission","orientation","makeOrientation","createOrientation","page-utilities","createPageVisibility","usePageVisibility","makePageLeave","createPageLeaveBlocker","pagination","createPagination","createSegment","createInfiniteScroll","permission","createPermission","platform","List of variables","pointer","createPointerListeners","createPerPointerListeners","createPointerPosition","createPointerList","presence","createPresence","promise","promiseTimeout","raceTimeout","until","props","combineProps","combineHandlers","filterProps","partitionProps","queue","makeQueue","createQueue","makePriorityQueue","createPriorityQueue","createTaskQueue","createConcurrentTaskQueue","raf","createRAF","createMs","targetFPS","range","createNumericRange","repeat","mapRange","indexRange","Repeat","IndexRange","refs","mergeRefs","resolveElements","resolveFirst","Ref","Refs","resize-observer","createResizeObserver","createWindowSize","createElementSize","resource","createAggregated","createDeepSignal","makeAbortable","createAbortable","makeCache","makeRetrying","rootless","createSubRoot","createCallback","createDisposable","createSharedRoot","createRootPool","scheduled","debounce","throttle","scheduleIdle","leading","createScheduled","leadingAndTrailing","script-loader","createScriptLoader","scroll","createScrollPosition","useWindowScrollPosition","createPreventScroll","selection","createSelection","sensors","makeAccelerometer","createAccelerometer","makeGyroscope","createGyroscope","makeSensor","createSensor","makeCompass","createCompass","makeBattery","createBattery","set","ReactiveSet","ReactiveWeakSet","union","intersection","difference","symmetricDifference","readonlySet","share","createSocialShare","createWebShare","makeWebShare","signal-builders","push","filter","sort","map","get","merge","update","add","clamp","template","spring","createSpring","createDerivedSpring","sse","makeSSE","createSSE","makeSSEWorker","state-machine","createMachine","static-store","createStaticStore","createDerivedStaticStore","storage","makePersisted","cookieStorage","tauriStorage","multiplexStorage","storageSync","messageSync","wsSync","multiplexSync","addClearMethod","addWithOptionsMethod","makeObjectStorage","styles","createRemSize","timer","makeTimer","createTimer","createTimeoutLoop","createPolled","createIntervalCounter","transition-group","createSwitchTransition","createListTransition","trigger","createTrigger","createTriggerCache","tween","createTween","upload","createFilePicker","createFileUploader","fileSender","fileUploader","createDropzone","dropzone","utils","shallowArrayCopy","shallowObjectCopy","shallowCopy","withArrayCopy","withObjectCopy","withCopy","push","drop","dropRight","filterOut","filter","sort","sortBy","map","slice","splice","fill","concat","remove","removeItems","flatten","filterInstance","filterOutInstance","omit","pick","split","merge","get","update","add","substract","multiply","divide","power","clamp","json","ndjson","lines","number","safe","pipe","wrapSetter","vibrate","isVibrationSupported","makeVibrate","createVibrate","frequencyToPattern","makePulse","createPulse","video","makeVideo","makeVideoPlayer","createVideo","createVideoPlayer","virtual","createVirutalList","VirtualList","websocket","makeWS","createWS","createWSState","createWSMessage","makeReconnectingWS","createReconnectingWS","makeHeartbeatWS","wsMessageIterable","createWSData","createWSStore","workers","createWorker","createWorkerPool","createSignaledWorker"] \ No newline at end of file diff --git a/packages/async/stories/fromJSONStream.stories.tsx b/packages/async/stories/fromJSONStream.stories.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/async/stories/fromStream.stories.tsx b/packages/async/stories/fromStream.stories.tsx new file mode 100644 index 000000000..ac54e02af --- /dev/null +++ b/packages/async/stories/fromStream.stories.tsx @@ -0,0 +1,49 @@ +import { fromStream } from "@solid-primitives/async"; +import preview from "../../../.storybook/preview.js"; +import data from "./data.json"; +import { createMemo, Loading } from "solid-js"; + +const meta = preview.meta({ + title: "Reactivity", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +export const FromStream = meta.story({ + name: "makeAbortable AutoSuggest", + parameters: { + docs: { + description: { + story: + "`fromStream` wraps Web Stream ReadableStreams or streaming Responses with aggregation to be used in Solid's reactive system." + } + } + }, + render: () => { + const stream = new ReadableStream({ + async pull(controller) { + const source = JSON.stringify(data); + const packetCount = 16; + const sliceLength = Math.ceil(source.length / 16); + const parts = Array.from( + { length: packetCount }, + (_, idx) => source.slice(idx * sliceLength, (idx + 1) * sliceLength - 1) + ); + const encoder = new TextEncoder(); + for (const part in parts) { + await new Promise(r => setTimeout(r, 200)); + controller.enqueue(encoder.encode(part)); + } + controller.close(); + } + }); + const items = createMemo(fromStream(() => stream)); + + return + {items()} + + }, +}); \ No newline at end of file diff --git a/packages/async/stories/makeAbortable.stories.tsx b/packages/async/stories/makeAbortable.stories.tsx new file mode 100644 index 000000000..f146166c7 --- /dev/null +++ b/packages/async/stories/makeAbortable.stories.tsx @@ -0,0 +1,57 @@ +import { makeAbortable } from "@solid-primitives/async"; +import preview from "../../../.storybook/preview.js"; +import data from "./data.json"; +import { createMemo, createSignal, For, Loading } from "solid-js"; + +const meta = preview.meta({ + title: "Reactivity", + parameters: { + layout: "centered", + }, +}); + +export default meta; + +declare global { + class AbortError extends Error {} +} + +export const MakeAbortableAutoSuggest = meta.story({ + name: "makeAbortable AutoSuggest", + parameters: { + docs: { + description: { + story: + "`makeAbortable` automatically aborts subsequent requests and automatically aborts on next signal, ideal for patterns like auto-suggest." + } + } + }, + render: () => { + if (!('AbortError' in globalThis)) { + (globalThis as any).AbortError = class AbortError extends Error { + constructor(msg: string) { super(msg); } + } + } + const autoSuggest = async (query: string, signal: AbortSignal) => { + await new Promise(r => setTimeout(r, 500)); + if (signal.aborted) throw new AbortError("aborted"); + const fuzzy = new RegExp(query.replace(/./g, "$1.*?"), "i"); + return data.filter(term => fuzzy.test(term)); + } + const [query, setQuery] = createSignal(""); + const [signal, abort, filterError] = makeAbortable(); + const suggest = createMemo(() => autoSuggest(query(), signal())); + + return + { setQuery(ev.currentTarget.value)}} + /> +
    + no suggestions found}> + {(suggestion) =>
  • {suggestion}
  • } +
    +
+
+ }, +}); \ No newline at end of file diff --git a/packages/async/stories/makeAggregated.stories.tsx b/packages/async/stories/makeAggregated.stories.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/async/stories/makeRetrying.stories.tsx b/packages/async/stories/makeRetrying.stories.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/packages/async/stories/tsconfig.json b/packages/async/stories/tsconfig.json new file mode 100644 index 000000000..0cd588e9e --- /dev/null +++ b/packages/async/stories/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../.storybook/tsconfig.json", + "include": ["./**/*"] +} diff --git a/packages/async/test/index.test.ts b/packages/async/test/index.test.ts new file mode 100644 index 000000000..cead1dcc5 --- /dev/null +++ b/packages/async/test/index.test.ts @@ -0,0 +1,304 @@ +import { describe, test, expect } from "vitest"; +import { createEffect, createMemo, createRoot, createSignal, flush } from "solid-js"; +import { fromStream, fromJSONStream, makeAbortable, createAbortable, makeRetrying, createAggregated } 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()[Symbol.asyncIterator]().next()).toEqual({ done: false, value: true }); + }); + + test("throws after the retry limit", async () => { + const responses: Promise[] = Array.from({ length: 4 }, () => Promise.reject(new Error("retry"))); + const fetcher = (_prev: unknown) => responses.shift() || Promise.resolve(true); + const wrapped = makeRetrying(fetcher, { delay: 15 }); + const result = wrapped()[Symbol.asyncIterator]().next(); + console.log(result) + await expect(result).rejects.toThrow(); + }); +}); + +describe("makeAggregated", () => { + test("aggregates arrays", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData(["one"]); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual(["one"]); + addData(["two"]); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual(["one", "two"]); + addData(["three", "four"]); + } else if (run === 3) { + expect(aggregate, "adding multiple data points works").toEqual([ + "one", + "two", + "three", + "four", + ]); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("aggregates objects", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal>(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData({ one: "one" }); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual({ one: "one" }); + addData({ two: "two" }); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual({ + one: "one", + two: "two", + }); + addData({ three: "three", four: "four" }); + } else if (run === 3) { + expect(aggregate, "adding multiple data points works").toEqual({ + one: "one", + two: "two", + three: "three", + four: "four", + }); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("aggregates strings", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData("one "); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toBe("one "); + addData("two "); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toBe("one two "); + addData("three four"); + } else if (run === 3) { + expect(aggregate, "adding multiple data points works").toBe("one two three four"); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("aggregates numbers", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initially undefined").toBeUndefined(); + addData(1); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual([1]); + addData(2); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual([1, 2]); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); + test("an initial value [] allows to aggregate objects into arrays", () => + new Promise(resolve => createRoot(dispose => { + const [data, addData] = createSignal>(); + const memo = createMemo(() => Promise.resolve(data())); + const aggregated = createAggregated(memo, []); + let run = 0; + createEffect(aggregated, (aggregate) => { + if (run === 0) { + expect(aggregate, "initial value").toEqual([]); + addData({ one: "one" }); + } else if (run === 1) { + expect(aggregate, "adding initial data works").toEqual([{ one: "one" }]); + addData({ two: "two" }); + } else if (run === 2) { + expect(aggregate, "adding another point of data works").toEqual([ + { one: "one" }, + { two: "two" }, + ]); + queueMicrotask(dispose); + resolve(); + } + run++; + }); + }))); +}); diff --git a/packages/async/test/server.test.ts b/packages/async/test/server.test.ts new file mode 100644 index 000000000..486c356d0 --- /dev/null +++ b/packages/async/test/server.test.ts @@ -0,0 +1,20 @@ +import { describe, test, expect } from "vitest"; +import { createEffect, createMemo, createRoot } from "solid-js"; +import { fromStream } from "../src/index.js"; +import { createReadStream } from "fs"; +import { Readable } from "stream"; + +describe("fromStream", () => { + // this is only relevant on streaming SSR, which is not supported by our tests, + // so we will test it outside of Solid + test("works on node readable streams", async () => { + const stream = Readable.toWeb(createReadStream('../../README.md')); + const readme = fromStream(() => stream); + let parts = 0; + for await (const data of readme()) { + parts++; + expect(data).toBeDefined(); + } + expect(parts).toBeGreaterThan(0); + }); +}); 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 78922ba24..3f367cadd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,12 @@ importers: specifier: ^1.9.7 version: 1.9.7 + packages/async: + devDependencies: + solid-js: + specifier: 2.0.0-beta.14 + version: 2.0.0-beta.14 + packages/audio: dependencies: '@solid-primitives/static-store': @@ -3038,10 +3044,6 @@ packages: '@oxc-project/types@0.127.0': resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} - '@oxc-project/types@0.130.0': - resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==} - - '@oxc-resolver/binding-android-arm-eabi@11.20.0': resolution: {integrity: sha512-IjfWOXRgJFNdORDl+Uf1aibNgZY2guOD3zmOhx1BGVb/MIiqlFTdmjpQNplSN58lhWehnX4UNqC3QwpUo8pjJg==} cpu: [arm] @@ -8756,9 +8758,6 @@ snapshots: '@oxc-project/types@0.127.0': {} - '@oxc-project/types@0.130.0': {} - - '@oxc-resolver/binding-android-arm-eabi@11.20.0': optional: true