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
+
+[](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:
+
+- [`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 @@
[](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 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':