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
+
+[](https://bundlephobia.com/package/@solid-primitives/async)
+[](https://www.npmjs.com/package/@solid-primitives/async)
+[](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 @@
[](https://www.npmjs.com/package/@solid-primitives/resource)
[](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