inertjs-vector 1.0.0-beta.7 → 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,6 +1,6 @@
1
1
  {
2
2
  "name": "inertjs-vector",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "dependencies": {
package/src/index.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { analyzeTemplateStrings, escape, CONTEXT } from './escaper.js';
2
2
  import { minifyHTML } from 'inertjs-optimizer';
3
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.
4
8
 
5
9
  const planCache = new WeakMap();
6
10
 
@@ -44,13 +48,17 @@ export function raw(str, suppressWarning = false) {
44
48
  * `fallback` / `error` content is treated as trusted HTML (like `raw()`), so
45
49
  * interpolate untrusted values through `vec\`\`` before handing them in.
46
50
  *
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`).
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`).
50
55
  * @param {object} [options]
51
56
  * @param {string|RawString} [options.fallback] Skeleton shown while pending.
52
57
  * @param {string|RawString|object|((err: Error) => string|RawString|object)} [options.error]
53
- * Content to patch in if `source` rejects. A function receives the 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).
54
62
  * @returns {DeferredFragment}
55
63
  */
56
64
  export function defer(source, options = {}) {
@@ -64,6 +72,7 @@ export class DeferredFragment {
64
72
  this.source = source;
65
73
  this.fallback = options.fallback ?? null;
66
74
  this.error = options.error ?? null;
75
+ this.timeout = options.timeout ?? null;
67
76
  }
68
77
  }
69
78
 
package/src/stream.js CHANGED
@@ -1,28 +1,84 @@
1
- import { escape } from './escaper.js';
1
+ import { escape, CONTEXT } from './escaper.js';
2
2
  import { RawString } from './index.js';
3
3
 
4
4
  const encoder = new TextEncoder();
5
5
  const decoder = new TextDecoder();
6
6
 
7
- const PATCHER_SCRIPT = `<script nonce="[INERT_NONCE]">
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}">
8
34
  (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);
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);
13
39
  if(slot){
14
- const errored=d.hasAttribute('data-i-error');
15
- slot.replaceWith(d.content);
40
+ var errored=t.hasAttribute('data-i-error');
41
+ slot.replaceWith(t.content);
16
42
  if(errored){
17
- const s=(window.__inert=window.__inert||{});
43
+ var s=(W.__inert=W.__inert||{});
18
44
  s.fragmentErrors=(s.fragmentErrors||0)+1;
19
- try{window.dispatchEvent(new CustomEvent('inert:fragment:error',{detail:{slot:id}}));}catch(e){}
45
+ try{W.dispatchEvent(new CustomEvent('inert:fragment:error',{detail:{slot:id}}));}catch(e){}
20
46
  }
21
47
  }
22
- d.remove();
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();});
23
73
  }
24
74
  })();
25
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
+ }
26
82
 
27
83
  /**
28
84
  * Fully drains a WHATWG ReadableStream of Uint8Array chunks into a string,
@@ -48,15 +104,15 @@ async function drainStream(readable) {
48
104
  * Resolves an already-settled value (the result of `await`ing a hole) into an
49
105
  * HTML string, recursing through nested streams, RawStrings and arrays.
50
106
  */
51
- async function valueToHtml(value, ctx, attrName, nonce) {
107
+ async function valueToHtml(value, ctx, attrName, nonce, options) {
52
108
  if (value == null || value === false) return '';
53
109
  if (value instanceof RawString) return value.value;
54
110
  if (value && value.type === 'VecStream') {
55
- return drainStream(renderToStream(value, nonce));
111
+ return drainStream(renderToStream(value, nonce, options));
56
112
  }
57
113
  if (Array.isArray(value)) {
58
114
  let out = '';
59
- for (const item of value) out += await valueToHtml(item, ctx, attrName, nonce);
115
+ for (const item of value) out += await valueToHtml(item, ctx, attrName, nonce, options);
60
116
  return out;
61
117
  }
62
118
  return escape(value, ctx, attrName);
@@ -72,27 +128,41 @@ function skeletonHtml(fragment) {
72
128
  }
73
129
 
74
130
  /** Resolves a fragment's error boundary content once the source has rejected. */
75
- async function errorBoundaryHtml(fragment, err, ctx, attrName, nonce) {
131
+ async function errorBoundaryHtml(fragment, err, slotId, ctx, attrName, nonce, options) {
76
132
  let content = fragment && fragment.error;
77
133
  if (typeof content === 'function') {
78
134
  try {
79
135
  content = content(err);
80
136
  } catch (boundaryErr) {
81
137
  console.error('[InertJS] Fragment error boundary threw:', boundaryErr);
82
- return '';
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>`;
83
147
  }
148
+ return '';
84
149
  }
85
- if (content == null) return '';
86
150
  if (content instanceof RawString) return content.value;
87
151
  if (content && content.type === 'VecStream') {
88
- return drainStream(renderToStream(content, nonce));
152
+ return drainStream(renderToStream(content, nonce, options));
89
153
  }
90
154
  return String(content);
91
155
  }
92
156
 
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);
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));
96
166
  }
97
167
 
98
168
  /**
@@ -110,7 +180,10 @@ function fillPatch(slotId, html, nonce, errored) {
110
180
  * @param {string} [nonce] The CSP nonce for inline scripts
111
181
  * @param {object} [options]
112
182
  * @param {(err: Error, info: { slot: number }) => void} [options.onError]
113
- * Called for every hole that rejects after the shell was flushed.
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 }) => ...)`.
114
187
  * @returns {ReadableStream}
115
188
  */
116
189
  export function renderToStream(vecResult, nonce = '', options = {}) {
@@ -126,6 +199,17 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
126
199
 
127
200
  const { plan, values } = vecResult;
128
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
+
129
213
  let slotCounter = 0;
130
214
  const pendingTasks = new Set();
131
215
 
@@ -134,12 +218,13 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
134
218
  let closed = false;
135
219
 
136
220
  const enqueue = (str) => {
137
- if (closed) return;
221
+ if (closed || signal.aborted) return;
138
222
  try {
139
223
  controller.enqueue(encoder.encode(str));
140
224
  } catch {
141
- // Client went away; stop trying to write.
225
+ // Client went away; stop trying to write and let fragments bail.
142
226
  closed = true;
227
+ abort.abort();
143
228
  }
144
229
  };
145
230
 
@@ -153,6 +238,12 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
153
238
  }
154
239
  };
155
240
 
241
+ if (signal.aborted) {
242
+ closeController();
243
+ return;
244
+ }
245
+ signal.addEventListener('abort', closeController, { once: true });
246
+
156
247
  try {
157
248
  let initialHtml = plan.statics[0];
158
249
 
@@ -173,28 +264,31 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
173
264
 
174
265
  const task = (async () => {
175
266
  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);
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));
188
273
  }
189
- html = accumulated;
190
- } else {
191
- html = await valueToHtml(await source, ctx, attrName, nonce);
192
- }
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
193
286
 
194
- enqueue(fillPatch(slotId, html, nonce, false));
287
+ const html = await withTimeout(build, fragment && fragment.timeout, slotId);
288
+ enqueue(fillPatch(slotId, html, false));
195
289
  } catch (err) {
196
290
  console.error(
197
- `[InertJS] Deferred fragment in slot ${slotId} rejected after the shell was flushed:`,
291
+ `[InertJS] Deferred fragment in slot ${slotId} failed after the shell was flushed:`,
198
292
  err
199
293
  );
200
294
  if (onError) {
@@ -206,11 +300,11 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
206
300
  }
207
301
  let boundary = '';
208
302
  try {
209
- boundary = await errorBoundaryHtml(fragment, err, ctx, attrName, nonce);
303
+ boundary = await errorBoundaryHtml(fragment, err, slotId, ctx, attrName, nonce, options);
210
304
  } catch (boundaryErr) {
211
305
  console.error('[InertJS] Fragment error boundary failed:', boundaryErr);
212
306
  }
213
- enqueue(fillPatch(slotId, boundary, nonce, true));
307
+ enqueue(fillPatch(slotId, boundary, true));
214
308
  } finally {
215
309
  pendingTasks.delete(task);
216
310
  if (pendingTasks.size === 0) closeController();
@@ -231,6 +325,11 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
231
325
  initialHtml += plan.statics[i + 1];
232
326
  }
233
327
 
328
+ // One patcher for the whole document, only when something actually streams.
329
+ if (slotCounter > 0) {
330
+ initialHtml += bootstrapScript(nonce);
331
+ }
332
+
234
333
  // Flush the synchronous initial shell
235
334
  enqueue(initialHtml);
236
335
 
@@ -246,6 +345,10 @@ export function renderToStream(vecResult, nonce = '', options = {}) {
246
345
  /* already torn down */
247
346
  }
248
347
  }
348
+ },
349
+ cancel() {
350
+ // Consumer walked away: let in-flight fragment work abort itself.
351
+ abort.abort();
249
352
  }
250
353
  });
251
354
  }
@@ -106,3 +106,97 @@ test('Out-of-order streaming: fragment errors after shell flush', async (t) => {
106
106
  assert.strictEqual(await resolveToString(vec`<p>${failed}</p>`), '<p><i>oops</i></p>');
107
107
  });
108
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
+ });