@hozu/runtime-server 0.7.0 → 0.9.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.
Files changed (58) hide show
  1. package/dist/app.d.ts +28 -0
  2. package/dist/app.d.ts.map +1 -0
  3. package/dist/app.js +15 -0
  4. package/dist/app.js.map +1 -0
  5. package/dist/components.d.ts +3 -0
  6. package/dist/components.d.ts.map +1 -0
  7. package/dist/components.js +10 -0
  8. package/dist/components.js.map +1 -0
  9. package/dist/forms.d.ts +8 -6
  10. package/dist/forms.d.ts.map +1 -1
  11. package/dist/forms.js +50 -15
  12. package/dist/forms.js.map +1 -1
  13. package/dist/generate.d.ts.map +1 -1
  14. package/dist/generate.js +11 -16
  15. package/dist/generate.js.map +1 -1
  16. package/dist/handler.d.ts +11 -4
  17. package/dist/handler.d.ts.map +1 -1
  18. package/dist/handler.js +249 -135
  19. package/dist/handler.js.map +1 -1
  20. package/dist/images.js +1 -1
  21. package/dist/images.js.map +1 -1
  22. package/dist/index.d.ts +8 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +4 -3
  25. package/dist/index.js.map +1 -1
  26. package/dist/lower.js +1 -1
  27. package/dist/lower.js.map +1 -1
  28. package/dist/render.d.ts +6 -4
  29. package/dist/render.d.ts.map +1 -1
  30. package/dist/render.js +48 -71
  31. package/dist/render.js.map +1 -1
  32. package/dist/rendered.d.ts +1 -1
  33. package/dist/rendered.d.ts.map +1 -1
  34. package/dist/rendered.js +1 -1
  35. package/dist/rendered.js.map +1 -1
  36. package/dist/search.d.ts +1 -0
  37. package/dist/search.d.ts.map +1 -1
  38. package/dist/search.js +10 -0
  39. package/dist/search.js.map +1 -1
  40. package/dist/security.d.ts +2 -1
  41. package/dist/security.d.ts.map +1 -1
  42. package/dist/security.js +3 -1
  43. package/dist/security.js.map +1 -1
  44. package/dist/session.d.ts +16 -5
  45. package/dist/session.d.ts.map +1 -1
  46. package/dist/session.js +64 -10
  47. package/dist/session.js.map +1 -1
  48. package/dist/shape.js +1 -1
  49. package/dist/shape.js.map +1 -1
  50. package/dist/static.d.ts +4 -4
  51. package/dist/static.d.ts.map +1 -1
  52. package/dist/static.js +4 -4
  53. package/dist/static.js.map +1 -1
  54. package/package.json +6 -6
  55. package/dist/compile.d.ts +0 -43
  56. package/dist/compile.d.ts.map +0 -1
  57. package/dist/compile.js +0 -170
  58. package/dist/compile.js.map +0 -1
package/dist/handler.js CHANGED
@@ -1,20 +1,21 @@
1
1
  import { planRoute } from '@hozu/compiler';
2
- import { FORM_FIELD, hashJson, publicPath, routeParams, routePattern, routeTable, } from '@hozu/core/ir';
2
+ import { canonicalStringify, codes, FORM_FIELD, hashJson, join, publicPath, resolveSource, routeParams, routePattern, routeTable, } from '@hozu/core/ir';
3
3
  import { createDataRuntime } from '@hozu/data';
4
4
  import { compileValue } from '@hozu/machine';
5
+ import { appHandlerOptions, appOptionsOf } from './app.js';
5
6
  import { clientBundle } from './assets.js';
6
7
  import { memoryCache } from './cache.js';
8
+ import { assertComponentBundle } from './components.js';
7
9
  import { pageEntries, robotsTxt, sitemapXml } from './crawl.js';
8
- import { formFields, formNode, runForm } from './forms.js';
10
+ import { endpointForm, formFields, formNode, runForm } from './forms.js';
9
11
  import { serviceWorker, serviceWorkerRegistration, webManifest } from './pwa.js';
10
12
  import { fnsModule, inlineScriptHashes, pathOf, renderPage, renderToString, } from './render.js';
11
13
  import { instantiate } from './rendered.js';
12
14
  import { matcher } from './routing.js';
13
- import { parseSearch } from './search.js';
14
- import { contentSecurityPolicy, crossSite, ERROR_HTML } from './security.js';
15
- import { sessionCookie } from './session.js';
15
+ import { parseSearch, queryInput } from './search.js';
16
+ import { contentSecurityPolicy, crossSite, ERROR_HTML, NOT_FOUND_HTML } from './security.js';
17
+ import { memorySessions, signedCookie } from './session.js';
16
18
  import { publicAssets } from './static.js';
17
- import { assertWidgetBundle } from './widgets.js';
18
19
  const mime = {
19
20
  '.png': 'image/png',
20
21
  '.jpg': 'image/jpeg',
@@ -69,7 +70,10 @@ const readEffect = async (request) => {
69
70
  files.set(key, value);
70
71
  return { body: String(form.get('request') ?? '{}'), files };
71
72
  };
72
- export function createHandler({ build, resolvers, session: sessionOption = () => null, now = Date.now, styles = null, widgets = null, onError = (error, info) => console.error('[hozu]', info, error), csp = {}, cache = memoryCache(), readFile, manifest, images = null, env: rawEnv = {}, preview, og = null, render, }) {
73
+ export function createHandler(first, host) {
74
+ return handlerFor(appOptionsOf(first) ? appHandlerOptions(first, host) : first);
75
+ }
76
+ function handlerFor({ build, resolvers, session: sessionOption, now = Date.now, styles = null, components = null, onError = (error, info) => console.error('[hozu]', info, error), csp = {}, cache = memoryCache(), readFile, manifest, images = null, env: rawEnv = {}, preview, og = null, render, }) {
73
77
  const generated = render ? instantiate(render) : undefined;
74
78
  const cards = new Map();
75
79
  const ogImage = (url) => {
@@ -95,7 +99,7 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
95
99
  if (preview && preview.secret.length < 32)
96
100
  throw new Error('preview.secret must be at least 32 characters');
97
101
  const previewCookie = preview
98
- ? sessionCookie({
102
+ ? signedCookie({
99
103
  name: 'hozu_preview',
100
104
  secret: preview.secret,
101
105
  maxAge: 60 * 60,
@@ -110,26 +114,44 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
110
114
  const variants = manifest?.images ?? images?.variants ?? null;
111
115
  if (manifest && manifest.irHash !== hashJson(build.ir))
112
116
  throw new Error('The build manifest does not match this project; run `hozu build` again');
113
- const untransformed = build.diagnostics.find((d) => d.code === 'HZ044' || d.code === 'HZ047');
117
+ const untransformed = build.diagnostics.find((d) => ['HZ044', 'HZ047', 'HZ059'].includes(d.code));
114
118
  if (untransformed)
115
119
  throw new Error(`${untransformed.message}. ${untransformed.fix?.summary ?? ''}`);
116
- const store = typeof sessionOption === 'function' ? null : sessionOption;
120
+ const { ir } = build;
121
+ if (sessionOption === undefined && ir.session && !rawEnv.SESSION_SECRET && rawEnv.NODE_ENV === 'production')
122
+ throw new Error('This project declares a session: set SESSION_SECRET, or pass createHandler({ session: memorySessions({ secret }) })');
123
+ const store = typeof sessionOption === 'function'
124
+ ? null
125
+ : (sessionOption ??
126
+ (ir.session
127
+ ? memorySessions({
128
+ secret: rawEnv.SESSION_SECRET,
129
+ secure: rawEnv.SESSION_SECURE
130
+ ? rawEnv.SESSION_SECURE === 'true'
131
+ : rawEnv.NODE_ENV === 'production',
132
+ })
133
+ : null));
117
134
  const session = store
118
135
  ? (request) => store.read(request)
119
- : async (request) => sessionOption(request);
120
- const { ir } = build;
136
+ : async (request) => sessionOption?.(request) ?? null;
121
137
  const { basePath, redirects, headers: headerRules } = ir.http;
122
138
  const assets = manifest
123
- ? publicAssets(basePath, manifest.styles, Object.fromEntries(Object.entries(manifest.widgets).map(([k, w]) => [k, w.url])))
124
- : publicAssets(basePath, styles, widgets?.urls ?? {});
125
- assertWidgetBundle(ir, assets.widgets, Boolean(manifest || widgets));
139
+ ? publicAssets(basePath, manifest.styles, Object.fromEntries(Object.entries(manifest.components).map(([k, c]) => [k, c.url])))
140
+ : publicAssets(basePath, styles, components?.urls ?? {});
141
+ assertComponentBundle(ir, assets.components, Boolean(manifest || components));
126
142
  const data = createDataRuntime({ build, resolvers, now, onError, env: rawEnv });
127
- const previewData = {
128
- ...data,
129
- run: (ref, input, who, files) => data.run(ref, input, who, files, { preview: true }),
143
+ const scopes = new WeakMap();
144
+ const dataFor = (request) => {
145
+ let scope = scopes.get(request);
146
+ if (!scope) {
147
+ scope = session(request).then((who) => data.scope(who, { preview: previewing.get(request) === true }));
148
+ scopes.set(request, scope);
149
+ }
150
+ return scope;
130
151
  };
131
- const dataFor = (request) => (previewing.get(request) ? previewData : data);
132
- const internal = (path) => (path?.startsWith('/') && !path.startsWith('//') ? path : null);
152
+ const hasSession = build.ir.session !== null;
153
+ const privately = (read) => read ? { 'cache-control': 'private, no-cache', ...(hasSession ? { vary: 'Cookie' } : {}) } : {};
154
+ const internal = (path) => path && /^\/(?![/\\])/.test(path) && ![...path].some((c) => c < ' ') ? path : null;
133
155
  const enterPreview = async (url) => {
134
156
  const given = new TextEncoder().encode(url.searchParams.get('secret') ?? '');
135
157
  const want = new TextEncoder().encode(preview?.secret ?? '');
@@ -173,29 +195,13 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
173
195
  return { locale: null, rest: path };
174
196
  const segment = path.split('/')[1] ?? '';
175
197
  if (!locales.includes(segment))
176
- return null;
198
+ return { locale: ir.site.lang, rest: path };
177
199
  return { locale: segment, rest: path.slice(segment.length + 1) || '/' };
178
200
  };
179
- const negotiate = (request, url, path) => {
180
- const wanted = (request.headers.get('accept-language') ?? '')
181
- .split(',')
182
- .map((part) => {
183
- const [tag = '', q] = part.trim().split(';q=');
184
- return { tag: tag.toLowerCase(), q: q === undefined ? 1 : Number(q) };
185
- })
186
- .filter((w) => w.tag && w.q > 0)
187
- .sort((a, b) => b.q - a.q);
188
- const list = locales ?? [];
189
- const best = wanted
190
- .map((w) => list.find((l) => l.toLowerCase() === w.tag) ??
191
- list.find((l) => l.toLowerCase().split('-')[0] === w.tag.split('-')[0]))
192
- .find(Boolean) ?? ir.site.lang;
193
- return new Response(null, {
194
- status: 307,
195
- headers: { location: publicPath(ir, path, best) + url.search, vary: 'Accept-Language' },
196
- });
197
- };
198
201
  const queries = Object.values(ir.features).flatMap((f) => Object.keys(f.queries).map((q) => `${f.id}.${q}`));
202
+ const perRequest = new Set(Object.values(ir.features).flatMap((f) => Object.entries(f.queries)
203
+ .filter(([, q]) => q.freshness.kind === 'request')
204
+ .map(([q]) => `${f.id}.${q}`)));
199
205
  const regenerating = new Map();
200
206
  const fns = fnsModule(build);
201
207
  const manifestText = webManifest(ir);
@@ -240,32 +246,75 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
240
246
  };
241
247
  const generate = async (path, route, params, search, locale) => {
242
248
  const at = now();
243
- const { html, tags, status, redirect } = await renderToString({
244
- build,
245
- data,
246
- route,
247
- params,
248
- search,
249
- assets,
250
- images: variants,
251
- ...(generated ? { render: generated } : {}),
252
- env: publicEnv,
253
- locale,
254
- });
255
- const page = { html, status, redirect, at, ttl: ttlOf(route), tags: [...tags] };
256
- await cache.set(path, page);
257
- return page;
258
- };
259
- const listeners = new Set();
260
- const revalidate = async (tags) => {
261
- if (tags.length) {
262
- const message = encoder.encode(`data: ${JSON.stringify(tags)}\n\n`);
263
- for (const c of listeners)
264
- c.enqueue(message);
249
+ const started = epoch;
250
+ generating++;
251
+ try {
252
+ const { html, tags, status, redirect } = await renderToString({
253
+ build,
254
+ data,
255
+ route,
256
+ params,
257
+ search,
258
+ assets,
259
+ images: variants,
260
+ ...(generated ? { render: generated } : {}),
261
+ env: publicEnv,
262
+ locale,
263
+ });
264
+ const page = { html, status, redirect, at, ttl: ttlOf(route), tags: [...tags] };
265
+ if (!page.tags.some((t) => (revalidatedAt.get(t) ?? -1) > started))
266
+ await cache.set(path, page);
267
+ return page;
265
268
  }
266
- data.invalidate(tags);
269
+ finally {
270
+ if (--generating === 0)
271
+ revalidatedAt.clear();
272
+ }
273
+ };
274
+ let epoch = 0;
275
+ let generating = 0;
276
+ const revalidatedAt = new Map();
277
+ const dropPages = async (tags) => {
278
+ if (!tags.length)
279
+ return 0;
280
+ epoch++;
281
+ if (generating)
282
+ for (const t of tags)
283
+ revalidatedAt.set(t, epoch);
267
284
  return cache.deleteTags(tags);
268
285
  };
286
+ const listeners = new Map();
287
+ const broadcast = (tags) => {
288
+ if (!tags.length)
289
+ return;
290
+ for (const [c, subscribed] of listeners) {
291
+ const mine = tags.filter((t) => subscribed.has(t));
292
+ if (mine.length)
293
+ c.enqueue(encoder.encode(`data: ${JSON.stringify(mine)}\n\n`));
294
+ }
295
+ };
296
+ const after = (tags) => {
297
+ if (tags.length)
298
+ setTimeout(() => broadcast(tags), 0);
299
+ };
300
+ const TAG_USE = Symbol.for('hozu.tagUse');
301
+ const tagKeyOf = (use) => {
302
+ const u = use[TAG_USE];
303
+ const ref = u ? build.bindings.refs.get(u.tag) : undefined;
304
+ if (!u || !ref)
305
+ throw new TypeError('revalidate takes tag uses of this project: server.revalidate([notesTag()])');
306
+ const dot = ref.indexOf('.');
307
+ return ir.features[ref.slice(0, dot)]?.tags[ref.slice(dot + 1)]?.param
308
+ ? `${ref}(${canonicalStringify(u.param)})`
309
+ : ref;
310
+ };
311
+ const revalidate = async (uses) => {
312
+ const tags = [...new Set(uses.map(tagKeyOf))];
313
+ const entries = data.invalidate(tags);
314
+ const pages = await dropPages(tags);
315
+ broadcast(tags);
316
+ return { entries, pages };
317
+ };
269
318
  const json = (body, headers = {}) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json', ...headers } });
270
319
  const plain = (status, body, headers = {}) => new Response(body, { status, headers: { 'content-type': 'text/plain', ...headers } });
271
320
  const see = (location, cookie = null) => new Response(null, { status: 303, headers: { location, ...(cookie ? { 'set-cookie': cookie } : {}) } });
@@ -273,25 +322,29 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
273
322
  const effect = async (request) => {
274
323
  const { body, files } = await readEffect(request);
275
324
  const { effect, input, keys } = JSON.parse(body);
276
- const who = await session(request);
277
- const result = (await data.run(effect, input, who, files));
325
+ const scope = await dataFor(request);
326
+ const result = (await scope.run(effect, input, files));
278
327
  const invalidated = result.invalidated ?? [];
279
- if (invalidated.length)
280
- await revalidate(invalidated);
328
+ await dropPages(invalidated);
329
+ const changed = new Set(invalidated);
281
330
  const refreshed = [];
282
- if (invalidated.length)
283
- for (const key of keys) {
284
- const ref = queries.find((q) => key.startsWith(q) && '{["tfn0123456789-'.includes(key[q.length] ?? ''));
285
- if (ref)
286
- refreshed.push([
287
- key,
288
- (await data.run(ref, JSON.parse(key.slice(ref.length)), who)),
289
- ]);
290
- }
291
- const cookie = store && 'session' in result ? await store.write(result.session) : null;
331
+ for (const key of keys) {
332
+ const ref = queries.find((q) => key.startsWith(q) && '{["tfn0123456789-'.includes(key[q.length] ?? ''));
333
+ if (!ref)
334
+ continue;
335
+ const input = JSON.parse(key.slice(ref.length));
336
+ if (perRequest.has(ref) || data.tagsOf(ref, input).some((t) => changed.has(t)))
337
+ refreshed.push([key, (await scope.run(ref, input))]);
338
+ }
339
+ const cookie = store && scope.written ? await store.write(scope.written.value, request) : null;
292
340
  const { invalidated: _, session: __, ...rest } = result;
293
- const response = { result: rest, refreshed };
294
- return json(response, cookie ? { 'set-cookie': cookie } : {});
341
+ const response = {
342
+ result: rest,
343
+ refreshed,
344
+ ...(scope.written ? { session: true } : {}),
345
+ };
346
+ after(invalidated);
347
+ return json(response, { ...privately(true), ...(cookie ? { 'set-cookie': cookie } : {}) });
295
348
  };
296
349
  const page = async (path, route, params, search, request, missing, locale) => {
297
350
  const headers = {
@@ -302,9 +355,16 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
302
355
  const head = request.method === 'HEAD';
303
356
  const statusOf = (s) => (missing ? 404 : s);
304
357
  const inPreview = previewing.get(request) === true;
358
+ const plan = planRoute(ir, route).plan;
305
359
  if (inPreview)
306
360
  Object.assign(headers, { 'cache-control': 'private, no-store', 'x-robots-tag': 'noindex' });
307
- if (!inPreview && planRoute(ir, route).plan.cacheable) {
361
+ else if (plan.cacheable)
362
+ Object.assign(headers, { 'cache-control': 'public, max-age=0, must-revalidate' });
363
+ else if (plan.regions.some((r) => r.scope === 'user'))
364
+ Object.assign(headers, privately(true));
365
+ else
366
+ Object.assign(headers, { 'cache-control': 'no-cache' });
367
+ if (!inPreview && plan.cacheable) {
308
368
  let cached = await cache.get(path);
309
369
  let state = 'hit';
310
370
  if (!cached) {
@@ -327,11 +387,11 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
327
387
  }
328
388
  const rendered = await renderPage({
329
389
  build,
330
- data: dataFor(request),
390
+ data,
391
+ scope: await dataFor(request),
331
392
  route,
332
393
  params,
333
394
  search,
334
- session: await session(request),
335
395
  assets,
336
396
  images: variants,
337
397
  ...(generated ? { render: generated } : {}),
@@ -348,8 +408,8 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
348
408
  const formPost = async (request, url, path) => {
349
409
  const id = url.searchParams.get(FORM_FIELD);
350
410
  const where = split(path);
351
- const found = where ? match(where.rest) : null;
352
- const locale = where?.locale ?? null;
411
+ const found = match(where.rest);
412
+ const locale = where.locale;
353
413
  const form = id ? formNode(build, id) : null;
354
414
  if (!found || !form)
355
415
  return notAllowed();
@@ -357,51 +417,53 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
357
417
  query.delete(FORM_FIELD);
358
418
  const search = parseSearch(ir.routes[found.route]?.search ?? null, query);
359
419
  const fields = await formFields(request);
360
- const who = await session(request);
420
+ const scope = await dataFor(request);
361
421
  const outcome = await runForm({
362
422
  build,
363
- data,
423
+ data: scope,
364
424
  routes: tableOf(locale),
365
425
  form,
366
426
  fields,
367
427
  route: found.route,
368
428
  params: found.params,
369
429
  search,
370
- session: who,
371
430
  });
372
431
  if (!outcome)
373
432
  return notAllowed();
374
- if (outcome.invalidated.length)
375
- await revalidate(outcome.invalidated);
376
- const cookie = store && outcome.session ? await store.write(outcome.session.value) : null;
433
+ await dropPages(outcome.invalidated);
434
+ const cookie = store && outcome.session ? await store.write(outcome.session.value, request) : null;
377
435
  const back = pathOf(tableOf(locale)[found.route] ?? url.pathname, found.params, search);
378
- const target = outcome.navigate ?? (outcome.unchanged ? back : null);
379
- if (target)
436
+ const target = outcome.invalid ? null : (outcome.navigate ?? (outcome.unchanged ? back : null));
437
+ if (target) {
438
+ after(outcome.invalidated);
380
439
  return see(target, cookie);
440
+ }
381
441
  const rendered = await renderPage({
382
442
  build,
383
443
  data,
444
+ scope,
384
445
  route: found.route,
385
446
  params: found.params,
386
447
  search,
387
448
  snapshots: outcome.snapshots,
388
- session: who,
389
449
  assets,
390
450
  images: variants,
391
451
  ...(generated ? { render: generated } : {}),
392
452
  env: publicEnv,
393
453
  locale,
394
454
  });
395
- return new Response(stream(rendered.chunks, (e) => onError(e, { path })), {
396
- status: outcome.unexpected ? 500 : rendered.status,
455
+ const response = new Response(stream(rendered.chunks, (e) => onError(e, { path })), {
456
+ status: outcome.unexpected ? 500 : outcome.invalid ? 400 : rendered.status,
397
457
  headers: {
398
458
  ...extraHeaders(found.route),
399
459
  'content-type': 'text/html; charset=utf-8',
400
- 'cache-control': 'no-store',
460
+ ...privately(true),
401
461
  ...(cookie ? { 'set-cookie': cookie } : {}),
402
462
  ...(await secureHeaders()),
403
463
  },
404
464
  });
465
+ after(outcome.invalidated);
466
+ return response;
405
467
  };
406
468
  const files = {};
407
469
  for (const [href, file] of Object.entries(styles?.assets ?? {}))
@@ -424,12 +486,12 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
424
486
  const text = (type, body, head, cacheControl) => new Response(head ? null : body, {
425
487
  headers: { 'content-type': type, ...(cacheControl ? { 'cache-control': cacheControl } : {}) },
426
488
  });
427
- const live = () => {
489
+ const live = (url) => {
428
490
  let self = null;
429
491
  const body = new ReadableStream({
430
492
  start(controller) {
431
493
  self = controller;
432
- listeners.add(controller);
494
+ listeners.set(controller, new Set(url.searchParams.getAll('tag')));
433
495
  controller.enqueue(encoder.encode(': live\n\n'));
434
496
  },
435
497
  cancel() {
@@ -441,44 +503,98 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
441
503
  headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
442
504
  });
443
505
  };
444
- const missing = (request, locale = null) => ir.notFound
506
+ const missing = async (request, locale = null) => ir.notFound
445
507
  ? page(`#404:${locale ?? ''}`, ir.notFound, null, null, request, true, locale)
446
- : plain(404, request.method === 'HEAD' ? null : 'Not found');
508
+ : new Response(request.method === 'HEAD' ? null : NOT_FOUND_HTML, {
509
+ status: 404,
510
+ headers: { 'content-type': 'text/html; charset=utf-8', ...(await secureHeaders()) },
511
+ });
447
512
  const endpointRefs = new Map(Object.values(ir.features).flatMap((f) => Object.entries(f.endpoints ?? {}).map(([symbol, e]) => [`${e.method} ${e.path}`, `${f.id}.${symbol}`])));
513
+ const LINK = Symbol.for('hozu.link');
514
+ const locationOf = (href) => {
515
+ const l = href?.[LINK];
516
+ const ref = l ? build.bindings.refs.get(l.route) : undefined;
517
+ return ref?.startsWith('#route:') ? pathOf(table[ref.slice(7)] ?? '/', l.params, l.search) : null;
518
+ };
519
+ const endpointIRs = new Map(Object.values(ir.features).flatMap((f) => Object.entries(f.endpoints).map(([sym, e]) => [
520
+ `${f.id}.${sym}`,
521
+ { feature: f, sym, e },
522
+ ])));
523
+ const htmlFromEndpoint = (ref) => {
524
+ const { feature, sym } = endpointIRs.get(ref);
525
+ const pointer = join('', 'features', feature.id, 'endpoints', sym);
526
+ const diagnostic = {
527
+ code: 'HZ053',
528
+ severity: codes.HZ053.severity,
529
+ message: `Endpoint ${ref} answered text/html`,
530
+ location: { feature: feature.id, pointer, source: resolveSource(build.sources, pointer) },
531
+ cause: 'A page is rendered from the IR, with its CSP, headers, lang and view-transition opt-in; HTML from an endpoint has none of them. The framework answers 500 instead.',
532
+ fix: {
533
+ summary: 'Render it as a ui.page, and answer 403 / 404 / 410 or redirect through head.failed',
534
+ snippet: `ui.page(route, { views: [...], head: { query, input, render, failed: { Forbidden: 403 } } })`,
535
+ patch: null,
536
+ },
537
+ };
538
+ return Object.assign(new Error(`HZ053 ${diagnostic.message}. ${diagnostic.fix.summary}`), { diagnostic });
539
+ };
448
540
  const endpointCall = async (request, url, ref) => {
449
- let input;
450
- if (request.method === 'GET')
451
- input = Object.fromEntries(url.searchParams);
541
+ const { feature, e } = endpointIRs.get(ref);
542
+ let input = null;
543
+ let bytes;
544
+ if (e.raw)
545
+ bytes = new Uint8Array(await request.arrayBuffer());
546
+ else if (request.method !== 'POST')
547
+ input = queryInput(feature.schemas[e.input] ?? null, url.searchParams);
452
548
  else if ((request.headers.get('content-type') ?? '').includes('json'))
453
549
  input = (await request.json().catch(() => null));
454
- else {
455
- const form = await request.formData().catch(() => null);
456
- input = Object.fromEntries([...(form?.entries() ?? [])].filter((e) => typeof e[1] === 'string'));
457
- }
458
- let next = null;
459
- const result = await dataFor(request).endpoint(ref, input, {
460
- request,
461
- session: await session(request),
462
- setSession: (value) => {
463
- next = { value };
464
- },
465
- preview: previewing.get(request) === true,
466
- });
467
- const saved = next;
468
- const cookie = store && saved ? await store.write(saved.value) : null;
469
- const withCookie = (response) => {
550
+ else
551
+ input = await endpointForm(feature.schemas[e.input] ?? null, request).catch(() => null);
552
+ const scope = await dataFor(request);
553
+ const result = await scope.endpoint(ref, input, { request, ...(bytes ? { bytes } : {}) });
554
+ const cookie = store && scope.written ? await store.write(scope.written.value, request) : null;
555
+ const finish = (response) => {
556
+ const out = new Response(response.body, response);
557
+ for (const [k, v] of Object.entries(base))
558
+ if (!out.headers.has(k))
559
+ out.headers.set(k, v);
470
560
  if (cookie)
471
- response.headers.append('set-cookie', cookie);
472
- return response;
561
+ out.headers.append('set-cookie', cookie);
562
+ if (!out.headers.has('cache-control'))
563
+ for (const [k, v] of Object.entries(privately(scope.readSession)))
564
+ out.headers.set(k, v);
565
+ return out;
473
566
  };
474
567
  if (!result.ok)
475
- return withCookie(new Response(JSON.stringify({ message: result.message, fields: result.fields }), {
476
- status: result.status,
477
- headers: { 'content-type': 'application/json' },
478
- }));
479
- if (result.value instanceof Response)
480
- return withCookie(result.value);
481
- return json(result.value, cookie ? { 'set-cookie': cookie } : {});
568
+ return finish(new Response(JSON.stringify({
569
+ error: result.error,
570
+ message: result.message,
571
+ ...(result.fields ? { fields: result.fields } : {}),
572
+ }), { status: result.status, headers: { 'content-type': 'application/json' } }));
573
+ await dropPages(result.invalidated);
574
+ let response;
575
+ const to = result.redirect === undefined ? null : locationOf(result.redirect);
576
+ if (result.redirect !== undefined && !to) {
577
+ onError(new Error(`${ref} returned redirect() of something that is not ui.link(route, …)`), {
578
+ path: url.pathname,
579
+ });
580
+ response = plain(500, 'Internal error');
581
+ }
582
+ else if (to)
583
+ response = see(to);
584
+ else if (result.value instanceof Response) {
585
+ if ((result.value.headers.get('content-type') ?? '').toLowerCase().includes('text/html')) {
586
+ const error = htmlFromEndpoint(ref);
587
+ onError(error, { path: url.pathname });
588
+ response = plain(500, error.message);
589
+ }
590
+ else
591
+ response = result.value;
592
+ }
593
+ else
594
+ response = json(result.value);
595
+ const out = finish(response);
596
+ after(result.invalidated);
597
+ return out;
482
598
  };
483
599
  const route = async (request, url, path) => {
484
600
  if (request.method === 'POST' && crossSite(request))
@@ -494,10 +610,12 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
494
610
  const { query, input } = (await request.json());
495
611
  if (!queries.includes(query))
496
612
  return plain(400, 'Unknown query');
497
- return json(await dataFor(request).run(query, input, await session(request)));
613
+ const scope = await dataFor(request);
614
+ const result = await scope.run(query, input);
615
+ return json(result, privately(scope.readSession));
498
616
  }
499
617
  if (path === '/_hozu/live')
500
- return live();
618
+ return live(url);
501
619
  if (path === '/manifest.webmanifest' && manifestText)
502
620
  return text('application/manifest+json', manifestText, request.method === 'HEAD');
503
621
  if (path === '/sw.js' && worker)
@@ -537,7 +655,7 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
537
655
  'cache-control': IMMUTABLE,
538
656
  },
539
657
  });
540
- const script = widgets?.files[path];
658
+ const script = components?.files[path];
541
659
  if (script !== undefined)
542
660
  return text('text/javascript', script, head, IMMUTABLE);
543
661
  if (styles && path === styles.href)
@@ -549,11 +667,7 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
549
667
  const moved = redirectFor(path, url.search);
550
668
  if (moved)
551
669
  return moved;
552
- if (locales && path === '/')
553
- return negotiate(request, url, '/');
554
670
  const where = split(path);
555
- if (!where)
556
- return match(path) ? negotiate(request, url, path) : missing(request);
557
671
  const found = match(where.rest);
558
672
  if (!found)
559
673
  return missing(request, where.locale);