@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/app.d.ts +28 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +15 -0
- package/dist/app.js.map +1 -0
- package/dist/forms.d.ts +9 -6
- package/dist/forms.d.ts.map +1 -1
- package/dist/forms.js +56 -17
- package/dist/forms.js.map +1 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +4 -0
- package/dist/generate.js.map +1 -1
- package/dist/handler.d.ts +9 -2
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +245 -130
- package/dist/handler.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/render.d.ts +4 -2
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +49 -44
- package/dist/render.js.map +1 -1
- package/dist/search.d.ts +1 -0
- package/dist/search.d.ts.map +1 -1
- package/dist/search.js +10 -0
- package/dist/search.js.map +1 -1
- package/dist/security.d.ts +2 -1
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +3 -1
- package/dist/security.js.map +1 -1
- package/dist/seed.d.ts +3 -0
- package/dist/seed.d.ts.map +1 -0
- package/dist/seed.js +22 -0
- package/dist/seed.js.map +1 -0
- package/dist/session.d.ts +16 -5
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +64 -10
- package/dist/session.js.map +1 -1
- package/package.json +6 -6
- package/dist/compile.d.ts +0 -43
- package/dist/compile.d.ts.map +0 -1
- package/dist/compile.js +0 -170
- package/dist/compile.js.map +0 -1
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 {
|
|
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(
|
|
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
|
-
?
|
|
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) =>
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
132
|
-
const
|
|
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
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
const result = (await
|
|
325
|
+
const scope = await dataFor(request);
|
|
326
|
+
const result = (await scope.run(effect, input, files));
|
|
278
327
|
const invalidated = result.invalidated ?? [];
|
|
279
|
-
|
|
280
|
-
|
|
328
|
+
await dropPages(invalidated);
|
|
329
|
+
const changed = new Set(invalidated);
|
|
281
330
|
const refreshed = [];
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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 = {
|
|
294
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
352
|
-
const locale = where
|
|
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
|
|
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
|
-
|
|
374
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
471
|
-
|
|
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
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
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);
|