@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.
- 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/components.d.ts +3 -0
- package/dist/components.d.ts.map +1 -0
- package/dist/components.js +10 -0
- package/dist/components.js.map +1 -0
- package/dist/forms.d.ts +8 -6
- package/dist/forms.d.ts.map +1 -1
- package/dist/forms.js +50 -15
- package/dist/forms.js.map +1 -1
- package/dist/generate.d.ts.map +1 -1
- package/dist/generate.js +11 -16
- package/dist/generate.js.map +1 -1
- package/dist/handler.d.ts +11 -4
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +249 -135
- package/dist/handler.js.map +1 -1
- package/dist/images.js +1 -1
- package/dist/images.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/lower.js +1 -1
- package/dist/lower.js.map +1 -1
- package/dist/render.d.ts +6 -4
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +48 -71
- package/dist/render.js.map +1 -1
- package/dist/rendered.d.ts +1 -1
- package/dist/rendered.d.ts.map +1 -1
- package/dist/rendered.js +1 -1
- package/dist/rendered.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/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/dist/shape.js +1 -1
- package/dist/shape.js.map +1 -1
- package/dist/static.d.ts +4 -4
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +4 -4
- package/dist/static.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,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 {
|
|
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(
|
|
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
|
-
?
|
|
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
|
-
? publicAssets(basePath, manifest.styles, Object.fromEntries(Object.entries(manifest.
|
|
124
|
-
: publicAssets(basePath, styles,
|
|
125
|
-
|
|
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
|
|
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
|
-
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
|
-
|
|
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
|
|
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,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
|
|
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
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
:
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
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 =
|
|
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);
|