inertjs-vector 1.0.0-beta.6 → 1.0.0-beta.8

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.6",
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
- }
1
+ {
2
+ "name": "inertjs-vector",
3
+ "version": "1.0.0-beta.8",
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,155 @@
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
+ export { renderToStream, DeferTimeoutError } from './stream.js';
5
+ // NOTE: index <-> stream is a cycle (stream imports RawString from here). Both
6
+ // only touch each other's bindings at call time, by which point ESM has finished
7
+ // initialising both modules, so the re-export above is safe.
8
+
9
+ const planCache = new WeakMap();
10
+
11
+ export class RawString {
12
+ constructor(str) {
13
+ this.value = str;
14
+ }
15
+ toString() {
16
+ return this.value;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Opt-out of HTML escaping.
22
+ * Use with extreme caution.
23
+ *
24
+ * @param {string} str Unescaped HTML string
25
+ * @returns {RawString}
26
+ */
27
+ export function raw(str, suppressWarning = false) {
28
+ if (!suppressWarning && process.env.NODE_ENV !== 'production') {
29
+ const stack = new Error().stack;
30
+ // index 1 is raw(), index 2 is the caller
31
+ const callSite = stack && stack.split('\n')[2] ? stack.split('\n')[2].trim() : 'unknown location';
32
+ console.warn(`[Lens] Warning: raw() escape hatch used at ${callSite}. Ensure this string is safe.`);
33
+ }
34
+ return new RawString(str);
35
+ }
36
+
37
+ /**
38
+ * A slow value whose surrounding shell is allowed to flush to the browser
39
+ * immediately. The `fallback` is rendered into the shell as a skeleton and
40
+ * patched out of the DOM once `source` settles.
41
+ *
42
+ * Because the shell (and HTTP headers) are already on the wire by the time a
43
+ * deferred fragment settles, a rejection can no longer become a 500. Instead
44
+ * the slot is patched with `error` (an isolated, per-fragment error boundary)
45
+ * and the client is notified via the `inert:fragment:error` event. Siblings
46
+ * keep streaming untouched.
47
+ *
48
+ * `fallback` / `error` content is treated as trusted HTML (like `raw()`), so
49
+ * interpolate untrusted values through `vec\`\`` before handing them in.
50
+ *
51
+ * @param {Promise<any>|AsyncIterable<any>|object|((ctx: { signal: AbortSignal }) => any)} source
52
+ * The deferred value, or a factory that produces it (called lazily with an
53
+ * `{ signal }` that aborts when the response is torn down; a throw from the
54
+ * factory is caught and routed to `error`).
55
+ * @param {object} [options]
56
+ * @param {string|RawString} [options.fallback] Skeleton shown while pending.
57
+ * @param {string|RawString|object|((err: Error) => string|RawString|object)} [options.error]
58
+ * Content to patch in if `source` rejects or times out. A function receives the error.
59
+ * @param {number} [options.timeout]
60
+ * Milliseconds to wait before failing the fragment with a `DeferTimeoutError`
61
+ * (routed to `error` / `onError` like any other rejection).
62
+ * @returns {DeferredFragment}
63
+ */
64
+ export function defer(source, options = {}) {
65
+ return new DeferredFragment(source, options);
66
+ }
67
+
68
+ /** Internal marker produced by {@link defer}. */
69
+ export class DeferredFragment {
70
+ constructor(source, options = {}) {
71
+ this.type = 'VecFragment';
72
+ this.source = source;
73
+ this.fallback = options.fallback ?? null;
74
+ this.error = options.error ?? null;
75
+ this.timeout = options.timeout ?? null;
76
+ }
77
+ }
78
+
79
+ function isStreamable(v) {
80
+ return v instanceof Promise
81
+ || (v && typeof v[Symbol.asyncIterator] === 'function')
82
+ || (v && (v.type === 'VecStream' || v.type === 'VecFragment'));
83
+ }
84
+
85
+ /**
86
+ * Tagged template literal for Vector template engine.
87
+ * Safely escapes all interpolations based on their HTML context.
88
+ *
89
+ * @param {TemplateStringsArray} strings
90
+ * @param {...any} values
91
+ */
92
+ export function vec(strings, ...values) {
93
+ let plan = planCache.get(strings);
94
+ if (!plan) {
95
+ plan = analyzeTemplateStrings(strings);
96
+ // Minify statics on the fly during template parse
97
+ plan.statics = plan.statics.map(staticStr => minifyHTML(staticStr));
98
+ planCache.set(strings, plan);
99
+ }
100
+
101
+ // Check if we need to stream (any value is a Promise, AsyncIterable, nested
102
+ // stream, or a deferred fragment).
103
+ const isAsync = values.some(isStreamable);
104
+
105
+ if (isAsync) {
106
+ return {
107
+ type: 'VecStream',
108
+ plan,
109
+ values
110
+ };
111
+ }
112
+
113
+ let result = plan.statics[0];
114
+ for (let i = 0; i < values.length; i++) {
115
+ const val = values[i];
116
+ const ctx = plan.contexts[i];
117
+ const attrName = plan.attrNames[i];
118
+
119
+ if (val instanceof RawString) {
120
+ result += val.value;
121
+ } else if (val && (val.type === 'VecStream' || val.type === 'VecFragment')) {
122
+ throw new Error('E_INERT_VECTOR_NESTED_ASYNC: Async nested vec`` found in synchronous render context.');
123
+ } else if (Array.isArray(val)) {
124
+ result += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
125
+ } else {
126
+ result += escape(val, ctx, attrName);
127
+ }
128
+ result += plan.statics[i + 1];
129
+ }
130
+
131
+ // Return RawString so nested `vec` calls aren't double-escaped
132
+ return new RawString(result);
133
+ }
134
+
135
+ /**
136
+ * Generates an optimized image tag that hooks into InertJS's on-the-fly optimizer.
137
+ *
138
+ * @param {object} props Image properties: src, width, height, quality, class, alt
139
+ * @returns {RawString}
140
+ */
141
+ export function img({ src, width, height, quality, ...rest }) {
142
+ const query = new URLSearchParams();
143
+ query.set('src', src);
144
+ if (width) query.set('w', width);
145
+ if (height) query.set('h', height);
146
+ if (quality) query.set('q', quality);
147
+
148
+ const url = `/_inert/image?${query.toString()}`;
149
+
150
+ const attrs = Object.entries(rest)
151
+ .map(([key, val]) => `${key}="${escape(val, CONTEXT.ATTR_VALUE_DOUBLE)}"`)
152
+ .join(' ');
153
+
154
+ return raw(`<img src="${url}" ${attrs} />`, true);
155
+ }
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,354 @@
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, CONTEXT } from './escaper.js';
2
+ import { RawString } from './index.js';
3
+
4
+ const encoder = new TextEncoder();
5
+ const decoder = new TextDecoder();
6
+
7
+ /**
8
+ * Error handed to the error boundary / `onError` hook when a deferred fragment
9
+ * exceeds its `timeout`.
10
+ */
11
+ export class DeferTimeoutError extends Error {
12
+ constructor(slotId, ms) {
13
+ super(`Deferred fragment in slot ${slotId} timed out after ${ms}ms`);
14
+ this.name = 'DeferTimeoutError';
15
+ this.code = 'E_INERT_FRAGMENT_TIMEOUT';
16
+ this.slot = slotId;
17
+ this.timeout = ms;
18
+ }
19
+ }
20
+
21
+ function isDev() {
22
+ return typeof process !== 'undefined' && !!process.env && process.env.NODE_ENV !== 'production';
23
+ }
24
+
25
+ /**
26
+ * One inline script, emitted once into the shell. It applies every streamed
27
+ * `<template data-i-fill>` patch — synchronously for those already parsed, and
28
+ * via a MutationObserver for those that arrive later in the stream. Replaces the
29
+ * previous approach of re-emitting a full patcher script after every patch
30
+ * (which was O(n²) and multiplied inline-script surface under CSP).
31
+ */
32
+ function bootstrapScript(nonce) {
33
+ return `<script nonce="${nonce}">
34
+ (function(){
35
+ var W=window,D=document;
36
+ function patch(t){
37
+ var id=t.getAttribute('data-i-fill');
38
+ var slot=D.getElementById('i-slot-'+id);
39
+ if(slot){
40
+ var errored=t.hasAttribute('data-i-error');
41
+ slot.replaceWith(t.content);
42
+ if(errored){
43
+ var s=(W.__inert=W.__inert||{});
44
+ s.fragmentErrors=(s.fragmentErrors||0)+1;
45
+ try{W.dispatchEvent(new CustomEvent('inert:fragment:error',{detail:{slot:id}}));}catch(e){}
46
+ }
47
+ }
48
+ if(t.parentNode)t.remove();
49
+ }
50
+ function sweep(root){
51
+ var ts=(root||D).querySelectorAll('template[data-i-fill]');
52
+ for(var i=0;i<ts.length;i++)patch(ts[i]);
53
+ }
54
+ sweep();
55
+ if(W.MutationObserver){
56
+ var mo=new MutationObserver(function(muts){
57
+ for(var i=0;i<muts.length;i++){
58
+ var added=muts[i].addedNodes;
59
+ for(var j=0;j<added.length;j++){
60
+ var n=added[j];
61
+ if(n.nodeType!==1)continue;
62
+ if(n.tagName==='TEMPLATE'&&n.hasAttribute('data-i-fill'))patch(n);
63
+ else if(n.querySelector&&n.querySelector('template[data-i-fill]'))sweep(n);
64
+ }
65
+ }
66
+ });
67
+ mo.observe(D.documentElement,{childList:true,subtree:true});
68
+ var done=function(){sweep();mo.disconnect();};
69
+ if(D.readyState==='complete')done();
70
+ else W.addEventListener('load',done);
71
+ }else{
72
+ W.addEventListener('load',function(){sweep();});
73
+ }
74
+ })();
75
+ </script>`;
76
+ }
77
+
78
+ /** A bare patch: the bootstrap script (already in the shell) applies it. */
79
+ function fillPatch(slotId, html, errored) {
80
+ return `\n<template data-i-fill="${slotId}"${errored ? ' data-i-error="1"' : ''}>${html}</template>`;
81
+ }
82
+
83
+ /**
84
+ * Fully drains a WHATWG ReadableStream of Uint8Array chunks into a string,
85
+ * always releasing the reader lock.
86
+ */
87
+ async function drainStream(readable) {
88
+ const reader = readable.getReader();
89
+ let html = '';
90
+ try {
91
+ while (true) {
92
+ const { done, value } = await reader.read();
93
+ if (done) break;
94
+ html += decoder.decode(value, { stream: true });
95
+ }
96
+ html += decoder.decode();
97
+ } finally {
98
+ reader.releaseLock();
99
+ }
100
+ return html;
101
+ }
102
+
103
+ /**
104
+ * Resolves an already-settled value (the result of `await`ing a hole) into an
105
+ * HTML string, recursing through nested streams, RawStrings and arrays.
106
+ */
107
+ async function valueToHtml(value, ctx, attrName, nonce, options) {
108
+ if (value == null || value === false) return '';
109
+ if (value instanceof RawString) return value.value;
110
+ if (value && value.type === 'VecStream') {
111
+ return drainStream(renderToStream(value, nonce, options));
112
+ }
113
+ if (Array.isArray(value)) {
114
+ let out = '';
115
+ for (const item of value) out += await valueToHtml(item, ctx, attrName, nonce, options);
116
+ return out;
117
+ }
118
+ return escape(value, ctx, attrName);
119
+ }
120
+
121
+ /** Synchronously resolves a fragment's skeleton (must be inlined into the shell). */
122
+ function skeletonHtml(fragment) {
123
+ const f = fragment && fragment.fallback;
124
+ if (f == null) return '';
125
+ if (f instanceof RawString) return f.value;
126
+ if (typeof f === 'string') return f;
127
+ return '';
128
+ }
129
+
130
+ /** Resolves a fragment's error boundary content once the source has rejected. */
131
+ async function errorBoundaryHtml(fragment, err, slotId, ctx, attrName, nonce, options) {
132
+ let content = fragment && fragment.error;
133
+ if (typeof content === 'function') {
134
+ try {
135
+ content = content(err);
136
+ } catch (boundaryErr) {
137
+ console.error('[InertJS] Fragment error boundary threw:', boundaryErr);
138
+ content = null;
139
+ }
140
+ }
141
+ if (content == null) {
142
+ if (isDev()) {
143
+ const msg = escape(err && err.message ? err.message : String(err), CONTEXT.TEXT);
144
+ return `<div data-inert-fragment-error style="border:1px dashed #f43f5e;background:#fff1f2;color:#9f1239;` +
145
+ `padding:6px 10px;font:12px/1.5 ui-monospace,monospace;border-radius:4px">` +
146
+ `⚠ InertJS fragment ${slotId} failed: ${msg}</div>`;
147
+ }
148
+ return '';
149
+ }
150
+ if (content instanceof RawString) return content.value;
151
+ if (content && content.type === 'VecStream') {
152
+ return drainStream(renderToStream(content, nonce, options));
153
+ }
154
+ return String(content);
155
+ }
156
+
157
+ /** Races a fragment's html production against its `timeout`, if any. */
158
+ function withTimeout(promise, ms, slotId) {
159
+ if (!ms || ms <= 0) return promise;
160
+ let timer;
161
+ const timeout = new Promise((_, reject) => {
162
+ timer = setTimeout(() => reject(new DeferTimeoutError(slotId, ms)), ms);
163
+ if (timer && typeof timer.unref === 'function') timer.unref();
164
+ });
165
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
166
+ }
167
+
168
+ /**
169
+ * Renders a VecStream (or RawString) to a WHATWG ReadableStream.
170
+ * Handles out-of-order streaming for Promises, AsyncIterables and deferred
171
+ * fragments.
172
+ *
173
+ * Once the synchronous shell has been flushed, a hole that rejects can no
174
+ * longer surface as an HTTP error. Such a failure is isolated to its own slot:
175
+ * the slot is patched with the fragment's error boundary (or emptied), the
176
+ * client is signalled via `inert:fragment:error`, `options.onError` is invoked,
177
+ * and every sibling hole keeps streaming.
178
+ *
179
+ * @param {object} vecResult The result from calling vec\`\`
180
+ * @param {string} [nonce] The CSP nonce for inline scripts
181
+ * @param {object} [options]
182
+ * @param {(err: Error, info: { slot: number }) => void} [options.onError]
183
+ * Called for every hole that rejects (or times out) after the shell flushed.
184
+ * @param {AbortSignal} [options.signal]
185
+ * Aborting it stops the stream and is forwarded to deferred fragment
186
+ * factories as `defer(({ signal }) => ...)`.
187
+ * @returns {ReadableStream}
188
+ */
189
+ export function renderToStream(vecResult, nonce = '', options = {}) {
190
+ if (!(vecResult && vecResult.type === 'VecStream')) {
191
+ const value = vecResult instanceof RawString ? vecResult.value : String(vecResult);
192
+ return new ReadableStream({
193
+ start(controller) {
194
+ controller.enqueue(encoder.encode(value));
195
+ controller.close();
196
+ }
197
+ });
198
+ }
199
+
200
+ const { plan, values } = vecResult;
201
+ const onError = typeof options.onError === 'function' ? options.onError : null;
202
+
203
+ // Every render has a real AbortSignal so `defer` factories can always rely on
204
+ // `({ signal })`. It fires when the caller's signal fires, when the consumer
205
+ // cancels the stream, or when a write reveals the client has gone away.
206
+ const abort = new AbortController();
207
+ const signal = abort.signal;
208
+ if (options.signal) {
209
+ if (options.signal.aborted) abort.abort();
210
+ else options.signal.addEventListener('abort', () => abort.abort(), { once: true });
211
+ }
212
+
213
+ let slotCounter = 0;
214
+ const pendingTasks = new Set();
215
+
216
+ return new ReadableStream({
217
+ async start(controller) {
218
+ let closed = false;
219
+
220
+ const enqueue = (str) => {
221
+ if (closed || signal.aborted) return;
222
+ try {
223
+ controller.enqueue(encoder.encode(str));
224
+ } catch {
225
+ // Client went away; stop trying to write and let fragments bail.
226
+ closed = true;
227
+ abort.abort();
228
+ }
229
+ };
230
+
231
+ const closeController = () => {
232
+ if (closed) return;
233
+ closed = true;
234
+ try {
235
+ controller.close();
236
+ } catch {
237
+ /* already closed / errored */
238
+ }
239
+ };
240
+
241
+ if (signal.aborted) {
242
+ closeController();
243
+ return;
244
+ }
245
+ signal.addEventListener('abort', closeController, { once: true });
246
+
247
+ try {
248
+ let initialHtml = plan.statics[0];
249
+
250
+ for (let i = 0; i < values.length; i++) {
251
+ const val = values[i];
252
+ const ctx = plan.contexts[i];
253
+ const attrName = plan.attrNames[i];
254
+
255
+ const isPromise = val instanceof Promise;
256
+ const isAsyncIter = val && typeof val[Symbol.asyncIterator] === 'function';
257
+ const isNestedStream = val && val.type === 'VecStream';
258
+ const isFragment = val && val.type === 'VecFragment';
259
+
260
+ if (isPromise || isAsyncIter || isNestedStream || isFragment) {
261
+ const slotId = ++slotCounter;
262
+ const fragment = isFragment ? val : null;
263
+ initialHtml += `<i-slot id="i-slot-${slotId}">${fragment ? skeletonHtml(fragment) : ''}</i-slot>`;
264
+
265
+ const task = (async () => {
266
+ try {
267
+ const build = (async () => {
268
+ let source = fragment ? fragment.source : val;
269
+ if (typeof source === 'function') source = source({ signal });
270
+
271
+ if (source && source.type === 'VecStream') {
272
+ return drainStream(renderToStream(source, nonce, options));
273
+ }
274
+ if (source && typeof source[Symbol.asyncIterator] === 'function') {
275
+ let accumulated = '';
276
+ for await (const chunk of source) {
277
+ accumulated += chunk instanceof RawString
278
+ ? chunk.value
279
+ : escape(chunk, ctx, attrName);
280
+ }
281
+ return accumulated;
282
+ }
283
+ return valueToHtml(await source, ctx, attrName, nonce, options);
284
+ })();
285
+ build.catch(() => {}); // a lost timeout race must not warn
286
+
287
+ const html = await withTimeout(build, fragment && fragment.timeout, slotId);
288
+ enqueue(fillPatch(slotId, html, false));
289
+ } catch (err) {
290
+ console.error(
291
+ `[InertJS] Deferred fragment in slot ${slotId} failed after the shell was flushed:`,
292
+ err
293
+ );
294
+ if (onError) {
295
+ try {
296
+ onError(err, { slot: slotId });
297
+ } catch (hookErr) {
298
+ console.error('[InertJS] renderToStream onError hook threw:', hookErr);
299
+ }
300
+ }
301
+ let boundary = '';
302
+ try {
303
+ boundary = await errorBoundaryHtml(fragment, err, slotId, ctx, attrName, nonce, options);
304
+ } catch (boundaryErr) {
305
+ console.error('[InertJS] Fragment error boundary failed:', boundaryErr);
306
+ }
307
+ enqueue(fillPatch(slotId, boundary, true));
308
+ } finally {
309
+ pendingTasks.delete(task);
310
+ if (pendingTasks.size === 0) closeController();
311
+ }
312
+ })();
313
+
314
+ pendingTasks.add(task);
315
+ } else {
316
+ // Synchronous value
317
+ if (val instanceof RawString) {
318
+ initialHtml += val.value;
319
+ } else if (Array.isArray(val)) {
320
+ initialHtml += val.map(v => v instanceof RawString ? v.value : escape(v, ctx, attrName)).join('');
321
+ } else {
322
+ initialHtml += escape(val, ctx, attrName);
323
+ }
324
+ }
325
+ initialHtml += plan.statics[i + 1];
326
+ }
327
+
328
+ // One patcher for the whole document, only when something actually streams.
329
+ if (slotCounter > 0) {
330
+ initialHtml += bootstrapScript(nonce);
331
+ }
332
+
333
+ // Flush the synchronous initial shell
334
+ enqueue(initialHtml);
335
+
336
+ if (pendingTasks.size === 0) {
337
+ closeController();
338
+ }
339
+ } catch (err) {
340
+ // Reached only while the shell is still being built (nothing flushed yet),
341
+ // so it is safe to fail the whole response here.
342
+ try {
343
+ controller.error(err);
344
+ } catch {
345
+ /* already torn down */
346
+ }
347
+ }
348
+ },
349
+ cancel() {
350
+ // Consumer walked away: let in-flight fragment work abort itself.
351
+ abort.abort();
352
+ }
353
+ });
354
+ }
@@ -0,0 +1,202 @@
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
+ });
109
+
110
+ test('Out-of-order streaming: hardening (beta.8)', async (t) => {
111
+ await t.test('one bootstrap patcher script, patches are bare templates', async () => {
112
+ const a = Promise.resolve(raw('<p>a</p>'));
113
+ const b = Promise.resolve(raw('<p>b</p>'));
114
+ const html = await collect(renderToStream(vec`<div>${a}</div><div>${b}</div>`, 'nn'));
115
+
116
+ const scripts = html.match(/<script nonce="nn">/g) || [];
117
+ assert.strictEqual(scripts.length, 1, 'exactly one inline script');
118
+ assert.ok(html.includes('MutationObserver'), 'bootstrap installs a MutationObserver');
119
+ assert.match(html, /<template data-i-fill="1"><p>a<\/p><\/template>/);
120
+ assert.match(html, /<template data-i-fill="2"><p>b<\/p><\/template>/);
121
+ });
122
+
123
+ await t.test('no patcher script when nothing streams', async () => {
124
+ const html = await collect(renderToStream(vec`<p>${Promise.resolve('x')}</p>`, 'z'));
125
+ // one slot -> one script; a fully sync template never reaches renderToStream's stream path
126
+ assert.ok(html.includes('<script nonce="z">'));
127
+ const sync = await collect(renderToStream(raw('<p>hi</p>'), 'z'));
128
+ assert.strictEqual(sync, '<p>hi</p>');
129
+ });
130
+
131
+ await t.test('defer timeout patches the error boundary and closes the stream', async () => {
132
+ const frag = defer(new Promise(() => {}), { timeout: 40, error: raw('<p>too slow</p>') });
133
+ const html = await collect(renderToStream(vec`<main>${frag}</main>`, 'n'), 2000);
134
+ assert.ok(html.includes('data-i-error="1"'));
135
+ assert.ok(html.includes('<p>too slow</p>'));
136
+ });
137
+
138
+ await t.test('timeout surfaces as DeferTimeoutError on the onError hook', async () => {
139
+ const seen = [];
140
+ const frag = defer(new Promise(() => {}), { timeout: 30 });
141
+ await collect(renderToStream(vec`<main>${frag}</main>`, 'n', {
142
+ onError: (err) => seen.push(err)
143
+ }), 2000);
144
+ assert.strictEqual(seen.length, 1);
145
+ assert.strictEqual(seen[0].code, 'E_INERT_FRAGMENT_TIMEOUT');
146
+ assert.strictEqual(seen[0].slot, 1);
147
+ });
148
+
149
+ await t.test('abort signal stops the stream', async () => {
150
+ const ac = new AbortController();
151
+ const frag = defer(new Promise(() => {}), { fallback: raw('<i>loading</i>') });
152
+ const reader = renderToStream(vec`<main>${frag}</main>`, 'n', { signal: ac.signal }).getReader();
153
+
154
+ const shell = decoder.decode((await reader.read()).value);
155
+ assert.ok(shell.includes('i-slot-1'));
156
+ ac.abort();
157
+ assert.strictEqual((await reader.read()).done, true, 'closed after abort');
158
+ });
159
+
160
+ await t.test('a pre-aborted signal yields an immediately-closed stream', async () => {
161
+ const ac = new AbortController();
162
+ ac.abort();
163
+ const reader = renderToStream(vec`<main>${Promise.resolve('x')}</main>`, 'n', { signal: ac.signal }).getReader();
164
+ assert.strictEqual((await reader.read()).done, true);
165
+ });
166
+
167
+ await t.test('fragment factory receives an abort signal', async () => {
168
+ let received;
169
+ const frag = defer(({ signal }) => { received = signal; return Promise.resolve(raw('ok')); });
170
+ await collect(renderToStream(vec`<main>${frag}</main>`, 'n'));
171
+ assert.ok(received && typeof received.aborted === 'boolean');
172
+ });
173
+
174
+ await t.test('dev mode: a boundary-less failure renders a visible marker', async () => {
175
+ const prev = process.env.NODE_ENV;
176
+ delete process.env.NODE_ENV;
177
+ try {
178
+ const bad = Promise.reject(new Error('kaboom <x>'));
179
+ bad.catch(() => {});
180
+ const html = await collect(renderToStream(vec`<main>${bad}</main>`, 'n'));
181
+ assert.ok(html.includes('data-inert-fragment-error'));
182
+ assert.ok(html.includes('kaboom &lt;x&gt;'), 'error message is escaped');
183
+ } finally {
184
+ if (prev !== undefined) process.env.NODE_ENV = prev;
185
+ }
186
+ });
187
+
188
+ await t.test('production mode: a boundary-less failure renders nothing visible', async () => {
189
+ const prev = process.env.NODE_ENV;
190
+ process.env.NODE_ENV = 'production';
191
+ try {
192
+ const bad = Promise.reject(new Error('kaboom'));
193
+ bad.catch(() => {});
194
+ const html = await collect(renderToStream(vec`<main>${bad}</main>`, 'n'));
195
+ assert.ok(html.includes('data-i-fill="1" data-i-error="1"'));
196
+ assert.ok(!html.includes('data-inert-fragment-error'));
197
+ assert.match(html, /<template data-i-fill="1" data-i-error="1"><\/template>/);
198
+ } finally {
199
+ if (prev !== undefined) process.env.NODE_ENV = prev; else delete process.env.NODE_ENV;
200
+ }
201
+ });
202
+ });