@hozu/runtime-server 0.6.0 → 0.8.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/dist/handler.js CHANGED
@@ -1,18 +1,19 @@
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';
7
8
  import { pageEntries, robotsTxt, sitemapXml } from './crawl.js';
8
- import { formFields, formNode, runForm } from './forms.js';
9
+ import { endpointForm, formFields, formNode, runForm } from './forms.js';
9
10
  import { serviceWorker, serviceWorkerRegistration, webManifest } from './pwa.js';
10
11
  import { fnsModule, inlineScriptHashes, pathOf, renderPage, renderToString, } from './render.js';
11
12
  import { instantiate } from './rendered.js';
12
13
  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';
14
+ import { parseSearch, queryInput } from './search.js';
15
+ import { contentSecurityPolicy, crossSite, ERROR_HTML, NOT_FOUND_HTML } from './security.js';
16
+ import { memorySessions, signedCookie } from './session.js';
16
17
  import { publicAssets } from './static.js';
17
18
  import { assertWidgetBundle } from './widgets.js';
18
19
  const mime = {
@@ -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, widgets = 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
139
  ? publicAssets(basePath, manifest.styles, Object.fromEntries(Object.entries(manifest.widgets).map(([k, w]) => [k, w.url])))
124
140
  : publicAssets(basePath, styles, widgets?.urls ?? {});
125
141
  assertWidgetBundle(ir, assets.widgets, Boolean(manifest || widgets));
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;
268
+ }
269
+ finally {
270
+ if (--generating === 0)
271
+ revalidatedAt.clear();
265
272
  }
266
- data.invalidate(tags);
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,50 +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,
427
+ route: found.route,
367
428
  params: found.params,
368
429
  search,
369
- session: who,
370
430
  });
371
431
  if (!outcome)
372
432
  return notAllowed();
373
- if (outcome.invalidated.length)
374
- await revalidate(outcome.invalidated);
375
- 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;
376
435
  const back = pathOf(tableOf(locale)[found.route] ?? url.pathname, found.params, search);
377
- const target = outcome.navigate ?? (outcome.unchanged ? back : null);
378
- if (target)
436
+ const target = outcome.invalid ? null : (outcome.navigate ?? (outcome.unchanged ? back : null));
437
+ if (target) {
438
+ after(outcome.invalidated);
379
439
  return see(target, cookie);
440
+ }
380
441
  const rendered = await renderPage({
381
442
  build,
382
443
  data,
444
+ scope,
383
445
  route: found.route,
384
446
  params: found.params,
385
447
  search,
386
448
  snapshots: outcome.snapshots,
387
- session: who,
388
449
  assets,
389
450
  images: variants,
390
451
  ...(generated ? { render: generated } : {}),
391
452
  env: publicEnv,
392
453
  locale,
393
454
  });
394
- return new Response(stream(rendered.chunks, (e) => onError(e, { path })), {
395
- 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,
396
457
  headers: {
397
458
  ...extraHeaders(found.route),
398
459
  'content-type': 'text/html; charset=utf-8',
399
- 'cache-control': 'no-store',
460
+ ...privately(true),
400
461
  ...(cookie ? { 'set-cookie': cookie } : {}),
401
462
  ...(await secureHeaders()),
402
463
  },
403
464
  });
465
+ after(outcome.invalidated);
466
+ return response;
404
467
  };
405
468
  const files = {};
406
469
  for (const [href, file] of Object.entries(styles?.assets ?? {}))
@@ -423,12 +486,12 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
423
486
  const text = (type, body, head, cacheControl) => new Response(head ? null : body, {
424
487
  headers: { 'content-type': type, ...(cacheControl ? { 'cache-control': cacheControl } : {}) },
425
488
  });
426
- const live = () => {
489
+ const live = (url) => {
427
490
  let self = null;
428
491
  const body = new ReadableStream({
429
492
  start(controller) {
430
493
  self = controller;
431
- listeners.add(controller);
494
+ listeners.set(controller, new Set(url.searchParams.getAll('tag')));
432
495
  controller.enqueue(encoder.encode(': live\n\n'));
433
496
  },
434
497
  cancel() {
@@ -440,44 +503,98 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
440
503
  headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' },
441
504
  });
442
505
  };
443
- const missing = (request, locale = null) => ir.notFound
506
+ const missing = async (request, locale = null) => ir.notFound
444
507
  ? page(`#404:${locale ?? ''}`, ir.notFound, null, null, request, true, locale)
445
- : 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
+ });
446
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
+ };
447
540
  const endpointCall = async (request, url, ref) => {
448
- let input;
449
- if (request.method === 'GET')
450
- 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);
451
548
  else if ((request.headers.get('content-type') ?? '').includes('json'))
452
549
  input = (await request.json().catch(() => null));
453
- else {
454
- const form = await request.formData().catch(() => null);
455
- input = Object.fromEntries([...(form?.entries() ?? [])].filter((e) => typeof e[1] === 'string'));
456
- }
457
- let next = null;
458
- const result = await dataFor(request).endpoint(ref, input, {
459
- request,
460
- session: await session(request),
461
- setSession: (value) => {
462
- next = { value };
463
- },
464
- preview: previewing.get(request) === true,
465
- });
466
- const saved = next;
467
- const cookie = store && saved ? await store.write(saved.value) : null;
468
- 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);
469
560
  if (cookie)
470
- response.headers.append('set-cookie', cookie);
471
- 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;
472
566
  };
473
567
  if (!result.ok)
474
- return withCookie(new Response(JSON.stringify({ message: result.message, fields: result.fields }), {
475
- status: result.status,
476
- headers: { 'content-type': 'application/json' },
477
- }));
478
- if (result.value instanceof Response)
479
- return withCookie(result.value);
480
- 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;
481
598
  };
482
599
  const route = async (request, url, path) => {
483
600
  if (request.method === 'POST' && crossSite(request))
@@ -493,10 +610,12 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
493
610
  const { query, input } = (await request.json());
494
611
  if (!queries.includes(query))
495
612
  return plain(400, 'Unknown query');
496
- 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));
497
616
  }
498
617
  if (path === '/_hozu/live')
499
- return live();
618
+ return live(url);
500
619
  if (path === '/manifest.webmanifest' && manifestText)
501
620
  return text('application/manifest+json', manifestText, request.method === 'HEAD');
502
621
  if (path === '/sw.js' && worker)
@@ -548,11 +667,7 @@ export function createHandler({ build, resolvers, session: sessionOption = () =>
548
667
  const moved = redirectFor(path, url.search);
549
668
  if (moved)
550
669
  return moved;
551
- if (locales && path === '/')
552
- return negotiate(request, url, '/');
553
670
  const where = split(path);
554
- if (!where)
555
- return match(path) ? negotiate(request, url, path) : missing(request);
556
671
  const found = match(where.rest);
557
672
  if (!found)
558
673
  return missing(request, where.locale);