@transclude/core 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/dev.js CHANGED
@@ -264,17 +264,24 @@ const handleAction = async (route, c) => {
264
264
  : sendFragment(route, c, region, extra);
265
265
  };
266
266
 
267
- const onError = (c, err) => {
267
+ const onError = (c, err, at = null) => {
268
268
  // Before anything reads the stack: Vite's transform means the raw one points
269
269
  // at generated code, and a reporter given that is worse than none.
270
270
  vite.ssrFixStacktrace(err);
271
271
  console.error(err);
272
272
 
273
- // The same seam production has, so a reporter is exercised while you are the
274
- // one looking at it rather than first on a live site.
273
+ // The same seam production has, with the same shape, so a reporter is
274
+ // exercised while you are the one looking at it rather than first on a live
275
+ // site. A field dev left null would read as a production bug later.
275
276
  if (typeof config.onError === 'function') {
276
277
  try {
277
- config.onError(err, { request: c.req.raw, url: c.req.url, method: c.req.method });
278
+ config.onError(err, {
279
+ request: c.req.raw,
280
+ url: c.req.url,
281
+ method: c.req.method,
282
+ route: at ? { id: at.route.id, pattern: at.route.pattern, params: c.req.param() } : null,
283
+ phase: at?.phase ?? null,
284
+ });
278
285
  } catch (failed) {
279
286
  console.error('[transclude] onError itself threw:', failed);
280
287
  }
@@ -389,7 +396,7 @@ async function buildApp() {
389
396
  const fragment = fragmentOf(c);
390
397
  return fragment === null ? await renderPage(route, c) : await sendFragment(route, c, fragment);
391
398
  } catch (err) {
392
- return onError(c, err);
399
+ return onError(c, err, { route, phase: fragmentOf(c) === null ? 'page' : 'fragment' });
393
400
  }
394
401
  });
395
402
 
@@ -400,7 +407,7 @@ async function buildApp() {
400
407
  try {
401
408
  return await handleAction(route, c);
402
409
  } catch (err) {
403
- return onError(c, err);
410
+ return onError(c, err, { route, phase: 'action' });
404
411
  }
405
412
  });
406
413
  }
@@ -422,7 +429,7 @@ async function buildApp() {
422
429
  Allow: endpointMethods(mod).join(', '),
423
430
  });
424
431
  } catch (err) {
425
- return onError(c, err);
432
+ return onError(c, err, { route, phase: 'endpoint' });
426
433
  }
427
434
  });
428
435
  }
@@ -432,7 +439,7 @@ async function buildApp() {
432
439
  try {
433
440
  return await renderPage(notFound, c, 404);
434
441
  } catch (err) {
435
- return onError(c, err);
442
+ return onError(c, err, { route: notFound, phase: 'page' });
436
443
  }
437
444
  });
438
445
 
package/bin/serve.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // Node adapter. The app is in src/production.js; this listens with it.
3
3
 
4
4
  import { serve } from '@hono/node-server';
5
+ import { drainOn } from '../src/drain.js';
5
6
  import { app, noBuild, port, summary } from '../src/production.js';
6
7
 
7
8
  if (noBuild) {
@@ -9,4 +10,8 @@ if (noBuild) {
9
10
  process.exit(1);
10
11
  }
11
12
 
12
- serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
13
+ const server = serve({ fetch: app.fetch, port }, ({ port }) => summary(port));
14
+
15
+ // A container sends SIGTERM and waits. Node's default is to die on the spot,
16
+ // which cuts a render that was halfway through answering.
17
+ drainOn(server);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@transclude/core",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "An HTML-first server framework. A page is an .html file, the directory tree is the route table, and any fragment of a page is a URL of its own. Runs on Node, Bun, Deno and workerd, and ships no client JavaScript by default.",
5
5
  "keywords": [
6
6
  "html",
package/src/app.js CHANGED
@@ -35,6 +35,11 @@ import { withDefaults } from './defaults.js';
35
35
 
36
36
  const IMMUTABLE = 'public, max-age=31536000, immutable';
37
37
  const REVALIDATE = 'public, max-age=0, must-revalidate';
38
+ // What a personal render says instead. `public` is an explicit grant, and a
39
+ // page that read a cookie is one visitor's. A conforming shared cache would
40
+ // revalidate and miss on the ETag anyway; this is for the CDN whose edge rule
41
+ // skips revalidation and would hand one visitor's page to the next.
42
+ const PERSONAL = 'private, no-cache';
38
43
 
39
44
  // One per process rather than one per render. It holds no state between calls.
40
45
  const encoder = new TextEncoder();
@@ -220,7 +225,7 @@ export function createApp({
220
225
  revalidateTag: cache.revalidateTag,
221
226
  // Reported through `report`, so work that fails after the reader is gone
222
227
  // is not quieter than work that fails in front of them.
223
- after: afterFor(c, (error) => report(error, c)),
228
+ after: afterFor(c, (error) => report(error, c, { route, phase: 'after' })),
224
229
  ...extra,
225
230
  };
226
231
  };
@@ -316,7 +321,7 @@ export function createApp({
316
321
  if (html === null) return c.text(`no fragment "${region}"`, 404);
317
322
  return sendRendered(c, html, ctx);
318
323
  } catch (err) {
319
- return internalError(c, err);
324
+ return internalError(c, err, { route, phase: 'fragment' });
320
325
  }
321
326
  });
322
327
 
@@ -369,7 +374,7 @@ export function createApp({
369
374
  if (html instanceof Response) return withEnvelope(html, ctx);
370
375
  return sendRendered(c, html, ctx);
371
376
  } catch (err) {
372
- return internalError(c, err);
377
+ return internalError(c, err, { route, phase: 'action' });
373
378
  }
374
379
  });
375
380
  }
@@ -391,7 +396,7 @@ export function createApp({
391
396
  Allow: endpointMethods(mod).join(', '),
392
397
  });
393
398
  } catch (err) {
394
- return internalError(c, err);
399
+ return internalError(c, err, { route, phase: 'endpoint' });
395
400
  }
396
401
  });
397
402
  }
@@ -450,7 +455,7 @@ export function createApp({
450
455
  // workerd stops the rebuild when this response is sent, and the entry it
451
456
  // leaves in the in-flight map answers every later request with a dead
452
457
  // promise.
453
- const after = afterFor(c, (error) => report(error, c));
458
+ const after = afterFor(c, (error) => report(error, c, { route, phase: 'revalidate' }));
454
459
  const html = await cache.read(cacheKey(c.req.url), window, render, after);
455
460
 
456
461
  // A miss rendered through the cache, and that render can answer with a
@@ -462,7 +467,7 @@ export function createApp({
462
467
  const ctx = last ? last.ctx : contextFor(route, c);
463
468
  return sendRendered(c, html, ctx, preload);
464
469
  } catch (err) {
465
- return internalError(c, err);
470
+ return internalError(c, err, { route, phase: 'page' });
466
471
  }
467
472
  });
468
473
  }
@@ -475,19 +480,28 @@ export function createApp({
475
480
  * `console.error` is the default and not much of one: a real site sends this
476
481
  * to something that can page a person. `onError` is that seam, and it is given
477
482
  * the request as well, because an error with no URL and no method is most of
478
- * the way to useless.
483
+ * the way to useless. `route` and `phase` say where: the reader starts at the
484
+ * loader of `people/[slug]` with `slug: 'ada'` rather than at a URL to
485
+ * re-derive that from. The phases are page, fragment, action, endpoint,
486
+ * after and revalidate.
479
487
  *
480
488
  * It is called inside a `try`. A reporter that throws would otherwise replace
481
489
  * the error being reported, which is the one failure mode a reporting hook
482
490
  * must not have.
483
491
  */
484
- function report(err, c) {
492
+ function report(err, c, at = null) {
485
493
  if (typeof config.onError !== 'function') {
486
494
  console.error(err);
487
495
  return;
488
496
  }
489
497
  try {
490
- config.onError(err, { request: c.req.raw, url: c.req.url, method: c.req.method });
498
+ config.onError(err, {
499
+ request: c.req.raw,
500
+ url: c.req.url,
501
+ method: c.req.method,
502
+ route: at ? { id: at.route.id, pattern: at.route.pattern, params: c.req.param() } : null,
503
+ phase: at?.phase ?? null,
504
+ });
491
505
  } catch (failed) {
492
506
  console.error(err);
493
507
  console.error('[transclude] onError itself threw:', failed);
@@ -495,8 +509,8 @@ export function createApp({
495
509
  }
496
510
 
497
511
  /** Every `catch` above. One place decides what a failed request looks like. */
498
- function internalError(c, err) {
499
- report(err, c);
512
+ function internalError(c, err, at = null) {
513
+ report(err, c, at);
500
514
  // No ETag and no Cache-Control: nothing about a failure should be stored or
501
515
  // revalidated, and the same bytes would be sent for an unrelated one next time.
502
516
  if (!errorPage) return c.text('Internal error', 500);
@@ -529,7 +543,10 @@ export function createApp({
529
543
  const etag = encoding ? `${base.slice(0, -1)}-${encoding}"` : base;
530
544
 
531
545
  c.header('Vary', varyOn);
532
- c.header('Cache-Control', REVALIDATE);
546
+ // The same test that gates the held-page store. A shareable render is
547
+ // anyone's; a personal one has to say so, or a cache told `public` would
548
+ // be within its rights to believe it.
549
+ c.header('Cache-Control', ctx && !isShareable(html, ctx) ? PERSONAL : REVALIDATE);
533
550
  c.header('ETag', etag);
534
551
 
535
552
  // Whatever the loaders put on `ctx.response`, after the defaults above so a
package/src/cookies.js CHANGED
@@ -45,7 +45,9 @@ export function cookiesOf(request, response, secret = null) {
45
45
  // `typeof` along the way said `string`, and the config carried it all the
46
46
  // way here. The only thing that said otherwise was the length. Reading
47
47
  // "needs a secret" while looking at a secret that is plainly set sends you
48
- // hunting through the wiring instead of the value.
48
+ // hunting through the wiring instead of the value. `withDefaults` refuses
49
+ // the empty string at boot now; this stays for a `cookiesOf` reached
50
+ // without it.
49
51
  if (typeof secret === 'string') {
50
52
  throw new Error(
51
53
  `[transclude] ${what} needs a secret, and \`cookieSecret\` is set to an ` +
@@ -168,5 +170,18 @@ function overTls(request) {
168
170
  * turns the whole thing off. Set it yourself to override either way.
169
171
  */
170
172
  function withDefaults(options, request) {
171
- return { path: '/', httpOnly: true, sameSite: 'Lax', secure: overTls(request), ...options };
173
+ const merged = { path: '/', httpOnly: true, sameSite: 'Lax', secure: overTls(request), ...options };
174
+
175
+ // Every browser drops this pair, silently, so writing it is never right.
176
+ // `None` is for a cookie sent cross-site, and those are Secure-only
177
+ // everywhere. Refused here rather than left to the browser, because a cookie
178
+ // that never arrives reads exactly like a bug somewhere else.
179
+ if (String(merged.sameSite).toLowerCase() === 'none' && !merged.secure) {
180
+ throw new Error(
181
+ `[transclude] a cookie with \`sameSite: 'None'\` needs \`secure: true\`. Every ` +
182
+ `browser drops the pair without it, silently. Set both, or use 'Lax'.`,
183
+ );
184
+ }
185
+
186
+ return merged;
172
187
  }
package/src/defaults.js CHANGED
@@ -97,6 +97,19 @@ export function withDefaults(config = {}) {
97
97
 
98
98
  const merged = { ...DEFAULTS, ...config };
99
99
 
100
+ // Set but empty is refused at boot rather than at the first signed cookie,
101
+ // because that first read happens in production, at request time, days after
102
+ // the deploy that broke it. It happened: `wrangler secret put` took a blank
103
+ // line, so the binding existed and carried nothing. `null` stays fine, since
104
+ // that is how an app says it signs nothing.
105
+ if (merged.cookieSecret === '') {
106
+ throw new Error(
107
+ `[transclude] \`cookieSecret\` is an empty string. Whatever supplies it handed ` +
108
+ `over nothing: on a worker that is usually a \`wrangler secret put\` that took ` +
109
+ `a blank line. Set a real secret, or \`null\` for none.`,
110
+ );
111
+ }
112
+
100
113
  // Refused here because there are four places that render a page and only two of
101
114
  // them could fall back to a request's origin. Left to the render, `canonical`
102
115
  // would work in dev and throw in the build, which is the dev-and-production
package/src/drain.js ADDED
@@ -0,0 +1,63 @@
1
+ // Finishing what is in flight when the platform says stop.
2
+ //
3
+ // A container sends SIGTERM and waits a moment before SIGKILL. Node's default
4
+ // for SIGTERM is to die on the spot, so a render halfway through its loader
5
+ // answers nobody, and an action may have happened with its response cut on the
6
+ // wire. Draining instead refuses new connections, finishes what is running,
7
+ // and leaves.
8
+ //
9
+ // No imports. `process` and the timers are globals, and the server arrives as
10
+ // an argument.
11
+
12
+ /**
13
+ * Exit cleanly on a stop signal, once the work in flight is done.
14
+ *
15
+ * `close` stops the listener and waits for every open connection. A keep-alive
16
+ * connection counts as open with no request on it, so idle ones are swept
17
+ * while the close waits; without the sweep, the first browser that ever
18
+ * connected would hold the wait to the cap. The cap is for a render that
19
+ * hangs: past it, every connection is cut and the exit code says the drain was
20
+ * not clean. Both timers are unref'd, so neither keeps a finished process
21
+ * alive.
22
+ *
23
+ * @param {object} server what `serve` returned: a `node:http` server
24
+ * @param {{ signals?: string[], grace?: number, sweep?: number, exit?: Function }} [options]
25
+ * @returns {() => void} the drain itself, so a test can run one without a signal
26
+ */
27
+ export function drainOn(server, options = {}) {
28
+ const {
29
+ signals = ['SIGTERM', 'SIGINT'],
30
+ grace = 10_000,
31
+ sweep = 500,
32
+ exit = (code) => process.exit(code),
33
+ } = options;
34
+
35
+ // The cap and the close both want to be the exit. First one wins.
36
+ let left = false;
37
+ const leave = (code) => {
38
+ if (left) return;
39
+ left = true;
40
+ exit(code);
41
+ };
42
+
43
+ const drain = () => {
44
+ const idle = setInterval(() => server.closeIdleConnections?.(), sweep);
45
+ idle.unref?.();
46
+
47
+ const cap = setTimeout(() => {
48
+ server.closeAllConnections?.();
49
+ leave(1);
50
+ }, grace);
51
+ cap.unref?.();
52
+
53
+ server.close(() => {
54
+ clearInterval(idle);
55
+ clearTimeout(cap);
56
+ leave(0);
57
+ });
58
+ server.closeIdleConnections?.();
59
+ };
60
+
61
+ for (const signal of signals) process.once(signal, drain);
62
+ return drain;
63
+ }
package/src/proxy.js CHANGED
@@ -33,13 +33,26 @@ const DEFAULTS = {
33
33
 
34
34
  const STYLE_MODES = new Set(['keep', 'strip']);
35
35
 
36
+ /** Every key `proxy` may set. `lookup` has no default: absent means the runtime's. */
37
+ const KEYS = new Set([...Object.keys(DEFAULTS), 'lookup']);
38
+
36
39
  /**
37
- * Defaults filled in, and the one value worth checking checked. A misspelled
38
- * `styles` would keep every style attribute and say nothing, which reads exactly
39
- * like the setting working.
40
+ * Defaults filled in, and what the author wrote checked. A misspelled `maxage`
41
+ * would fall back to the default and say nothing, which reads exactly like the
42
+ * setting working. That is the failure the config's own keys refuse by name,
43
+ * one level up, and these keys get the same treatment.
40
44
  */
41
45
  function settings(options) {
42
46
  const config = { ...DEFAULTS, ...options };
47
+
48
+ const unknown = Object.keys(options ?? {}).filter((key) => !KEYS.has(key));
49
+ if (unknown.length) {
50
+ throw new Error(
51
+ `[transclude] \`proxy\` sets ${unknown.join(', ')}, which nothing reads. ` +
52
+ `The keys are ${[...KEYS].sort().join(', ')}.`,
53
+ );
54
+ }
55
+
43
56
  if (!STYLE_MODES.has(config.styles)) {
44
57
  throw new Error(
45
58
  `[transclude] proxy.styles is ${JSON.stringify(config.styles)}. It is 'keep' or 'strip'.`,
@@ -289,10 +302,12 @@ export function proxyHandler(options = {}, deps = {}) {
289
302
  try {
290
303
  const entry = await readForeign(url, config, { ...deps, store });
291
304
 
292
- // No id is a question about the document rather than a piece of it.
305
+ // No id is a question about the document rather than a piece of it, so
306
+ // the answer also says what the cleaning took out. The list was already
307
+ // kept for exactly this; nothing read it until here.
293
308
  if (!id) {
294
309
  const { listFragments } = await import('./extract.js');
295
- return json(200, { url, fragments: listFragments(entry.doc) });
310
+ return json(200, { url, fragments: listFragments(entry.doc), removed: entry.removed });
296
311
  }
297
312
 
298
313
  const found = resolveFragment(entry.doc, id);
package/src/rewrite.js CHANGED
@@ -81,15 +81,15 @@ export function sanitize(root, { styles = 'keep' } = {}) {
81
81
  removed.push('@style');
82
82
  return false;
83
83
  }
84
- return true;
85
- });
86
-
87
- for (const attr of child.attrs) {
84
+ // Removed rather than emptied. An empty value still means something:
85
+ // `href=""` names the page the fragment lands in, and `action=""`
86
+ // submits to it, neither of which the source wrote.
88
87
  if (!allowedUrl(child, attr)) {
89
88
  removed.push(`@${attr.name}`);
90
- attr.value = '';
89
+ return false;
91
90
  }
92
- }
91
+ return true;
92
+ });
93
93
 
94
94
  visit(child);
95
95
  }
@@ -100,7 +100,7 @@ export function sanitize(root, { styles = 'keep' } = {}) {
100
100
  }
101
101
 
102
102
  /**
103
- * Whether a URL-bearing attribute may keep its value.
103
+ * Whether a URL-bearing attribute may stay.
104
104
  *
105
105
  * `javascript:` is refused everywhere. `data:` is refused everywhere except an
106
106
  * image source, where it is ordinary and cannot navigate anything.