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 +16 -16
- package/src/index.js +146 -102
- package/src/resolve.js +66 -48
- package/src/stream.js +251 -142
- package/test/streaming-error.test.js +108 -0
package/package.json
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "inertjs-vector",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
4
|
-
"type": "module",
|
|
5
|
-
"main": "src/index.js",
|
|
6
|
-
"dependencies": {
|
|
7
|
-
"inertjs-optimizer": "^1.0.0-beta.
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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 (
|
|
17
|
-
let
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
+
});
|