inertjs-vector 1.0.0-beta.5 → 1.0.0-beta.7

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/package.json CHANGED
@@ -1,16 +1,16 @@
1
- {
2
- "name": "inertjs-vector",
3
- "version": "1.0.0-beta.5",
4
- "type": "module",
5
- "main": "src/index.js",
6
- "dependencies": {
7
- "inertjs-optimizer": "^1.0.0-beta.5"
8
- },
9
- "engines": {
10
- "node": ">=22.0.0"
11
- },
12
- "exports": {
13
- ".": "./src/index.js",
14
- "./*": "./*"
15
- }
16
- }
1
+ {
2
+ "name": "inertjs-vector",
3
+ "version": "1.0.0-beta.7",
4
+ "type": "module",
5
+ "main": "src/index.js",
6
+ "dependencies": {
7
+ "inertjs-optimizer": "^1.0.0-beta.6"
8
+ },
9
+ "engines": {
10
+ "node": ">=22.0.0"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./*": "./*"
15
+ }
16
+ }
package/src/index.js CHANGED
@@ -1,102 +1,146 @@
1
- import { analyzeTemplateStrings, escape, CONTEXT } from './escaper.js';
2
- import { minifyHTML } from 'inertjs-optimizer';
3
- export { resolveToString } from './resolve.js';
4
-
5
- const planCache = new WeakMap();
6
-
7
- export class RawString {
8
- constructor(str) {
9
- this.value = str;
10
- }
11
- toString() {
12
- return this.value;
13
- }
14
- }
15
-
16
- /**
17
- * Opt-out of HTML escaping.
18
- * Use with extreme caution.
19
- *
20
- * @param {string} str Unescaped HTML string
21
- * @returns {RawString}
22
- */
23
- export function raw(str, suppressWarning = false) {
24
- if (!suppressWarning && process.env.NODE_ENV !== 'production') {
25
- const stack = new Error().stack;
26
- // index 1 is raw(), index 2 is the caller
27
- const callSite = stack && stack.split('\n')[2] ? stack.split('\n')[2].trim() : 'unknown location';
28
- console.warn(`[Lens] Warning: raw() escape hatch used at ${callSite}. Ensure this string is safe.`);
29
- }
30
- return new RawString(str);
31
- }
32
-
33
- /**
34
- * Tagged template literal for Vector template engine.
35
- * Safely escapes all interpolations based on their HTML context.
36
- *
37
- * @param {TemplateStringsArray} strings
38
- * @param {...any} values
39
- */
40
- export function vec(strings, ...values) {
41
- let plan = planCache.get(strings);
42
- if (!plan) {
43
- plan = analyzeTemplateStrings(strings);
44
- // Minify statics on the fly during template parse
45
- plan.statics = plan.statics.map(staticStr => minifyHTML(staticStr));
46
- planCache.set(strings, plan);
47
- }
48
-
49
- // Check if we need to stream (any value is a Promise or AsyncIterable)
50
- const isAsync = values.some(v => v instanceof Promise || (v && typeof v[Symbol.asyncIterator] === 'function') || (v && v.type === 'VecStream'));
51
-
52
- if (isAsync) {
53
- return {
54
- type: 'VecStream',
55
- plan,
56
- values
57
- };
58
- }
59
-
60
- let result = plan.statics[0];
61
- for (let i = 0; i < values.length; i++) {
62
- const val = values[i];
63
- const ctx = plan.contexts[i];
64
- const attrName = plan.attrNames[i];
65
-
66
- if (val instanceof RawString) {
67
- result += val.value;
68
- } else if (val && val.type === 'VecStream') {
69
- throw new Error('E_INERT_VECTOR_NESTED_ASYNC: Async nested vec`` found in synchronous render context.');
70
- } else if (Array.isArray(val)) {
71
- result += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
72
- } else {
73
- result += escape(val, ctx, attrName);
74
- }
75
- result += plan.statics[i + 1];
76
- }
77
-
78
- // Return RawString so nested `vec` calls aren't double-escaped
79
- return new RawString(result);
80
- }
81
-
82
- /**
83
- * Generates an optimized image tag that hooks into InertJS's on-the-fly optimizer.
84
- *
85
- * @param {object} props Image properties: src, width, height, quality, class, alt
86
- * @returns {RawString}
87
- */
88
- export function img({ src, width, height, quality, ...rest }) {
89
- const query = new URLSearchParams();
90
- query.set('src', src);
91
- if (width) query.set('w', width);
92
- if (height) query.set('h', height);
93
- if (quality) query.set('q', quality);
94
-
95
- const url = `/_inert/image?${query.toString()}`;
96
-
97
- const attrs = Object.entries(rest)
98
- .map(([key, val]) => `${key}="${escape(val, CONTEXT.ATTR_VALUE_DOUBLE)}"`)
99
- .join(' ');
100
-
101
- return raw(`<img src="${url}" ${attrs} />`, true);
102
- }
1
+ import { analyzeTemplateStrings, escape, CONTEXT } from './escaper.js';
2
+ import { minifyHTML } from 'inertjs-optimizer';
3
+ export { resolveToString } from './resolve.js';
4
+
5
+ const planCache = new WeakMap();
6
+
7
+ export class RawString {
8
+ constructor(str) {
9
+ this.value = str;
10
+ }
11
+ toString() {
12
+ return this.value;
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Opt-out of HTML escaping.
18
+ * Use with extreme caution.
19
+ *
20
+ * @param {string} str Unescaped HTML string
21
+ * @returns {RawString}
22
+ */
23
+ export function raw(str, suppressWarning = false) {
24
+ if (!suppressWarning && process.env.NODE_ENV !== 'production') {
25
+ const stack = new Error().stack;
26
+ // index 1 is raw(), index 2 is the caller
27
+ const callSite = stack && stack.split('\n')[2] ? stack.split('\n')[2].trim() : 'unknown location';
28
+ console.warn(`[Lens] Warning: raw() escape hatch used at ${callSite}. Ensure this string is safe.`);
29
+ }
30
+ return new RawString(str);
31
+ }
32
+
33
+ /**
34
+ * A slow value whose surrounding shell is allowed to flush to the browser
35
+ * immediately. The `fallback` is rendered into the shell as a skeleton and
36
+ * patched out of the DOM once `source` settles.
37
+ *
38
+ * Because the shell (and HTTP headers) are already on the wire by the time a
39
+ * deferred fragment settles, a rejection can no longer become a 500. Instead
40
+ * the slot is patched with `error` (an isolated, per-fragment error boundary)
41
+ * and the client is notified via the `inert:fragment:error` event. Siblings
42
+ * keep streaming untouched.
43
+ *
44
+ * `fallback` / `error` content is treated as trusted HTML (like `raw()`), so
45
+ * interpolate untrusted values through `vec\`\`` before handing them in.
46
+ *
47
+ * @param {Promise<any>|AsyncIterable<any>|object|(() => any)} source
48
+ * The deferred value, or a factory that produces it (called lazily; a throw
49
+ * from the factory is caught and routed to `error`).
50
+ * @param {object} [options]
51
+ * @param {string|RawString} [options.fallback] Skeleton shown while pending.
52
+ * @param {string|RawString|object|((err: Error) => string|RawString|object)} [options.error]
53
+ * Content to patch in if `source` rejects. A function receives the error.
54
+ * @returns {DeferredFragment}
55
+ */
56
+ export function defer(source, options = {}) {
57
+ return new DeferredFragment(source, options);
58
+ }
59
+
60
+ /** Internal marker produced by {@link defer}. */
61
+ export class DeferredFragment {
62
+ constructor(source, options = {}) {
63
+ this.type = 'VecFragment';
64
+ this.source = source;
65
+ this.fallback = options.fallback ?? null;
66
+ this.error = options.error ?? null;
67
+ }
68
+ }
69
+
70
+ function isStreamable(v) {
71
+ return v instanceof Promise
72
+ || (v && typeof v[Symbol.asyncIterator] === 'function')
73
+ || (v && (v.type === 'VecStream' || v.type === 'VecFragment'));
74
+ }
75
+
76
+ /**
77
+ * Tagged template literal for Vector template engine.
78
+ * Safely escapes all interpolations based on their HTML context.
79
+ *
80
+ * @param {TemplateStringsArray} strings
81
+ * @param {...any} values
82
+ */
83
+ export function vec(strings, ...values) {
84
+ let plan = planCache.get(strings);
85
+ if (!plan) {
86
+ plan = analyzeTemplateStrings(strings);
87
+ // Minify statics on the fly during template parse
88
+ plan.statics = plan.statics.map(staticStr => minifyHTML(staticStr));
89
+ planCache.set(strings, plan);
90
+ }
91
+
92
+ // Check if we need to stream (any value is a Promise, AsyncIterable, nested
93
+ // stream, or a deferred fragment).
94
+ const isAsync = values.some(isStreamable);
95
+
96
+ if (isAsync) {
97
+ return {
98
+ type: 'VecStream',
99
+ plan,
100
+ values
101
+ };
102
+ }
103
+
104
+ let result = plan.statics[0];
105
+ for (let i = 0; i < values.length; i++) {
106
+ const val = values[i];
107
+ const ctx = plan.contexts[i];
108
+ const attrName = plan.attrNames[i];
109
+
110
+ if (val instanceof RawString) {
111
+ result += val.value;
112
+ } else if (val && (val.type === 'VecStream' || val.type === 'VecFragment')) {
113
+ throw new Error('E_INERT_VECTOR_NESTED_ASYNC: Async nested vec`` found in synchronous render context.');
114
+ } else if (Array.isArray(val)) {
115
+ result += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
116
+ } else {
117
+ result += escape(val, ctx, attrName);
118
+ }
119
+ result += plan.statics[i + 1];
120
+ }
121
+
122
+ // Return RawString so nested `vec` calls aren't double-escaped
123
+ return new RawString(result);
124
+ }
125
+
126
+ /**
127
+ * Generates an optimized image tag that hooks into InertJS's on-the-fly optimizer.
128
+ *
129
+ * @param {object} props Image properties: src, width, height, quality, class, alt
130
+ * @returns {RawString}
131
+ */
132
+ export function img({ src, width, height, quality, ...rest }) {
133
+ const query = new URLSearchParams();
134
+ query.set('src', src);
135
+ if (width) query.set('w', width);
136
+ if (height) query.set('h', height);
137
+ if (quality) query.set('q', quality);
138
+
139
+ const url = `/_inert/image?${query.toString()}`;
140
+
141
+ const attrs = Object.entries(rest)
142
+ .map(([key, val]) => `${key}="${escape(val, CONTEXT.ATTR_VALUE_DOUBLE)}"`)
143
+ .join(' ');
144
+
145
+ return raw(`<img src="${url}" ${attrs} />`, true);
146
+ }
package/src/resolve.js CHANGED
@@ -1,48 +1,66 @@
1
- import { escape } from './escaper.js';
2
- import { RawString } from './index.js';
3
-
4
- /**
5
- * Fully resolves a VecStream and all its asynchronous holes into a single string.
6
- */
7
- export async function resolveToString(val, ctx = 'text', attrName = '') {
8
- if (val == null || val === false) return '';
9
- if (typeof val === 'string' || typeof val === 'number') return escape(String(val), ctx, attrName);
10
- if (val instanceof RawString) return val.value;
11
-
12
- if (val instanceof Promise) {
13
- return resolveToString(await val, ctx, attrName);
14
- }
15
-
16
- if (Array.isArray(val)) {
17
- let res = '';
18
- for (const item of val) {
19
- res += await resolveToString(item, ctx, attrName);
20
- }
21
- return res;
22
- }
23
-
24
- if (val && val.type === 'VecStream') {
25
- let result = '';
26
- for (let i = 0; i < val.values.length; i++) {
27
- result += val.plan.statics[i];
28
-
29
- const part = val.values[i];
30
- const nextCtx = val.plan.contexts[i];
31
- const nextAttrName = val.plan.attrNames[i];
32
-
33
- result += await resolveToString(part, nextCtx, nextAttrName);
34
- }
35
- result += val.plan.statics[val.plan.statics.length - 1];
36
- return result;
37
- }
38
-
39
- if (val && typeof val[Symbol.asyncIterator] === 'function') {
40
- let res = '';
41
- for await (const chunk of val) {
42
- res += await resolveToString(chunk, ctx, attrName);
43
- }
44
- return res;
45
- }
46
-
47
- return escape(String(val), ctx, attrName);
48
- }
1
+ import { escape } from './escaper.js';
2
+ import { RawString } from './index.js';
3
+
4
+ /**
5
+ * Fully resolves a VecStream and all its asynchronous holes into a single string.
6
+ */
7
+ export async function resolveToString(val, ctx = 'text', attrName = '') {
8
+ if (val == null || val === false) return '';
9
+ if (typeof val === 'string' || typeof val === 'number') return escape(String(val), ctx, attrName);
10
+ if (val instanceof RawString) return val.value;
11
+
12
+ if (val instanceof Promise) {
13
+ return resolveToString(await val, ctx, attrName);
14
+ }
15
+
16
+ if (val && val.type === 'VecFragment') {
17
+ let source = val.source;
18
+ try {
19
+ if (typeof source === 'function') source = source();
20
+ return await resolveToString(await source, ctx, attrName);
21
+ } catch (err) {
22
+ console.error('[InertJS] Deferred fragment rejected during buffered render:', err);
23
+ let content = val.error;
24
+ if (typeof content === 'function') {
25
+ try { content = content(err); } catch { content = null; }
26
+ }
27
+ if (content == null) return '';
28
+ if (content instanceof RawString) return content.value;
29
+ if (content && content.type === 'VecStream') return resolveToString(content, ctx, attrName);
30
+ return String(content);
31
+ }
32
+ }
33
+
34
+ if (Array.isArray(val)) {
35
+ let res = '';
36
+ for (const item of val) {
37
+ res += await resolveToString(item, ctx, attrName);
38
+ }
39
+ return res;
40
+ }
41
+
42
+ if (val && val.type === 'VecStream') {
43
+ let result = '';
44
+ for (let i = 0; i < val.values.length; i++) {
45
+ result += val.plan.statics[i];
46
+
47
+ const part = val.values[i];
48
+ const nextCtx = val.plan.contexts[i];
49
+ const nextAttrName = val.plan.attrNames[i];
50
+
51
+ result += await resolveToString(part, nextCtx, nextAttrName);
52
+ }
53
+ result += val.plan.statics[val.plan.statics.length - 1];
54
+ return result;
55
+ }
56
+
57
+ if (val && typeof val[Symbol.asyncIterator] === 'function') {
58
+ let res = '';
59
+ for await (const chunk of val) {
60
+ res += await resolveToString(chunk, ctx, attrName);
61
+ }
62
+ return res;
63
+ }
64
+
65
+ return escape(String(val), ctx, attrName);
66
+ }
package/src/stream.js CHANGED
@@ -1,142 +1,251 @@
1
- import { escape } from './escaper.js';
2
- import { RawString } from './index.js';
3
-
4
- const encoder = new TextEncoder();
5
-
6
- const PATCHER_SCRIPT = `<script nonce="[INERT_NONCE]">
7
- (function(){
8
- const ds=document.querySelectorAll('template[data-i-fill]');
9
- for(let d of ds){
10
- const id=d.getAttribute('data-i-fill');
11
- const target=document.getElementById('i-slot-'+id);
12
- if(target){
13
- target.replaceWith(d.content);
14
- }
15
- d.remove();
16
- }
17
- })();
18
- </script>`;
19
-
20
- /**
21
- * Renders a VecStream (or RawString) to a WHATWG ReadableStream.
22
- * Handles out-of-order streaming for Promises and AsyncIterables.
23
- *
24
- * @param {object} vecResult The result from calling vec\`\`
25
- * @param {string} nonce The CSP nonce for inline scripts
26
- * @returns {ReadableStream}
27
- */
28
- export function renderToStream(vecResult, nonce = '') {
29
- if (!(vecResult && vecResult.type === 'VecStream')) {
30
- const value = vecResult instanceof RawString ? vecResult.value : String(vecResult);
31
- return new ReadableStream({
32
- start(controller) {
33
- controller.enqueue(encoder.encode(value));
34
- controller.close();
35
- }
36
- });
37
- }
38
-
39
- const { plan, values } = vecResult;
40
- let slotCounter = 0;
41
- const pendingTasks = new Set();
42
-
43
- return new ReadableStream({
44
- async start(controller) {
45
- try {
46
- let initialHtml = plan.statics[0];
47
-
48
- for (let i = 0; i < values.length; i++) {
49
- const val = values[i];
50
- const ctx = plan.contexts[i];
51
- const attrName = plan.attrNames[i];
52
-
53
- const isPromise = val instanceof Promise;
54
- const isAsyncIter = val && typeof val[Symbol.asyncIterator] === 'function';
55
- const isNestedStream = val && val.type === 'VecStream';
56
-
57
- if (isPromise || isAsyncIter || isNestedStream) {
58
- const slotId = ++slotCounter;
59
- initialHtml += `<i-slot id="i-slot-${slotId}"></i-slot>`;
60
-
61
- const task = (async () => {
62
- try {
63
- if (isPromise) {
64
- const resolved = await val;
65
- let str = '';
66
- if (resolved && resolved.type === 'VecStream') {
67
- // Resolve nested stream by reading from it
68
- const nestedReadable = renderToStream(resolved, nonce);
69
- const reader = nestedReadable.getReader();
70
- let nestedHtml = '';
71
- while (true) {
72
- const { done, value } = await reader.read();
73
- if (done) break;
74
- // value is Uint8Array
75
- nestedHtml += new TextDecoder().decode(value);
76
- }
77
- str = nestedHtml;
78
- } else if (resolved instanceof RawString) {
79
- str = resolved.value;
80
- } else {
81
- str = escape(resolved, ctx, attrName);
82
- }
83
- const patch = `\n<template data-i-fill="${slotId}">${str}</template>` +
84
- PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
85
- controller.enqueue(encoder.encode(patch));
86
- } else if (isAsyncIter) {
87
- let accumulated = '';
88
- for await (const chunk of val) {
89
- accumulated += (chunk instanceof RawString ? chunk.value : escape(chunk, ctx, attrName));
90
- }
91
- const patch = `\n<template data-i-fill="${slotId}">${accumulated}</template>` +
92
- PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
93
- controller.enqueue(encoder.encode(patch));
94
- } else if (isNestedStream) {
95
- const nestedReadable = renderToStream(val, nonce);
96
- const reader = nestedReadable.getReader();
97
- let nestedHtml = '';
98
- while (true) {
99
- const { done, value } = await reader.read();
100
- if (done) break;
101
- nestedHtml += new TextDecoder().decode(value);
102
- }
103
- const patch = `\n<template data-i-fill="${slotId}">${nestedHtml}</template>` +
104
- PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
105
- controller.enqueue(encoder.encode(patch));
106
- }
107
- } catch (err) {
108
- console.error(`[InertJS] Error streaming slot ${slotId}:`, err);
109
- } finally {
110
- pendingTasks.delete(task);
111
- if (pendingTasks.size === 0) {
112
- controller.close();
113
- }
114
- }
115
- })();
116
-
117
- pendingTasks.add(task);
118
- } else {
119
- // Synchronous value
120
- if (val instanceof RawString) {
121
- initialHtml += val.value;
122
- } else if (Array.isArray(val)) {
123
- initialHtml += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
124
- } else {
125
- initialHtml += escape(val, ctx, attrName);
126
- }
127
- }
128
- initialHtml += plan.statics[i + 1];
129
- }
130
-
131
- // Flush the synchronous initial shell
132
- controller.enqueue(encoder.encode(initialHtml));
133
-
134
- if (pendingTasks.size === 0) {
135
- controller.close();
136
- }
137
- } catch (err) {
138
- controller.error(err);
139
- }
140
- }
141
- });
142
- }
1
+ import { escape } from './escaper.js';
2
+ import { RawString } from './index.js';
3
+
4
+ const encoder = new TextEncoder();
5
+ const decoder = new TextDecoder();
6
+
7
+ const PATCHER_SCRIPT = `<script nonce="[INERT_NONCE]">
8
+ (function(){
9
+ const ds=document.querySelectorAll('template[data-i-fill]');
10
+ for(let d of ds){
11
+ const id=d.getAttribute('data-i-fill');
12
+ const slot=document.getElementById('i-slot-'+id);
13
+ if(slot){
14
+ const errored=d.hasAttribute('data-i-error');
15
+ slot.replaceWith(d.content);
16
+ if(errored){
17
+ const s=(window.__inert=window.__inert||{});
18
+ s.fragmentErrors=(s.fragmentErrors||0)+1;
19
+ try{window.dispatchEvent(new CustomEvent('inert:fragment:error',{detail:{slot:id}}));}catch(e){}
20
+ }
21
+ }
22
+ d.remove();
23
+ }
24
+ })();
25
+ </script>`;
26
+
27
+ /**
28
+ * Fully drains a WHATWG ReadableStream of Uint8Array chunks into a string,
29
+ * always releasing the reader lock.
30
+ */
31
+ async function drainStream(readable) {
32
+ const reader = readable.getReader();
33
+ let html = '';
34
+ try {
35
+ while (true) {
36
+ const { done, value } = await reader.read();
37
+ if (done) break;
38
+ html += decoder.decode(value, { stream: true });
39
+ }
40
+ html += decoder.decode();
41
+ } finally {
42
+ reader.releaseLock();
43
+ }
44
+ return html;
45
+ }
46
+
47
+ /**
48
+ * Resolves an already-settled value (the result of `await`ing a hole) into an
49
+ * HTML string, recursing through nested streams, RawStrings and arrays.
50
+ */
51
+ async function valueToHtml(value, ctx, attrName, nonce) {
52
+ if (value == null || value === false) return '';
53
+ if (value instanceof RawString) return value.value;
54
+ if (value && value.type === 'VecStream') {
55
+ return drainStream(renderToStream(value, nonce));
56
+ }
57
+ if (Array.isArray(value)) {
58
+ let out = '';
59
+ for (const item of value) out += await valueToHtml(item, ctx, attrName, nonce);
60
+ return out;
61
+ }
62
+ return escape(value, ctx, attrName);
63
+ }
64
+
65
+ /** Synchronously resolves a fragment's skeleton (must be inlined into the shell). */
66
+ function skeletonHtml(fragment) {
67
+ const f = fragment && fragment.fallback;
68
+ if (f == null) return '';
69
+ if (f instanceof RawString) return f.value;
70
+ if (typeof f === 'string') return f;
71
+ return '';
72
+ }
73
+
74
+ /** Resolves a fragment's error boundary content once the source has rejected. */
75
+ async function errorBoundaryHtml(fragment, err, ctx, attrName, nonce) {
76
+ let content = fragment && fragment.error;
77
+ if (typeof content === 'function') {
78
+ try {
79
+ content = content(err);
80
+ } catch (boundaryErr) {
81
+ console.error('[InertJS] Fragment error boundary threw:', boundaryErr);
82
+ return '';
83
+ }
84
+ }
85
+ if (content == null) return '';
86
+ if (content instanceof RawString) return content.value;
87
+ if (content && content.type === 'VecStream') {
88
+ return drainStream(renderToStream(content, nonce));
89
+ }
90
+ return String(content);
91
+ }
92
+
93
+ function fillPatch(slotId, html, nonce, errored) {
94
+ return `\n<template data-i-fill="${slotId}"${errored ? ' data-i-error="1"' : ''}>${html}</template>` +
95
+ PATCHER_SCRIPT.replace('[INERT_NONCE]', nonce);
96
+ }
97
+
98
+ /**
99
+ * Renders a VecStream (or RawString) to a WHATWG ReadableStream.
100
+ * Handles out-of-order streaming for Promises, AsyncIterables and deferred
101
+ * fragments.
102
+ *
103
+ * Once the synchronous shell has been flushed, a hole that rejects can no
104
+ * longer surface as an HTTP error. Such a failure is isolated to its own slot:
105
+ * the slot is patched with the fragment's error boundary (or emptied), the
106
+ * client is signalled via `inert:fragment:error`, `options.onError` is invoked,
107
+ * and every sibling hole keeps streaming.
108
+ *
109
+ * @param {object} vecResult The result from calling vec\`\`
110
+ * @param {string} [nonce] The CSP nonce for inline scripts
111
+ * @param {object} [options]
112
+ * @param {(err: Error, info: { slot: number }) => void} [options.onError]
113
+ * Called for every hole that rejects after the shell was flushed.
114
+ * @returns {ReadableStream}
115
+ */
116
+ export function renderToStream(vecResult, nonce = '', options = {}) {
117
+ if (!(vecResult && vecResult.type === 'VecStream')) {
118
+ const value = vecResult instanceof RawString ? vecResult.value : String(vecResult);
119
+ return new ReadableStream({
120
+ start(controller) {
121
+ controller.enqueue(encoder.encode(value));
122
+ controller.close();
123
+ }
124
+ });
125
+ }
126
+
127
+ const { plan, values } = vecResult;
128
+ const onError = typeof options.onError === 'function' ? options.onError : null;
129
+ let slotCounter = 0;
130
+ const pendingTasks = new Set();
131
+
132
+ return new ReadableStream({
133
+ async start(controller) {
134
+ let closed = false;
135
+
136
+ const enqueue = (str) => {
137
+ if (closed) return;
138
+ try {
139
+ controller.enqueue(encoder.encode(str));
140
+ } catch {
141
+ // Client went away; stop trying to write.
142
+ closed = true;
143
+ }
144
+ };
145
+
146
+ const closeController = () => {
147
+ if (closed) return;
148
+ closed = true;
149
+ try {
150
+ controller.close();
151
+ } catch {
152
+ /* already closed / errored */
153
+ }
154
+ };
155
+
156
+ try {
157
+ let initialHtml = plan.statics[0];
158
+
159
+ for (let i = 0; i < values.length; i++) {
160
+ const val = values[i];
161
+ const ctx = plan.contexts[i];
162
+ const attrName = plan.attrNames[i];
163
+
164
+ const isPromise = val instanceof Promise;
165
+ const isAsyncIter = val && typeof val[Symbol.asyncIterator] === 'function';
166
+ const isNestedStream = val && val.type === 'VecStream';
167
+ const isFragment = val && val.type === 'VecFragment';
168
+
169
+ if (isPromise || isAsyncIter || isNestedStream || isFragment) {
170
+ const slotId = ++slotCounter;
171
+ const fragment = isFragment ? val : null;
172
+ initialHtml += `<i-slot id="i-slot-${slotId}">${fragment ? skeletonHtml(fragment) : ''}</i-slot>`;
173
+
174
+ const task = (async () => {
175
+ try {
176
+ let source = fragment ? fragment.source : val;
177
+ if (typeof source === 'function') source = source();
178
+
179
+ let html;
180
+ if (source && source.type === 'VecStream') {
181
+ html = await drainStream(renderToStream(source, nonce, options));
182
+ } else if (source && typeof source[Symbol.asyncIterator] === 'function') {
183
+ let accumulated = '';
184
+ for await (const chunk of source) {
185
+ accumulated += chunk instanceof RawString
186
+ ? chunk.value
187
+ : escape(chunk, ctx, attrName);
188
+ }
189
+ html = accumulated;
190
+ } else {
191
+ html = await valueToHtml(await source, ctx, attrName, nonce);
192
+ }
193
+
194
+ enqueue(fillPatch(slotId, html, nonce, false));
195
+ } catch (err) {
196
+ console.error(
197
+ `[InertJS] Deferred fragment in slot ${slotId} rejected after the shell was flushed:`,
198
+ err
199
+ );
200
+ if (onError) {
201
+ try {
202
+ onError(err, { slot: slotId });
203
+ } catch (hookErr) {
204
+ console.error('[InertJS] renderToStream onError hook threw:', hookErr);
205
+ }
206
+ }
207
+ let boundary = '';
208
+ try {
209
+ boundary = await errorBoundaryHtml(fragment, err, ctx, attrName, nonce);
210
+ } catch (boundaryErr) {
211
+ console.error('[InertJS] Fragment error boundary failed:', boundaryErr);
212
+ }
213
+ enqueue(fillPatch(slotId, boundary, nonce, true));
214
+ } finally {
215
+ pendingTasks.delete(task);
216
+ if (pendingTasks.size === 0) closeController();
217
+ }
218
+ })();
219
+
220
+ pendingTasks.add(task);
221
+ } else {
222
+ // Synchronous value
223
+ if (val instanceof RawString) {
224
+ initialHtml += val.value;
225
+ } else if (Array.isArray(val)) {
226
+ initialHtml += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
227
+ } else {
228
+ initialHtml += escape(val, ctx, attrName);
229
+ }
230
+ }
231
+ initialHtml += plan.statics[i + 1];
232
+ }
233
+
234
+ // Flush the synchronous initial shell
235
+ enqueue(initialHtml);
236
+
237
+ if (pendingTasks.size === 0) {
238
+ closeController();
239
+ }
240
+ } catch (err) {
241
+ // Reached only while the shell is still being built (nothing flushed yet),
242
+ // so it is safe to fail the whole response here.
243
+ try {
244
+ controller.error(err);
245
+ } catch {
246
+ /* already torn down */
247
+ }
248
+ }
249
+ }
250
+ });
251
+ }
@@ -0,0 +1,108 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert';
3
+ import { vec, raw, defer } from '../src/index.js';
4
+ import { renderToStream } from '../src/stream.js';
5
+ import { resolveToString } from '../src/resolve.js';
6
+
7
+ const decoder = new TextDecoder();
8
+
9
+ /** Fully drains a stream to a string, failing the test rather than hanging forever. */
10
+ async function collect(stream, ms = 2000) {
11
+ const reader = stream.getReader();
12
+ let out = '';
13
+ const timer = setTimeout(() => reader.cancel(new Error('stream did not close')), ms);
14
+ try {
15
+ while (true) {
16
+ const { done, value } = await reader.read();
17
+ if (done) break;
18
+ out += decoder.decode(value, { stream: true });
19
+ }
20
+ out += decoder.decode();
21
+ } finally {
22
+ clearTimeout(timer);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ test('Out-of-order streaming: fragment errors after shell flush', async (t) => {
28
+ await t.test('a rejected bare promise does not hang the stream and resolves its slot', async () => {
29
+ const boom = Promise.reject(new Error('db exploded'));
30
+ // Attach a catch so Node does not print an unhandled rejection for the test input.
31
+ boom.catch(() => {});
32
+
33
+ const stream = renderToStream(vec`<main>${boom}</main>`, 'n0');
34
+ const html = await collect(stream);
35
+
36
+ assert.ok(html.includes('<i-slot id="i-slot-1">'), 'shell still flushes with the skeleton slot');
37
+ assert.ok(html.includes('data-i-fill="1" data-i-error="1"'), 'slot 1 receives an error patch');
38
+ assert.ok(html.includes('inert:fragment:error'), 'patcher signals the client');
39
+ });
40
+
41
+ await t.test('one failing fragment does not block a healthy sibling', async () => {
42
+ const bad = Promise.reject(new Error('nope'));
43
+ bad.catch(() => {});
44
+ const good = Promise.resolve(raw('<p>loaded</p>'));
45
+
46
+ const stream = renderToStream(vec`<div>${bad}</div><div>${good}</div>`, 'n1');
47
+ const html = await collect(stream);
48
+
49
+ assert.ok(html.includes('data-i-fill="1" data-i-error="1"'), 'slot 1 errored');
50
+ assert.match(html, /data-i-fill="2"(?!.*data-i-error)/, 'slot 2 filled normally');
51
+ assert.ok(html.includes('<p>loaded</p>'), 'healthy fragment content is present');
52
+ });
53
+
54
+ await t.test('defer() renders the fallback skeleton into the shell', async () => {
55
+ const slow = defer(new Promise(() => {}), { fallback: raw('<span class="skeleton"></span>') });
56
+ // never resolves -> read only the first chunk (the shell)
57
+ const reader = renderToStream(vec`<main>${slow}</main>`, 'n2').getReader();
58
+ const { value } = await reader.read();
59
+ await reader.cancel();
60
+ const shell = decoder.decode(value);
61
+
62
+ assert.ok(shell.includes('<i-slot id="i-slot-1"><span class="skeleton"></span></i-slot>'));
63
+ });
64
+
65
+ await t.test('defer().error is patched in when the source rejects', async () => {
66
+ const frag = defer(() => Promise.reject(new Error('timeout')), {
67
+ fallback: raw('<span>loading…</span>'),
68
+ error: (err) => vec`<p class="err">${err.message}</p>`
69
+ });
70
+
71
+ const stream = renderToStream(vec`<main>${frag}</main>`, 'n3');
72
+ const html = await collect(stream);
73
+
74
+ assert.ok(html.includes('data-i-error="1"'), 'error patch emitted');
75
+ assert.ok(html.includes('<p class="err">timeout</p>'), 'error boundary content rendered and escaped via vec');
76
+ });
77
+
78
+ await t.test('a throwing lazy factory is routed to the error boundary', async () => {
79
+ const frag = defer(() => { throw new Error('sync throw'); }, {
80
+ error: raw('<p>failed</p>')
81
+ });
82
+
83
+ const html = await collect(renderToStream(vec`<main>${frag}</main>`, 'n4'));
84
+ assert.ok(html.includes('data-i-error="1"'));
85
+ assert.ok(html.includes('<p>failed</p>'));
86
+ });
87
+
88
+ await t.test('onError hook is invoked with the slot id', async () => {
89
+ const seen = [];
90
+ const bad = Promise.reject(new Error('kaboom'));
91
+ bad.catch(() => {});
92
+
93
+ const stream = renderToStream(vec`<main>${bad}</main>`, 'n5', {
94
+ onError: (err, info) => seen.push([err.message, info.slot])
95
+ });
96
+ await collect(stream);
97
+
98
+ assert.deepStrictEqual(seen, [['kaboom', 1]]);
99
+ });
100
+
101
+ await t.test('resolveToString buffers a deferred fragment and its error boundary', async () => {
102
+ const ok = defer(Promise.resolve(raw('<b>hi</b>')));
103
+ assert.strictEqual(await resolveToString(vec`<p>${ok}</p>`), '<p><b>hi</b></p>');
104
+
105
+ const failed = defer(() => Promise.reject(new Error('x')), { error: raw('<i>oops</i>') });
106
+ assert.strictEqual(await resolveToString(vec`<p>${failed}</p>`), '<p><i>oops</i></p>');
107
+ });
108
+ });