Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/uneval-stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'devalue': minor
---

feat: Add `unevalStream` for streaming asynchronous values
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Like `JSON.stringify`, but handles
- `Temporal`
- custom types via replacers, reducers and revivers
- promises (via `stringifyAsync`)
- streamed promises (via `unevalStream`)

Try it out [here](https://svelte.dev/repl/138d70def7a748ce9eda736ef1c71239?version=3.49.0).

Expand Down Expand Up @@ -87,6 +88,47 @@ devalue.parse(stringified); // { quick: 'data', slow: { ... } }

Promises are awaited and their resolved values are serialized. The output format is identical to `stringify`, so `parse` and `unflatten` work unchanged.

### `unevalStream`

`unevalStream` is a streaming version of `uneval`. It synchronously returns a `head` expression that recreates the value with every promise _pending_, and a `tail` of statement blocks that settle those promises as they settle on the server:

```js
const { head, tail } = devalue.unevalStream({
quick: 'data',
slow: fetch('/api/slow').then((r) => r.json())
});

res.write(`<script>window.data = ${head}</script>`);
for await (const block of tail) res.write(`<script>${block}</script>`);
```

Evaluate `head` once, then each block once, in order, in the same realm. Concatenating them into a single script also works. A settled value may contain further promises, which later blocks settle.

- every promise is pending in `head`, regardless of timing
- object identity is preserved across `head` and every block
- the replacer is called at most once per object for the whole stream

#### Options

| Option | Description |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Session key (returned as `id`). Random by default; pass a stable value when `head` must be byte-identical across runs. |
| `scope` | Trusted, assignable expression holding the client session table. Defaults to `globalThis.__d`. |
| `signal` | An `AbortSignal` that stops the tail. The next `next()` yields one final block that rejects every promise still pending on the client with a generic `Error` and deletes the client session, then the tail is done. If the signal is already aborted, or aborts during the head emission, `unevalStream` throws `signal.reason`. |
| `transformError` | `(error) => unknown`, called with a rejection reason, or with the error that kept a resolved value from serializing. Its result (or what its returned promise resolves to) is what the client promise rejects with. As with a replacer, returning `undefined`, `null` or `false` keeps the default: a generic `Error`, so server errors never reach the client unless you opt in. Throwing, rejecting, or returning something that can't be serialized also falls back to it. |

#### Constraints

Don't mutate the input until the tail has finished, and don't mutate the revived value until every block has been evaluated — blocks address existing objects by path.

Only native promises are streamed. Other thenables need a replacer that rebuilds them around a native promise:

```js
devalue.unevalStream(value, (v, js) => {
if (v instanceof MyThenable) return js`new MyThenable(${Promise.resolve(v)})`;
});
```

### `unflatten`

In the case where devalued data is one part of a larger JSON string, `unflatten` allows you to revive just the bit you need:
Expand Down
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export { uneval } from './uneval.js';
export { unevalStream } from './uneval-stream.js';
export { parse, unflatten } from './parse.js';
export { stringify, stringifyAsync } from './stringify.js';
export {
Expand Down
33 changes: 33 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,39 @@ export type UnevalReplacer = (
js: JavaScriptTag
) => JavaScriptFragment | false | null | void;

/** Options for `unevalStream`. */
export interface UnevalStreamOptions {
/** Session key. Pass a stable value when heads must be byte-identical across runs. */
id?: string;
/** Trusted assignable expression holding the client session table. Default `globalThis.__d`. */
scope?: string;
/**
* Stops the tail: the next `next()` yields a final block that rejects every promise
* still pending on the client with a generic Error and deletes the client session,
* then the tail is done. If the signal is already aborted, or aborts during the head
* emission, `unevalStream` throws `signal.reason`.
*/
signal?: AbortSignal;
/**
* Turns a rejection reason, or the error that kept a resolved value from serializing,
* into the value the client promise rejects with. May return a promise. Like a
* replacer, returning `undefined`, `null` or `false` keeps the default: the client
* promise rejects with a generic Error. So does throwing, rejecting, or returning
* something that can't be serialized.
*/
transformError?: (error: unknown) => unknown;
}

/** The result of `unevalStream`. */
export interface UnevalStreamResult {
/** A JavaScript expression that recreates the value, with every promise pending. */
head: string;
/** Statement blocks that settle the promises created by `head` and earlier blocks. */
tail: AsyncIterableIterator<string, void>;
/** The session key. */
id: string;
}

/**
* The introspection/extraction operations `stringify` performs on the value
* being serialized. Every dynamic operation — property reads, prototype
Expand Down
Loading
Loading