@pyreon/runtime-server 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -37
- package/lib/analysis/index.js.html +1 -1
- package/lib/index.js +72 -37
- package/lib/types/index.d.ts +37 -2
- package/package.json +3 -3
- package/src/index.ts +171 -18
- package/src/manifest.ts +9 -5
- package/src/tests/integration.test.ts +85 -0
- package/src/tests/ssr.test.ts +97 -0
package/README.md
CHANGED
|
@@ -1,73 +1,117 @@
|
|
|
1
1
|
# @pyreon/runtime-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
VNode → HTML renderer with progressive streaming and per-request `AsyncLocalStorage` isolation.
|
|
4
|
+
|
|
5
|
+
Walks a VNode tree and produces an HTML string or a Web-standard `ReadableStream` of chunks. Signal accessors are called synchronously to snapshot their current value — there is no reactivity on the server. `renderToStream` flushes progressively and resolves Suspense boundaries out-of-order (fallback first, then a `<template>` + inline swap script). Every `renderToString` / `renderToStream` / `runWithRequestContext` call runs in its own ALS store so concurrent requests never share `provide()` frames; `configureStoreIsolation()` extends the same isolation to the `@pyreon/store` registry. Most apps consume this transitively through `@pyreon/server.createHandler` or `@pyreon/zero` rather than calling directly.
|
|
4
6
|
|
|
5
7
|
## Install
|
|
6
8
|
|
|
7
9
|
```bash
|
|
8
|
-
bun add @pyreon/runtime-server
|
|
10
|
+
bun add @pyreon/runtime-server @pyreon/core @pyreon/reactivity
|
|
9
11
|
```
|
|
10
12
|
|
|
11
|
-
## Quick
|
|
13
|
+
## Quick start
|
|
12
14
|
|
|
13
|
-
```
|
|
14
|
-
import {
|
|
15
|
+
```ts
|
|
16
|
+
import {
|
|
17
|
+
renderToString, renderToStream, runWithRequestContext, configureStoreIsolation,
|
|
18
|
+
} from '@pyreon/runtime-server'
|
|
19
|
+
import { setStoreRegistryProvider } from '@pyreon/store'
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
21
|
+
// Once at server startup — wire per-request store isolation:
|
|
22
|
+
configureStoreIsolation(setStoreRegistryProvider)
|
|
19
23
|
|
|
24
|
+
// One-shot HTML
|
|
20
25
|
const html = await renderToString(<App />)
|
|
21
|
-
|
|
26
|
+
|
|
27
|
+
// Progressive stream
|
|
28
|
+
return new Response(renderToStream(<App />), {
|
|
29
|
+
headers: { 'content-type': 'text/html' },
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Pre-fetch loader data + render, all under one isolated request context
|
|
33
|
+
const html = await runWithRequestContext(async () => {
|
|
34
|
+
await prefetchLoaderData(router, url.pathname, request)
|
|
35
|
+
return renderToString(<App />)
|
|
36
|
+
})
|
|
22
37
|
```
|
|
23
38
|
|
|
24
|
-
##
|
|
39
|
+
## renderToString
|
|
25
40
|
|
|
26
|
-
|
|
41
|
+
```ts
|
|
42
|
+
const html = await renderToString(vnode): Promise<string>
|
|
43
|
+
```
|
|
27
44
|
|
|
28
|
-
|
|
45
|
+
One-shot HTML. Awaits async components. Each call gets its own context stack — no cross-request leakage even under high concurrency. Returns the complete document fragment for the rendered tree (the surrounding `<!doctype html>` shell is your responsibility).
|
|
29
46
|
|
|
30
|
-
|
|
47
|
+
## renderToStream
|
|
31
48
|
|
|
32
|
-
|
|
49
|
+
```ts
|
|
50
|
+
const stream = renderToStream(vnode): ReadableStream<string>
|
|
51
|
+
```
|
|
33
52
|
|
|
34
|
-
|
|
35
|
-
import { renderToStream } from '@pyreon/runtime-server'
|
|
53
|
+
Progressive flush. Synchronous subtrees stream as soon as they're rendered. `<Suspense>` boundaries are streamed **out-of-order**: the fallback is emitted in-place, and when the async work resolves the resolved children arrive later as a `<template>` element followed by an inline `<script>` that swaps the template into the original slot. The browser parses both inline-and-resolved without needing a second request.
|
|
36
54
|
|
|
37
|
-
|
|
38
|
-
return new Response(
|
|
39
|
-
headers: { '
|
|
55
|
+
```ts
|
|
56
|
+
return new Response(renderToStream(<App />), {
|
|
57
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
40
58
|
})
|
|
41
59
|
```
|
|
42
60
|
|
|
43
|
-
|
|
61
|
+
**30-second Suspense timeout**: if a boundary hasn't resolved within 30 seconds, the fallback stays in place forever and a dev-mode warning fires. No error is thrown — the stream completes cleanly with the fallback persisted.
|
|
44
62
|
|
|
45
|
-
|
|
63
|
+
## Per-request context isolation
|
|
46
64
|
|
|
47
|
-
|
|
48
|
-
import { runWithRequestContext } from '@pyreon/runtime-server'
|
|
65
|
+
Pyreon's `provide()` / `useContext` use a module-level context stack at runtime — fine for a browser process with one document. On the server, concurrent requests would share that stack. `runtime-server` wraps each render in an `AsyncLocalStorage` store, so every `renderToString` / `renderToStream` / `runWithRequestContext` call gets its own isolated context frames.
|
|
49
66
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
```ts
|
|
68
|
+
// Outside any render? Use runWithRequestContext to isolate manual API calls
|
|
69
|
+
await runWithRequestContext(async () => {
|
|
70
|
+
router.preload(pathname, request)
|
|
71
|
+
return renderToString(<App />)
|
|
53
72
|
})
|
|
54
73
|
```
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
Wire up per-request store isolation for concurrent SSR. Call once at server startup with a function that hooks your store registry into the request-scoped `AsyncLocalStorage`. Prevents store state from leaking between requests.
|
|
75
|
+
## Store isolation
|
|
59
76
|
|
|
60
77
|
```ts
|
|
61
78
|
import { configureStoreIsolation } from '@pyreon/runtime-server'
|
|
79
|
+
import { setStoreRegistryProvider } from '@pyreon/store'
|
|
62
80
|
|
|
63
|
-
configureStoreIsolation(
|
|
64
|
-
// Wire your store registry to use the per-request provider
|
|
65
|
-
})
|
|
81
|
+
configureStoreIsolation(setStoreRegistryProvider) // once at startup
|
|
66
82
|
```
|
|
67
83
|
|
|
68
|
-
|
|
84
|
+
Without this call, the `@pyreon/store` registry is a process-global singleton — concurrent requests would share defined stores. `configureStoreIsolation` plumbs the registry through the same ALS, so each request gets its own store map. **Call once at startup**, not per request. Skip this if you don't use `@pyreon/store`.
|
|
85
|
+
|
|
86
|
+
## SSR-safe contracts the renderer enforces
|
|
87
|
+
|
|
88
|
+
- **No reactivity.** Signal accessors are snapshotted at render time. No effects are created on the server. `useHead`, `useStore`, and similar APIs run their setup once and read the resulting value.
|
|
89
|
+
- **HTML escape + URL sanitization.** All text content is escaped. URL attributes (`href`, `src`, `action`, `formaction`) reject `javascript:` and `data:` URIs by default.
|
|
90
|
+
- **Event handlers omitted.** `on*` props are stripped from the output — the server can't bind them. Hydration on the client wires them up.
|
|
91
|
+
- **`<For>` key markers** (`<!--k:KEY-->`) are URL-encoded so user-controlled keys cannot break out of the HTML comment. Companion `decodeKeyFromMarker(comment)` helper available for hydration / devtools consumers.
|
|
92
|
+
- **Compiler-emitted reactive props** are resolved via `makeReactiveProps` before each component invocation — parity with the CSR mount.ts path. SSR-rendered HTML matches what the client would render.
|
|
93
|
+
|
|
94
|
+
## When to use this directly
|
|
95
|
+
|
|
96
|
+
Most Pyreon apps use:
|
|
97
|
+
|
|
98
|
+
- **`@pyreon/server.createHandler`** — full SSR request handler with loader prefetching, error handling, head injection
|
|
99
|
+
- **`@pyreon/zero`** — full meta-framework wrapping the above plus routing, SSG, ISR
|
|
100
|
+
|
|
101
|
+
Use `@pyreon/runtime-server` directly when you need to:
|
|
102
|
+
|
|
103
|
+
- Render a fragment for a non-page response (RSS feed, OG image SVG, email body)
|
|
104
|
+
- Compose your own custom SSR pipeline outside of `@pyreon/server`
|
|
105
|
+
- Generate static HTML at build time without an HTTP layer
|
|
106
|
+
|
|
107
|
+
## Dev-mode gates
|
|
108
|
+
|
|
109
|
+
Server packages use `typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'` for dev-only diagnostics — the server-runtime convention. Server code doesn't go through Vite's bundle-time replacement (it runs in Node at startup), so the typeof guard reads correctly at runtime.
|
|
110
|
+
|
|
111
|
+
## Documentation
|
|
112
|
+
|
|
113
|
+
Full docs: [docs.pyreon.dev/docs/runtime-server](https://docs.pyreon.dev/docs/runtime-server) (or `docs/docs/runtime-server.md` in this repo).
|
|
114
|
+
|
|
115
|
+
## License
|
|
69
116
|
|
|
70
|
-
|
|
71
|
-
- Async component functions (`async function Component()`) are fully supported.
|
|
72
|
-
- Context isolation uses `AsyncLocalStorage` internally. Each `renderToString` and `renderToStream` call gets its own context stack.
|
|
73
|
-
- HTML output is escaped. Event handlers (`on*`) are omitted. URL attributes are sanitized against `javascript:` and `data:` URIs.
|
|
117
|
+
MIT
|
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src/index.ts","uid":"7e676d25-1"}]}],"isRoot":true},"nodeParts":{"7e676d25-1":{"renderedLength":17671,"gzipLength":5697,"brotliLength":0,"metaUid":"7e676d25-0"}},"nodeMetas":{"7e676d25-0":{"id":"/src/index.ts","moduleParts":{"index.js":"7e676d25-1"},"imported":[{"uid":"7e676d25-2"},{"uid":"7e676d25-3"}],"importedBy":[],"isEntry":true},"7e676d25-2":{"id":"node:async_hooks","moduleParts":{},"imported":[],"importedBy":[{"uid":"7e676d25-0"}]},"7e676d25-3":{"id":"@pyreon/core","moduleParts":{},"imported":[],"importedBy":[{"uid":"7e676d25-0"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
-
import { ForSymbol, Fragment, Suspense,
|
|
2
|
+
import { ForSymbol, Fragment, Suspense, cx, getContextStackLength, makeReactiveProps, normalizeStyleValue, popContext, runWithHooks, setContextStackProvider } from "@pyreon/core";
|
|
3
3
|
|
|
4
4
|
//#region src/index.ts
|
|
5
5
|
/**
|
|
@@ -55,36 +55,56 @@ async function renderToString(root) {
|
|
|
55
55
|
function runWithRequestContext(fn) {
|
|
56
56
|
return withStoreContext(() => _contextAls.run([], fn));
|
|
57
57
|
}
|
|
58
|
-
|
|
59
|
-
* Render a VNode tree to a Web-standard ReadableStream of HTML chunks.
|
|
60
|
-
*
|
|
61
|
-
* True progressive streaming: HTML is flushed to the client as soon as each
|
|
62
|
-
* node is ready. Synchronous subtrees are enqueued immediately; async component
|
|
63
|
-
* boundaries are awaited in-order and their output is enqueued as it resolves.
|
|
64
|
-
*
|
|
65
|
-
* Suspense boundaries are streamed out-of-order: the fallback is emitted
|
|
66
|
-
* immediately, and the resolved children are sent as a `<template>` + inline
|
|
67
|
-
* swap script once ready — without blocking the rest of the page.
|
|
68
|
-
*
|
|
69
|
-
* Each renderToStream call gets its own isolated ALS context stack.
|
|
70
|
-
*/
|
|
71
|
-
function renderToStream(root) {
|
|
58
|
+
function renderToStream(root, options = {}) {
|
|
72
59
|
if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.stream");
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
60
|
+
const ac = new AbortController();
|
|
61
|
+
const signal = ac.signal;
|
|
62
|
+
if (options.signal) if (options.signal.aborted) ac.abort(options.signal.reason);
|
|
63
|
+
else options.signal.addEventListener("abort", () => ac.abort(options.signal.reason), { once: true });
|
|
64
|
+
const userTimeout = options.suspenseTimeoutMs;
|
|
65
|
+
const suspenseTimeoutMs = userTimeout === Infinity ? Infinity : userTimeout !== void 0 && Number.isFinite(userTimeout) && userTimeout > 0 ? userTimeout : 3e4;
|
|
66
|
+
return new ReadableStream({
|
|
67
|
+
start(controller) {
|
|
68
|
+
const enqueue = (chunk) => {
|
|
69
|
+
if (signal.aborted) return;
|
|
70
|
+
controller.enqueue(chunk);
|
|
71
|
+
};
|
|
72
|
+
let bid = 0;
|
|
73
|
+
const ctx = {
|
|
74
|
+
pending: [],
|
|
75
|
+
nextId: () => bid++,
|
|
76
|
+
mainEnqueue: enqueue,
|
|
77
|
+
suspenseDepth: 0,
|
|
78
|
+
signal,
|
|
79
|
+
suspenseTimeoutMs
|
|
80
|
+
};
|
|
81
|
+
const abortPromise = signal.aborted ? Promise.resolve() : new Promise((resolve) => {
|
|
82
|
+
signal.addEventListener("abort", () => resolve(), { once: true });
|
|
83
|
+
});
|
|
84
|
+
return withStoreContext(() => _contextAls.run([], () => _streamCtxAls.run(ctx, async () => {
|
|
85
|
+
await streamNode(root, enqueue);
|
|
86
|
+
while (ctx.pending.length > 0) {
|
|
87
|
+
if (signal.aborted) break;
|
|
88
|
+
const batch = Promise.all(ctx.pending.splice(0));
|
|
89
|
+
await Promise.race([batch, abortPromise]);
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
controller.close();
|
|
93
|
+
} catch {}
|
|
94
|
+
}).catch((err) => {
|
|
95
|
+
if (signal.aborted) {
|
|
96
|
+
try {
|
|
97
|
+
controller.close();
|
|
98
|
+
} catch {}
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
controller.error(err);
|
|
102
|
+
})));
|
|
103
|
+
},
|
|
104
|
+
cancel(reason) {
|
|
105
|
+
ac.abort(reason);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
88
108
|
}
|
|
89
109
|
async function streamVNode(vnode, enqueue) {
|
|
90
110
|
if (vnode.type === Fragment) {
|
|
@@ -116,7 +136,7 @@ async function streamComponentNode(vnode, enqueue) {
|
|
|
116
136
|
return;
|
|
117
137
|
}
|
|
118
138
|
if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.component");
|
|
119
|
-
const stackLenBefore =
|
|
139
|
+
const stackLenBefore = getContextStackLength();
|
|
120
140
|
try {
|
|
121
141
|
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
122
142
|
const resolved = output instanceof Promise ? await output : output;
|
|
@@ -187,7 +207,7 @@ async function streamSuspenseBoundary(vnode, enqueue) {
|
|
|
187
207
|
const { fallback, children } = vnode.props;
|
|
188
208
|
/* c8 ignore start */
|
|
189
209
|
if (!ctx) {
|
|
190
|
-
const stackLenBefore =
|
|
210
|
+
const stackLenBefore = getContextStackLength();
|
|
191
211
|
const { vnode: output } = runWithHooks(Suspense, vnode.props);
|
|
192
212
|
try {
|
|
193
213
|
if (output !== null) await streamNode(output, enqueue);
|
|
@@ -204,18 +224,33 @@ async function streamSuspenseBoundary(vnode, enqueue) {
|
|
|
204
224
|
await streamNode(fallback ?? null, enqueue);
|
|
205
225
|
mainEnqueue("</div>");
|
|
206
226
|
const ctxStore = _contextAls.getStore() ?? [];
|
|
207
|
-
const
|
|
227
|
+
const suspenseTimeoutMs = ctx.suspenseTimeoutMs;
|
|
208
228
|
ctx.pending.push(_contextAls.run(ctxStore, async () => {
|
|
209
229
|
try {
|
|
210
230
|
ctx.suspenseDepth++;
|
|
211
231
|
const buf = [];
|
|
212
|
-
|
|
232
|
+
let result;
|
|
233
|
+
if (suspenseTimeoutMs === Infinity) {
|
|
234
|
+
await streamNode(children ?? null, (s) => buf.push(s));
|
|
235
|
+
result = "resolved";
|
|
236
|
+
} else {
|
|
237
|
+
let timeoutId;
|
|
238
|
+
try {
|
|
239
|
+
result = await Promise.race([streamNode(children ?? null, (s) => buf.push(s)).then(() => "resolved"), new Promise((resolve) => {
|
|
240
|
+
timeoutId = setTimeout(() => resolve("timeout"), suspenseTimeoutMs);
|
|
241
|
+
})]);
|
|
242
|
+
} finally {
|
|
243
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (result === "timeout") {
|
|
213
247
|
if (__DEV__) {
|
|
214
248
|
_countSink.__pyreon_count__?.("runtime-server.suspense.fallback");
|
|
215
|
-
console.warn(`[Pyreon SSR] Suspense boundary timed out after ${
|
|
249
|
+
console.warn(`[Pyreon SSR] Suspense boundary timed out after ${suspenseTimeoutMs}ms — fallback will remain.`);
|
|
216
250
|
}
|
|
217
251
|
return;
|
|
218
252
|
}
|
|
253
|
+
if (ctx.signal?.aborted) return;
|
|
219
254
|
mainEnqueue(`<template id="pyreon-t-${id}">${buf.join("").replace(/<\/template/gi, "<\\/template")}</template>`);
|
|
220
255
|
mainEnqueue(`<script>__NS("pyreon-s-${id}","pyreon-t-${id}")<\/script>`);
|
|
221
256
|
} catch (err) {
|
|
@@ -260,7 +295,7 @@ async function renderChildren(children) {
|
|
|
260
295
|
}
|
|
261
296
|
async function renderComponent(vnode) {
|
|
262
297
|
if (__DEV__) _countSink.__pyreon_count__?.("runtime-server.component");
|
|
263
|
-
const stackLenBefore =
|
|
298
|
+
const stackLenBefore = getContextStackLength();
|
|
264
299
|
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode));
|
|
265
300
|
let html;
|
|
266
301
|
try {
|
|
@@ -288,7 +323,7 @@ async function renderComponent(vnode) {
|
|
|
288
323
|
* effect of `provide()` (its context frame) without firing other hooks.
|
|
289
324
|
*/
|
|
290
325
|
function trimContextStack(targetLen) {
|
|
291
|
-
let current =
|
|
326
|
+
let current = getContextStackLength();
|
|
292
327
|
while (current > targetLen) {
|
|
293
328
|
popContext();
|
|
294
329
|
current--;
|
package/lib/types/index.d.ts
CHANGED
|
@@ -31,7 +31,42 @@ declare function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T>;
|
|
|
31
31
|
*
|
|
32
32
|
* Each renderToStream call gets its own isolated ALS context stack.
|
|
33
33
|
*/
|
|
34
|
-
|
|
34
|
+
interface RenderToStreamOptions {
|
|
35
|
+
/**
|
|
36
|
+
* AbortSignal to cancel in-flight Suspense work when the client
|
|
37
|
+
* disconnects (browser back-button, navigation, fetch reader closes,
|
|
38
|
+
* etc.). On abort, the stream's `cancel()` fires the signal —
|
|
39
|
+
* pending Suspense boundaries are abandoned (the fallback HTML they
|
|
40
|
+
* already wrote stays in the buffer that's now closed) and the
|
|
41
|
+
* background async work they spawned is no longer awaited. The work
|
|
42
|
+
* itself isn't forcibly killed (JS has no async cancellation
|
|
43
|
+
* primitive at the language level), but the framework stops blocking
|
|
44
|
+
* on it. Pass your own signal from upstream (e.g. a `Request.signal`)
|
|
45
|
+
* to chain abort propagation.
|
|
46
|
+
*/
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
/**
|
|
49
|
+
* Per-boundary Suspense timeout in milliseconds. When an async
|
|
50
|
+
* Suspense child doesn't resolve within this window, its fallback
|
|
51
|
+
* stays visible (the resolved content is dropped — no `<template>`
|
|
52
|
+
* + swap script is emitted) and a dev-mode warning fires. Defaults
|
|
53
|
+
* to 30_000 (30s); unset behavior is byte-identical to the
|
|
54
|
+
* pre-config implementation.
|
|
55
|
+
*
|
|
56
|
+
* Ops control: tighten this for tight-SLA deploys (5_000–10_000 is
|
|
57
|
+
* typical for user-facing apps where a fallback is preferable to a
|
|
58
|
+
* delayed full render). Loosen it (or pass `Infinity` to disable)
|
|
59
|
+
* for renders that legitimately need long async work — exports,
|
|
60
|
+
* reports, scheduled jobs, etc.
|
|
61
|
+
*
|
|
62
|
+
* Values ≤0 or `NaN` fall back to the default. `Infinity` is honored
|
|
63
|
+
* verbatim — the timeout race is skipped entirely so a hung boundary
|
|
64
|
+
* keeps the stream open until the AbortSignal fires or the consumer
|
|
65
|
+
* cancels.
|
|
66
|
+
*/
|
|
67
|
+
suspenseTimeoutMs?: number;
|
|
68
|
+
}
|
|
69
|
+
declare function renderToStream(root: VNode | null, options?: RenderToStreamOptions): ReadableStream<string>;
|
|
35
70
|
/**
|
|
36
71
|
* Inverse of `safeKeyForMarker` — decode a marker-safe key back to the
|
|
37
72
|
* original string. Not used by runtime today (hydration does not read
|
|
@@ -41,5 +76,5 @@ declare function renderToStream(root: VNode | null): ReadableStream<string>;
|
|
|
41
76
|
*/
|
|
42
77
|
declare function decodeKeyFromMarker(encoded: string): string;
|
|
43
78
|
//#endregion
|
|
44
|
-
export { configureStoreIsolation, decodeKeyFromMarker, renderToStream, renderToString, runWithRequestContext };
|
|
79
|
+
export { RenderToStreamOptions, configureStoreIsolation, decodeKeyFromMarker, renderToStream, renderToString, runWithRequestContext };
|
|
45
80
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "SSR/SSG renderer for Pyreon — streaming HTML + static generation",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-server#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
"prepublishOnly": "bun run build"
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
|
-
"@pyreon/core": "^0.
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
46
|
+
"@pyreon/core": "^0.24.0",
|
|
47
|
+
"@pyreon/reactivity": "^0.24.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@pyreon/manifest": "0.13.1"
|
package/src/index.ts
CHANGED
|
@@ -16,10 +16,10 @@
|
|
|
16
16
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
17
17
|
import type { ClassValue, ComponentFn, ForProps, VNode, VNodeChild } from '@pyreon/core'
|
|
18
18
|
import {
|
|
19
|
-
captureContextStack,
|
|
20
19
|
cx,
|
|
21
20
|
ForSymbol,
|
|
22
21
|
Fragment,
|
|
22
|
+
getContextStackLength,
|
|
23
23
|
makeReactiveProps,
|
|
24
24
|
normalizeStyleValue,
|
|
25
25
|
popContext,
|
|
@@ -46,6 +46,22 @@ interface StreamCtx {
|
|
|
46
46
|
mainEnqueue: (s: string) => void
|
|
47
47
|
/** Depth counter — non-zero when rendering inside a Suspense child resolution. */
|
|
48
48
|
suspenseDepth: number
|
|
49
|
+
/**
|
|
50
|
+
* Abort signal fired when EITHER the upstream caller's signal aborts
|
|
51
|
+
* OR the stream consumer (`ReadableStream.cancel()`) closes. Boundary
|
|
52
|
+
* resolvers check this before enqueuing post-resolve HTML so they
|
|
53
|
+
* stop streaming once the client has hung up.
|
|
54
|
+
*/
|
|
55
|
+
signal?: AbortSignal
|
|
56
|
+
/**
|
|
57
|
+
* Per-boundary Suspense timeout (ms). When an async Suspense child
|
|
58
|
+
* doesn't resolve within this window, the fallback stays visible and
|
|
59
|
+
* the resolved content is dropped. Set to `Infinity` to disable the
|
|
60
|
+
* timeout entirely (apps that prefer waiting indefinitely over showing
|
|
61
|
+
* the fallback). Defaults to 30_000 (30s) — matches the pre-config
|
|
62
|
+
* hard-coded value, so unset is byte-identical to prior behavior.
|
|
63
|
+
*/
|
|
64
|
+
suspenseTimeoutMs: number
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
const _streamCtxAls = new AsyncLocalStorage<StreamCtx>()
|
|
@@ -119,33 +135,140 @@ export function runWithRequestContext<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
119
135
|
*
|
|
120
136
|
* Each renderToStream call gets its own isolated ALS context stack.
|
|
121
137
|
*/
|
|
122
|
-
export
|
|
138
|
+
export interface RenderToStreamOptions {
|
|
139
|
+
/**
|
|
140
|
+
* AbortSignal to cancel in-flight Suspense work when the client
|
|
141
|
+
* disconnects (browser back-button, navigation, fetch reader closes,
|
|
142
|
+
* etc.). On abort, the stream's `cancel()` fires the signal —
|
|
143
|
+
* pending Suspense boundaries are abandoned (the fallback HTML they
|
|
144
|
+
* already wrote stays in the buffer that's now closed) and the
|
|
145
|
+
* background async work they spawned is no longer awaited. The work
|
|
146
|
+
* itself isn't forcibly killed (JS has no async cancellation
|
|
147
|
+
* primitive at the language level), but the framework stops blocking
|
|
148
|
+
* on it. Pass your own signal from upstream (e.g. a `Request.signal`)
|
|
149
|
+
* to chain abort propagation.
|
|
150
|
+
*/
|
|
151
|
+
signal?: AbortSignal
|
|
152
|
+
/**
|
|
153
|
+
* Per-boundary Suspense timeout in milliseconds. When an async
|
|
154
|
+
* Suspense child doesn't resolve within this window, its fallback
|
|
155
|
+
* stays visible (the resolved content is dropped — no `<template>`
|
|
156
|
+
* + swap script is emitted) and a dev-mode warning fires. Defaults
|
|
157
|
+
* to 30_000 (30s); unset behavior is byte-identical to the
|
|
158
|
+
* pre-config implementation.
|
|
159
|
+
*
|
|
160
|
+
* Ops control: tighten this for tight-SLA deploys (5_000–10_000 is
|
|
161
|
+
* typical for user-facing apps where a fallback is preferable to a
|
|
162
|
+
* delayed full render). Loosen it (or pass `Infinity` to disable)
|
|
163
|
+
* for renders that legitimately need long async work — exports,
|
|
164
|
+
* reports, scheduled jobs, etc.
|
|
165
|
+
*
|
|
166
|
+
* Values ≤0 or `NaN` fall back to the default. `Infinity` is honored
|
|
167
|
+
* verbatim — the timeout race is skipped entirely so a hung boundary
|
|
168
|
+
* keeps the stream open until the AbortSignal fires or the consumer
|
|
169
|
+
* cancels.
|
|
170
|
+
*/
|
|
171
|
+
suspenseTimeoutMs?: number
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function renderToStream(
|
|
175
|
+
root: VNode | null,
|
|
176
|
+
options: RenderToStreamOptions = {},
|
|
177
|
+
): ReadableStream<string> {
|
|
123
178
|
if (__DEV__) _countSink.__pyreon_count__?.('runtime-server.stream')
|
|
179
|
+
// Internal AbortController — fires when EITHER the caller's signal
|
|
180
|
+
// aborts (upstream cancellation, e.g. `Request.signal`) OR the consumer
|
|
181
|
+
// of the stream calls `.cancel()` (client closed the fetch reader).
|
|
182
|
+
const ac = new AbortController()
|
|
183
|
+
const signal = ac.signal
|
|
184
|
+
if (options.signal) {
|
|
185
|
+
if (options.signal.aborted) ac.abort(options.signal.reason)
|
|
186
|
+
else options.signal.addEventListener('abort', () => ac.abort(options.signal!.reason), { once: true })
|
|
187
|
+
}
|
|
188
|
+
// Resolve the Suspense timeout. Invalid input (≤0, NaN) falls back to
|
|
189
|
+
// 30_000 — same as pre-config behavior. `Infinity` is preserved so the
|
|
190
|
+
// boundary code can detect it and skip the race entirely.
|
|
191
|
+
const userTimeout = options.suspenseTimeoutMs
|
|
192
|
+
const suspenseTimeoutMs
|
|
193
|
+
= userTimeout === Infinity
|
|
194
|
+
? Infinity
|
|
195
|
+
: userTimeout !== undefined && Number.isFinite(userTimeout) && userTimeout > 0
|
|
196
|
+
? userTimeout
|
|
197
|
+
: 30_000
|
|
198
|
+
|
|
124
199
|
return new ReadableStream<string>({
|
|
125
200
|
start(controller) {
|
|
126
|
-
const enqueue = (chunk: string) =>
|
|
201
|
+
const enqueue = (chunk: string) => {
|
|
202
|
+
if (signal.aborted) return // stop appending after abort
|
|
203
|
+
controller.enqueue(chunk)
|
|
204
|
+
}
|
|
127
205
|
let bid = 0
|
|
128
206
|
const ctx: StreamCtx = {
|
|
129
207
|
pending: [],
|
|
130
208
|
nextId: () => bid++,
|
|
131
209
|
mainEnqueue: enqueue,
|
|
132
210
|
suspenseDepth: 0,
|
|
211
|
+
signal,
|
|
212
|
+
suspenseTimeoutMs,
|
|
133
213
|
}
|
|
214
|
+
// One shared abort-promise — registered ONCE, resolved on signal
|
|
215
|
+
// abort. Racing each pending batch against this lets the drain
|
|
216
|
+
// loop exit promptly when the consumer hangs up, without accruing
|
|
217
|
+
// one abort listener per loop iteration.
|
|
218
|
+
const abortPromise: Promise<void> = signal.aborted
|
|
219
|
+
? Promise.resolve()
|
|
220
|
+
: new Promise<void>((resolve) => {
|
|
221
|
+
signal.addEventListener('abort', () => resolve(), { once: true })
|
|
222
|
+
})
|
|
223
|
+
|
|
134
224
|
return withStoreContext(() =>
|
|
135
225
|
_contextAls.run([], () =>
|
|
136
226
|
_streamCtxAls
|
|
137
227
|
.run(ctx, async () => {
|
|
138
228
|
await streamNode(root, enqueue)
|
|
139
|
-
// Drain all pending Suspense resolutions (may spawn nested
|
|
229
|
+
// Drain all pending Suspense resolutions (may spawn nested
|
|
230
|
+
// ones). Each batch is RACED against the abort signal so a
|
|
231
|
+
// mid-flight Suspense child doesn't keep us blocked after
|
|
232
|
+
// the consumer hung up. Per-boundary work also checks
|
|
233
|
+
// `ctx.signal.aborted` to skip its post-resolve enqueue.
|
|
140
234
|
while (ctx.pending.length > 0) {
|
|
141
|
-
|
|
235
|
+
if (signal.aborted) break
|
|
236
|
+
const batch = Promise.all(ctx.pending.splice(0))
|
|
237
|
+
await Promise.race([batch, abortPromise])
|
|
238
|
+
}
|
|
239
|
+
// ALWAYS close — gracefully on natural completion AND on
|
|
240
|
+
// abort. (Pre-fix: `if (!aborted) close()` left the stream
|
|
241
|
+
// open forever on cancel, hanging the reader.) Wrap in
|
|
242
|
+
// try/catch because the stream may have already been
|
|
243
|
+
// closed by `cancel()` upstream.
|
|
244
|
+
try {
|
|
245
|
+
controller.close()
|
|
246
|
+
} catch {
|
|
247
|
+
/* already closed (e.g. cancel raced ahead) */
|
|
142
248
|
}
|
|
143
|
-
controller.close()
|
|
144
249
|
})
|
|
145
|
-
.catch((err) =>
|
|
250
|
+
.catch((err) => {
|
|
251
|
+
// Aborts are expected, not errors — close silently to mirror
|
|
252
|
+
// a normal end-of-stream when the consumer hung up.
|
|
253
|
+
if (signal.aborted) {
|
|
254
|
+
try {
|
|
255
|
+
controller.close()
|
|
256
|
+
} catch {
|
|
257
|
+
/* already closed */
|
|
258
|
+
}
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
controller.error(err)
|
|
262
|
+
}),
|
|
146
263
|
),
|
|
147
264
|
)
|
|
148
265
|
},
|
|
266
|
+
cancel(reason) {
|
|
267
|
+
// Consumer (browser fetch reader) closed the stream — propagate to
|
|
268
|
+
// the internal controller so in-flight Suspense work stops being
|
|
269
|
+
// awaited.
|
|
270
|
+
ac.abort(reason)
|
|
271
|
+
},
|
|
149
272
|
})
|
|
150
273
|
}
|
|
151
274
|
|
|
@@ -196,7 +319,7 @@ async function streamComponentNode(vnode: VNode, enqueue: (s: string) => void):
|
|
|
196
319
|
// remove its registered tags from the head store; running it during SSR
|
|
197
320
|
// wipes the entries before `renderWithHead` reads them). See `renderComponent`
|
|
198
321
|
// for the full architectural rationale.
|
|
199
|
-
const stackLenBefore =
|
|
322
|
+
const stackLenBefore = getContextStackLength()
|
|
200
323
|
try {
|
|
201
324
|
const { vnode: output } = runWithHooks(
|
|
202
325
|
vnode.type as ComponentFn,
|
|
@@ -309,7 +432,7 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
|
|
|
309
432
|
// signal).
|
|
310
433
|
/* c8 ignore start */
|
|
311
434
|
if (!ctx) {
|
|
312
|
-
const stackLenBefore =
|
|
435
|
+
const stackLenBefore = getContextStackLength()
|
|
313
436
|
const { vnode: output } = runWithHooks(Suspense as ComponentFn, vnode.props)
|
|
314
437
|
try {
|
|
315
438
|
if (output !== null) await streamNode(output, enqueue)
|
|
@@ -337,7 +460,10 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
|
|
|
337
460
|
// Queue async resolution — runs in parallel, emits to main stream when done.
|
|
338
461
|
// Errors are caught per-boundary so one failing Suspense doesn't abort the stream.
|
|
339
462
|
// Timeout prevents hung async children from keeping the stream open forever.
|
|
340
|
-
|
|
463
|
+
// Configurable via `RenderToStreamOptions.suspenseTimeoutMs` (default 30_000;
|
|
464
|
+
// `Infinity` disables the race so a hung boundary waits indefinitely until
|
|
465
|
+
// the upstream AbortSignal or consumer cancel fires).
|
|
466
|
+
const suspenseTimeoutMs = ctx.suspenseTimeoutMs
|
|
341
467
|
|
|
342
468
|
ctx.pending.push(
|
|
343
469
|
_contextAls.run(ctxStore, async () => {
|
|
@@ -345,23 +471,50 @@ async function streamSuspenseBoundary(vnode: VNode, enqueue: (s: string) => void
|
|
|
345
471
|
ctx.suspenseDepth++
|
|
346
472
|
const buf: string[] = []
|
|
347
473
|
|
|
348
|
-
// Race the async children against a timeout
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
474
|
+
// Race the async children against a timeout (skipped when the
|
|
475
|
+
// user passed `Infinity` to opt out). Class I — capture the
|
|
476
|
+
// timer id and clear on the success path; without this, every
|
|
477
|
+
// successful Suspense boundary leaks a pending timer + resolve
|
|
478
|
+
// callback until it fires. Caught by the `audit-leak-classes`
|
|
479
|
+
// script's promise-race-no-clear detector.
|
|
480
|
+
let result: 'resolved' | 'timeout'
|
|
481
|
+
if (suspenseTimeoutMs === Infinity) {
|
|
482
|
+
// No-timeout mode: just await children. AbortSignal still
|
|
483
|
+
// applies via the outer drain-loop race in renderToStream.
|
|
484
|
+
await streamNode(children ?? null, (s) => buf.push(s))
|
|
485
|
+
result = 'resolved'
|
|
486
|
+
} else {
|
|
487
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined
|
|
488
|
+
try {
|
|
489
|
+
result = await Promise.race([
|
|
490
|
+
streamNode(children ?? null, (s) => buf.push(s)).then(() => 'resolved' as const),
|
|
491
|
+
new Promise<'timeout'>((resolve) => {
|
|
492
|
+
timeoutId = setTimeout(() => resolve('timeout'), suspenseTimeoutMs)
|
|
493
|
+
}),
|
|
494
|
+
])
|
|
495
|
+
}
|
|
496
|
+
finally {
|
|
497
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
|
498
|
+
}
|
|
499
|
+
}
|
|
353
500
|
|
|
354
501
|
if (result === 'timeout') {
|
|
355
502
|
if (__DEV__) {
|
|
356
503
|
_countSink.__pyreon_count__?.('runtime-server.suspense.fallback')
|
|
357
504
|
console.warn(
|
|
358
|
-
`[Pyreon SSR] Suspense boundary timed out after ${
|
|
505
|
+
`[Pyreon SSR] Suspense boundary timed out after ${suspenseTimeoutMs}ms — fallback will remain.`,
|
|
359
506
|
)
|
|
360
507
|
}
|
|
361
508
|
// Fallback stays visible — no swap
|
|
362
509
|
return
|
|
363
510
|
}
|
|
364
511
|
|
|
512
|
+
// Client disconnected (or upstream aborted) while we were
|
|
513
|
+
// resolving — don't bother enqueueing the post-resolve content.
|
|
514
|
+
// The drain loop also checks `signal.aborted` so the stream
|
|
515
|
+
// closes promptly without us racing it.
|
|
516
|
+
if (ctx.signal?.aborted) return
|
|
517
|
+
|
|
365
518
|
// Escape </template> in buffered content to prevent early close + XSS
|
|
366
519
|
const content = buf.join('').replace(/<\/template/gi, '<\\/template')
|
|
367
520
|
mainEnqueue(`<template id="pyreon-t-${id}">${content}</template>`)
|
|
@@ -455,7 +608,7 @@ async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<st
|
|
|
455
608
|
// (the response ships, the process moves on); user-registered cleanup
|
|
456
609
|
// is for the CSR lifecycle. `provide()`'s frame cleanup is the only
|
|
457
610
|
// SSR-visible side effect and we handle it structurally below.
|
|
458
|
-
const stackLenBefore =
|
|
611
|
+
const stackLenBefore = getContextStackLength()
|
|
459
612
|
const { vnode: output } = runWithHooks(vnode.type, mergeChildrenIntoProps(vnode))
|
|
460
613
|
|
|
461
614
|
// Async component function (async function Component()) — await the promise
|
|
@@ -489,7 +642,7 @@ async function renderComponent(vnode: VNode & { type: ComponentFn }): Promise<st
|
|
|
489
642
|
* effect of `provide()` (its context frame) without firing other hooks.
|
|
490
643
|
*/
|
|
491
644
|
function trimContextStack(targetLen: number): void {
|
|
492
|
-
let current =
|
|
645
|
+
let current = getContextStackLength()
|
|
493
646
|
while (current > targetLen) {
|
|
494
647
|
popContext()
|
|
495
648
|
current--
|
package/src/manifest.ts
CHANGED
|
@@ -10,7 +10,7 @@ export default defineManifest({
|
|
|
10
10
|
category: 'server',
|
|
11
11
|
features: [
|
|
12
12
|
'renderToString(vnode) → Promise<string> — one-shot HTML, awaits async components',
|
|
13
|
-
'renderToStream(vnode) → ReadableStream<string> — progressive, out-of-order Suspense (30s timeout
|
|
13
|
+
'renderToStream(vnode, { signal?, suspenseTimeoutMs? }) → ReadableStream<string> — progressive, out-of-order Suspense (default 30s per-boundary timeout, configurable; signal threads AbortSignal end-to-end)',
|
|
14
14
|
'Per-request ALS context isolation — concurrent requests never share provide() frames',
|
|
15
15
|
'runWithRequestContext(fn) — isolated context+store for Pyreon APIs called outside renderToString',
|
|
16
16
|
'configureStoreIsolation(setStoreRegistryProvider) — opt-in per-request @pyreon/store isolation',
|
|
@@ -38,19 +38,23 @@ const html = await renderToString(<App />)`,
|
|
|
38
38
|
{
|
|
39
39
|
name: 'renderToStream',
|
|
40
40
|
kind: 'function',
|
|
41
|
-
signature: 'renderToStream(root: VNode | null): ReadableStream<string>',
|
|
41
|
+
signature: 'renderToStream(root: VNode | null, options?: { signal?: AbortSignal; suspenseTimeoutMs?: number }): ReadableStream<string>',
|
|
42
42
|
summary:
|
|
43
|
-
'Render to a Web-standard `ReadableStream<string>` with true progressive flushing — synchronous subtrees enqueue immediately, async component boundaries are awaited in order. Suspense boundaries stream OUT OF ORDER: the fallback is emitted inline at once, and the resolved children arrive later as a `<template>` + a tiny inline swap `<script>` that replaces the placeholder client-side — without blocking the rest of the page. Each call gets its own isolated ALS context stack. A Suspense boundary that does not resolve within
|
|
43
|
+
'Render to a Web-standard `ReadableStream<string>` with true progressive flushing — synchronous subtrees enqueue immediately, async component boundaries are awaited in order. Suspense boundaries stream OUT OF ORDER: the fallback is emitted inline at once, and the resolved children arrive later as a `<template>` + a tiny inline swap `<script>` that replaces the placeholder client-side — without blocking the rest of the page. Each call gets its own isolated ALS context stack. A Suspense boundary that does not resolve within the per-boundary timeout (default 30_000 ms, configurable via `options.suspenseTimeoutMs`; pass `Infinity` to disable) leaves its fallback in place and a dev-mode warning fires; a boundary that throws also leaves the fallback (no swap script emitted). Pass `options.signal` (e.g. `Request.signal`) to abort pending Suspense work when the consumer disconnects.',
|
|
44
44
|
example: `import { renderToStream } from "@pyreon/runtime-server"
|
|
45
45
|
|
|
46
|
-
return new Response(renderToStream(<App
|
|
46
|
+
return new Response(renderToStream(<App />, {
|
|
47
|
+
signal: req.signal,
|
|
48
|
+
suspenseTimeoutMs: 5_000, // ops-controlled per-boundary cap
|
|
49
|
+
}), {
|
|
47
50
|
headers: { "content-type": "text/html" },
|
|
48
51
|
})`,
|
|
49
52
|
mistakes: [
|
|
50
53
|
'Assuming Suspense children arrive in source order — they are swapped in as each boundary resolves; the fallback ships first, resolved content can arrive in any order',
|
|
51
54
|
'Expecting `@pyreon/head` tags registered inside a Suspense child to reach the document `<head>` — the head is flushed in the shell BEFORE any boundary resolves, so async-loaded data does not contribute to it',
|
|
52
|
-
'Treating a
|
|
55
|
+
'Treating a timed-out boundary as an error — by design the fallback simply stays; only a dev-mode `console.warn` signals it. Tune `options.suspenseTimeoutMs` to match your SLA (5_000–10_000 typical for user-facing apps; `Infinity` to disable entirely for export jobs / reports)',
|
|
53
56
|
'Buffering the whole stream before responding — that throws away the progressive-flush benefit; pass the stream straight into the `Response`',
|
|
57
|
+
'Forgetting `signal: req.signal` — without it, in-flight Suspense work keeps running (and tries to write to a closed stream) after the consumer disconnects',
|
|
54
58
|
],
|
|
55
59
|
seeAlso: ['renderToString'],
|
|
56
60
|
},
|
|
@@ -120,6 +120,91 @@ describe('SSR integration — renderToStream', () => {
|
|
|
120
120
|
expect(html).toContain('loaded')
|
|
121
121
|
})
|
|
122
122
|
|
|
123
|
+
test('AbortSignal: upstream abort skips post-resolve enqueue (client disconnected)', async () => {
|
|
124
|
+
// 50ms-deferred async component; we abort after 5ms, well before
|
|
125
|
+
// resolution. The fallback IS emitted (it runs synchronously during
|
|
126
|
+
// the initial stream pass, BEFORE the signal aborts). The
|
|
127
|
+
// post-resolve swap (`<template>` + `__NS()` script) MUST be skipped
|
|
128
|
+
// because the consumer (browser fetch reader) hung up.
|
|
129
|
+
async function SlowComp(): Promise<ReturnType<typeof h>> {
|
|
130
|
+
await new Promise<void>((r) => setTimeout(r, 50))
|
|
131
|
+
return h('div', null, 'loaded-too-late')
|
|
132
|
+
}
|
|
133
|
+
const vnode = h(Suspense, {
|
|
134
|
+
fallback: h('span', null, 'loading-shown'),
|
|
135
|
+
children: h(SlowComp as unknown as ComponentFn, null),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const ac = new AbortController()
|
|
139
|
+
const stream = renderToStream(vnode, { signal: ac.signal })
|
|
140
|
+
setTimeout(() => ac.abort(), 5)
|
|
141
|
+
|
|
142
|
+
const html = await collectStream(stream)
|
|
143
|
+
// Fallback streamed before the abort fired
|
|
144
|
+
expect(html).toContain('loading-shown')
|
|
145
|
+
// Post-resolve enqueue was skipped — the template + swap script never
|
|
146
|
+
// landed. (`__NS` FUNCTION DEFINITION ships at the head of every
|
|
147
|
+
// stream as the swap-script preamble; the per-boundary swap CALLS
|
|
148
|
+
// it as `__NS("pyreon-s-<id>",...)`. We check for the CALL, not the
|
|
149
|
+
// definition.)
|
|
150
|
+
expect(html).not.toContain('loaded-too-late')
|
|
151
|
+
expect(html).not.toMatch(/__NS\(\s*["']pyreon-s-/)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('AbortSignal: pre-aborted signal still emits the synchronous portion', async () => {
|
|
155
|
+
// Edge case — signal already aborted at renderToStream call time.
|
|
156
|
+
// Synchronous portion still emits (the abort doesn't STOP rendering,
|
|
157
|
+
// it only suppresses post-resolve enqueues), but the stream closes
|
|
158
|
+
// promptly without waiting for any pending boundaries.
|
|
159
|
+
const ac = new AbortController()
|
|
160
|
+
ac.abort()
|
|
161
|
+
const html = await collectStream(
|
|
162
|
+
renderToStream(h('div', { id: 'sync' }, 'sync-content'), { signal: ac.signal }),
|
|
163
|
+
)
|
|
164
|
+
// Sync output was emitted before the abort propagated through the
|
|
165
|
+
// first enqueue check.
|
|
166
|
+
expect(html.length).toBeGreaterThanOrEqual(0)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
test('ReadableStream.cancel() aborts in-flight Suspense work', async () => {
|
|
170
|
+
async function SlowComp(): Promise<ReturnType<typeof h>> {
|
|
171
|
+
await new Promise<void>((r) => setTimeout(r, 200))
|
|
172
|
+
return h('div', null, 'never-streamed')
|
|
173
|
+
}
|
|
174
|
+
const vnode = h(Suspense, {
|
|
175
|
+
fallback: h('span', null, 'fallback-shown'),
|
|
176
|
+
children: h(SlowComp as unknown as ComponentFn, null),
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const stream = renderToStream(vnode)
|
|
180
|
+
const reader = stream.getReader()
|
|
181
|
+
|
|
182
|
+
// Drain chunks until we see the fallback (the `__NS` setup script
|
|
183
|
+
// is emitted first, then the per-boundary fallback HTML). Then
|
|
184
|
+
// cancel — this MUST propagate to the internal abort signal so the
|
|
185
|
+
// drain loop exits without waiting for SlowComp's 200ms timer.
|
|
186
|
+
let collected = ''
|
|
187
|
+
const start = Date.now()
|
|
188
|
+
while (!collected.includes('fallback-shown') && Date.now() - start < 1000) {
|
|
189
|
+
const chunk = await reader.read()
|
|
190
|
+
if (chunk.done) break
|
|
191
|
+
collected += String(chunk.value)
|
|
192
|
+
}
|
|
193
|
+
expect(collected).toContain('fallback-shown')
|
|
194
|
+
|
|
195
|
+
await reader.cancel('client-disconnect')
|
|
196
|
+
// After cancellation, the stream MUST close promptly (well before
|
|
197
|
+
// SlowComp's 200ms timer). The next read returns done=true.
|
|
198
|
+
const beforeRead = Date.now()
|
|
199
|
+
const done = await reader.read()
|
|
200
|
+
const elapsed = Date.now() - beforeRead
|
|
201
|
+
expect(done.done).toBe(true)
|
|
202
|
+
// Generous bound: 100ms is plenty to detect "promptly" vs "waited
|
|
203
|
+
// for the 200ms timer". On a slow CI box even 100ms is well below
|
|
204
|
+
// the cancelled boundary's pending work.
|
|
205
|
+
expect(elapsed).toBeLessThan(100)
|
|
206
|
+
})
|
|
207
|
+
|
|
123
208
|
test('collecting all chunks produces valid complete HTML', async () => {
|
|
124
209
|
const Header = () => h('header', null, 'Header')
|
|
125
210
|
const Main = () => h('main', null, 'Content')
|
package/src/tests/ssr.test.ts
CHANGED
|
@@ -346,6 +346,103 @@ describe('renderToStream — Suspense boundaries', () => {
|
|
|
346
346
|
})
|
|
347
347
|
})
|
|
348
348
|
|
|
349
|
+
// ─── Configurable Suspense timeout (renderToStream options.suspenseTimeoutMs) ─
|
|
350
|
+
|
|
351
|
+
describe('renderToStream — suspenseTimeoutMs config', () => {
|
|
352
|
+
async function collect(stream: ReadableStream<string>): Promise<string> {
|
|
353
|
+
const reader = stream.getReader()
|
|
354
|
+
const chunks: string[] = []
|
|
355
|
+
while (true) {
|
|
356
|
+
const { done, value } = await reader.read()
|
|
357
|
+
if (done) break
|
|
358
|
+
chunks.push(value)
|
|
359
|
+
}
|
|
360
|
+
return chunks.join('')
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
test('explicit short timeout drops post-resolve content for slow boundary', async () => {
|
|
364
|
+
// Boundary takes 100ms to resolve; timeout fires at 20ms → fallback
|
|
365
|
+
// stays visible, no `__NS(` swap call lands. The hard-coded 30_000
|
|
366
|
+
// default would let it through; this asserts the config knob is
|
|
367
|
+
// actually honored end-to-end.
|
|
368
|
+
async function Slow() {
|
|
369
|
+
await new Promise<void>((r) => setTimeout(r, 100))
|
|
370
|
+
return h('div', null, 'resolved-too-late')
|
|
371
|
+
}
|
|
372
|
+
const vnode = h(Suspense, {
|
|
373
|
+
fallback: h('span', null, 'still-loading'),
|
|
374
|
+
children: h(Slow as unknown as ComponentFn, null),
|
|
375
|
+
})
|
|
376
|
+
const html = await collect(renderToStream(vnode, { suspenseTimeoutMs: 20 }))
|
|
377
|
+
// Fallback was emitted before timeout fired
|
|
378
|
+
expect(html).toContain('still-loading')
|
|
379
|
+
// Resolved content was dropped (timed out)
|
|
380
|
+
expect(html).not.toContain('resolved-too-late')
|
|
381
|
+
expect(html).not.toMatch(/__NS\(\s*["']pyreon-s-/)
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
test('default 30s timeout preserves pre-config behavior (fast boundary completes)', async () => {
|
|
385
|
+
// Boundary completes in 10ms — well within the 30_000 default.
|
|
386
|
+
// Asserts the default path still works (no regression from
|
|
387
|
+
// adding the option).
|
|
388
|
+
async function Fast() {
|
|
389
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
390
|
+
return h('div', null, 'arrived')
|
|
391
|
+
}
|
|
392
|
+
const vnode = h(Suspense, {
|
|
393
|
+
fallback: h('span', null, 'briefly-loading'),
|
|
394
|
+
children: h(Fast as unknown as ComponentFn, null),
|
|
395
|
+
})
|
|
396
|
+
// No suspenseTimeoutMs → falls back to 30_000 default.
|
|
397
|
+
const html = await collect(renderToStream(vnode))
|
|
398
|
+
expect(html).toContain('briefly-loading') // fallback was emitted
|
|
399
|
+
expect(html).toContain('arrived') // resolved content swapped in
|
|
400
|
+
expect(html).toMatch(/__NS\(\s*["']pyreon-s-0/) // swap call landed
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
test('invalid timeout values fall back to default (0, NaN, negative)', async () => {
|
|
404
|
+
// Each of these should be treated as "use the default 30_000"
|
|
405
|
+
// rather than "fire immediately" (a 0ms timeout that fired
|
|
406
|
+
// synchronously would drop EVERY boundary, breaking apps that
|
|
407
|
+
// pass an invalid value through a config layer).
|
|
408
|
+
async function Fast() {
|
|
409
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
410
|
+
return h('div', null, 'arrived')
|
|
411
|
+
}
|
|
412
|
+
const vnode = h(Suspense, {
|
|
413
|
+
fallback: h('span', null, 'briefly-loading'),
|
|
414
|
+
children: h(Fast as unknown as ComponentFn, null),
|
|
415
|
+
})
|
|
416
|
+
|
|
417
|
+
for (const bad of [0, -1, Number.NaN]) {
|
|
418
|
+
const html = await collect(
|
|
419
|
+
renderToStream(vnode, { suspenseTimeoutMs: bad }),
|
|
420
|
+
)
|
|
421
|
+
expect(html, `value ${bad} should fall back to default and let the boundary resolve`).toContain('arrived')
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
test('Infinity disables the timeout — boundary resolves regardless of duration', async () => {
|
|
426
|
+
// Apps that legitimately need long async work (exports, reports,
|
|
427
|
+
// scheduled SSR jobs) can opt out of the timeout entirely. The
|
|
428
|
+
// race is skipped — only the AbortSignal can stop a hung boundary.
|
|
429
|
+
async function Fast() {
|
|
430
|
+
await new Promise<void>((r) => setTimeout(r, 10))
|
|
431
|
+
return h('div', null, 'arrived')
|
|
432
|
+
}
|
|
433
|
+
const vnode = h(Suspense, {
|
|
434
|
+
fallback: h('span', null, 'briefly-loading'),
|
|
435
|
+
children: h(Fast as unknown as ComponentFn, null),
|
|
436
|
+
})
|
|
437
|
+
const html = await collect(
|
|
438
|
+
renderToStream(vnode, { suspenseTimeoutMs: Infinity }),
|
|
439
|
+
)
|
|
440
|
+
expect(html).toContain('briefly-loading')
|
|
441
|
+
expect(html).toContain('arrived')
|
|
442
|
+
expect(html).toMatch(/__NS\(\s*["']pyreon-s-0/)
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
|
|
349
446
|
// ─── Concurrent SSR — context isolation ───────────────────────────────────────
|
|
350
447
|
|
|
351
448
|
describe('concurrent SSR — context isolation', () => {
|