@weft-js/start 0.1.0 → 0.1.2
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/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +51 -10
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +124 -490
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +146 -155
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +97 -168
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +113 -35
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -174
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -964
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// Render first when no remembered head can be sent yet.
|
|
2
|
+
import { LAYER_ORDER, pageStyles, type keptSheets } from '../inline.mjs'
|
|
3
|
+
import { capture, speculate } from '../capture'
|
|
4
|
+
import { whole } from './compression'
|
|
5
|
+
import { withHeaders } from './request'
|
|
6
|
+
import { esc } from './text'
|
|
7
|
+
import { finish, pageShell, render, rest, shaped, table, top } from './page-stream'
|
|
8
|
+
import { HOLD, type Head, type PageState } from './page-types'
|
|
9
|
+
|
|
10
|
+
type Styles = ReturnType<typeof pageStyles>
|
|
11
|
+
type Shape = ReturnType<typeof shaped>
|
|
12
|
+
type Table = ReturnType<typeof table>
|
|
13
|
+
|
|
14
|
+
function coldHead(
|
|
15
|
+
p: PageState,
|
|
16
|
+
shape: Shape,
|
|
17
|
+
t: Table,
|
|
18
|
+
styles: Styles | undefined,
|
|
19
|
+
capturing: boolean,
|
|
20
|
+
) {
|
|
21
|
+
return (
|
|
22
|
+
top(p) +
|
|
23
|
+
shape.preloads +
|
|
24
|
+
// A streamed page's entry is at the end of what the shell sends, so its script tag is found late.
|
|
25
|
+
(p.assets.entry && shape.streaming
|
|
26
|
+
? `<link rel="modulepreload" href="${esc(p.assets.entry)}">\n`
|
|
27
|
+
: '') +
|
|
28
|
+
(t
|
|
29
|
+
? `<style>${LAYER_ORDER}${p.assets.pieces!.layers}${t.rules(p.stream.shell)}</style>\n`
|
|
30
|
+
: styles!.head) +
|
|
31
|
+
(capturing ? `<script>${capture(shape.now)}</script>\n` + speculate(shape) : '') +
|
|
32
|
+
p.dev +
|
|
33
|
+
(p.assets.entry && !shape.streaming
|
|
34
|
+
? `<script type="module" src="${esc(p.assets.entry)}"></script>\n`
|
|
35
|
+
: '') +
|
|
36
|
+
'</head>\n<body>\n' +
|
|
37
|
+
pageShell(p, shape, t ? t.links : '')
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Render first and send the head and shell together. */
|
|
42
|
+
export async function coldPage(
|
|
43
|
+
p: PageState,
|
|
44
|
+
known: Head | null | undefined,
|
|
45
|
+
cold: string,
|
|
46
|
+
kept: ReturnType<typeof keptSheets>,
|
|
47
|
+
) {
|
|
48
|
+
const t0 = performance.now()
|
|
49
|
+
p.stream = render(p)
|
|
50
|
+
await p.stream.settle() // queries read outside a Boundary have nowhere to stream to
|
|
51
|
+
await p.stream.hold(p.options.hold ?? HOLD)
|
|
52
|
+
const ms = performance.now() - t0
|
|
53
|
+
p.complete(p.r, p.stream.shell)
|
|
54
|
+
const shape = shaped(p)
|
|
55
|
+
const t = table(p, shape)
|
|
56
|
+
const styles = t
|
|
57
|
+
? undefined
|
|
58
|
+
: pageStyles(p.assets, shape.sheets, p.stream.shell, {
|
|
59
|
+
head: p.site.head,
|
|
60
|
+
have: kept,
|
|
61
|
+
keep: p.request.headers.has('cookie'),
|
|
62
|
+
})
|
|
63
|
+
const capturing = !!p.options.lazy || shape.streaming
|
|
64
|
+
if (known !== null && !p.options.dev)
|
|
65
|
+
p.remember(styles?.links ? cold + ' w' : cold, {
|
|
66
|
+
...shape,
|
|
67
|
+
ms,
|
|
68
|
+
split: styles?.split ?? false,
|
|
69
|
+
found: styles?.found() ?? new Set(),
|
|
70
|
+
capture: capturing,
|
|
71
|
+
fold: !!shape.parts.fold.length,
|
|
72
|
+
})
|
|
73
|
+
const head = coldHead(p, shape, t, styles, capturing)
|
|
74
|
+
const init = {
|
|
75
|
+
status: p.status,
|
|
76
|
+
headers: {
|
|
77
|
+
'content-type': 'text/html; charset=utf-8',
|
|
78
|
+
'cache-control': 'no-cache',
|
|
79
|
+
...(shape.parts.fold.length && { vary: 'Save-Data, Sec-CH-Prefers-Reduced-Data' }),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
// No Boundary is waiting, so there's nothing to stream: the page goes out in one piece.
|
|
83
|
+
if (!shape.streaming) {
|
|
84
|
+
const res = new Response(null, init)
|
|
85
|
+
whole.set(res, head + (await finish(p, false)))
|
|
86
|
+
return withHeaders(res, p.scope)
|
|
87
|
+
}
|
|
88
|
+
const body = new ReadableStream<Uint8Array>({
|
|
89
|
+
async start(controller) {
|
|
90
|
+
controller.enqueue(p.enc.encode(head))
|
|
91
|
+
await rest(p, { controller, styles: t || styles!, streaming: true })
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
return withHeaders(new Response(body, init), p.scope)
|
|
95
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// The render's shell, incremental data, and remaining streamed chunks.
|
|
2
|
+
import { metaTags, renderToStream } from '@weft-js/core/ssr'
|
|
3
|
+
import { keyOf } from '../data'
|
|
4
|
+
import * as exec from '../exec'
|
|
5
|
+
import { hub } from '../hub'
|
|
6
|
+
import { App } from '../view'
|
|
7
|
+
import type { pageStyles } from '../inline.mjs'
|
|
8
|
+
import { cookieJar } from './request'
|
|
9
|
+
import { dataTags, pageData, payloadOf, scrubbed, wire } from './payload'
|
|
10
|
+
import {
|
|
11
|
+
islandsOf,
|
|
12
|
+
modulepreloads,
|
|
13
|
+
pageChunks,
|
|
14
|
+
pageSheets,
|
|
15
|
+
saveData,
|
|
16
|
+
tableStyles,
|
|
17
|
+
withLevels,
|
|
18
|
+
} from './render'
|
|
19
|
+
import { esc, json } from './text'
|
|
20
|
+
import type { PageState } from './page-types'
|
|
21
|
+
|
|
22
|
+
/** Cache entries the client hasn't got. */
|
|
23
|
+
function news(p: PageState) {
|
|
24
|
+
const fresh = wire(p.scope).filter((w) => !p.sent.has(keyOf(w[0], w[1])))
|
|
25
|
+
for (const w of fresh) p.sent.add(keyOf(w[0], w[1]))
|
|
26
|
+
return fresh
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What the client hasn't been told of the render. */
|
|
30
|
+
function since(p: PageState) {
|
|
31
|
+
const d = p.stream.data()
|
|
32
|
+
const changed = <T,>(from: Record<string, T>, seen: Record<string, unknown>) => {
|
|
33
|
+
const out = Object.fromEntries(Object.entries(from).filter(([k, v]) => seen[k] !== v))
|
|
34
|
+
Object.assign(seen, out)
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
const state = JSON.stringify(d?.s)
|
|
38
|
+
const fresh = state !== p.told.s
|
|
39
|
+
p.told.s = state
|
|
40
|
+
return {
|
|
41
|
+
r: scrubbed(changed(d?.r ?? {}, p.told.r), p.scope),
|
|
42
|
+
b: changed(d?.b ?? {}, p.told.b),
|
|
43
|
+
...(fresh && d?.s && { s: d.s }),
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Start the page render with its request-local stream state. */
|
|
48
|
+
export function render(p: PageState) {
|
|
49
|
+
return renderToStream(() => <App route={p.route} />, {
|
|
50
|
+
...p.timing,
|
|
51
|
+
passes: p.passes,
|
|
52
|
+
shown: (e) => exec.shown(e, p.scope),
|
|
53
|
+
stalled: () =>
|
|
54
|
+
p.options.onDiagnostic?.({
|
|
55
|
+
code: 'E_STREAM',
|
|
56
|
+
severity: 'warning',
|
|
57
|
+
message: `${p.path}: a render stopped reading anything new, so what's still out is left to the client. Is a query's input different on every render, a \`Date.now()\` say?`,
|
|
58
|
+
}),
|
|
59
|
+
app: { cookies: cookieJar(p.request) },
|
|
60
|
+
landing: () => `(self.$wc=self.$wc||[]).push(${json({ c: news(p), ...since(p) })});`,
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The head down to the fonts, after metadata is complete. */
|
|
65
|
+
/**
|
|
66
|
+
* A full load between pages that opt into cross-document view transitions (warp does) brings the browser's own
|
|
67
|
+
* transition. When the browser skips one ("invalid state"), its three promises reject, and the page reveals before the
|
|
68
|
+
* runtime's module runs: so this listens from the head, on every page that has code. Chrome can also reject one the
|
|
69
|
+
* page never sees (a skipped outgoing transition), so a rejection that says only that a transition was aborted or
|
|
70
|
+
* skipped is marked handled too; nothing else is touched.
|
|
71
|
+
*/
|
|
72
|
+
const REVEAL = `<script>(f=>{["pagereveal","pageswap"].forEach(n=>addEventListener(n,e=>{var t=e.viewTransition;t&&[t.ready,t.finished,t.updateCallbackDone].forEach(q=>q.catch(f))}));addEventListener("unhandledrejection",e=>{/^Transition was (aborted|skipped)/.test(e.reason&&e.reason.message)&&e.preventDefault()})})(()=>{})</script>\n`
|
|
73
|
+
|
|
74
|
+
export function top(p: PageState) {
|
|
75
|
+
return (
|
|
76
|
+
`<!doctype html>\n<html${p.lang}>\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
|
|
77
|
+
(p.assets.entry ? REVEAL : '') +
|
|
78
|
+
metaTags(p.r.head, p.url.pathname) +
|
|
79
|
+
'\n' +
|
|
80
|
+
(p.site.head ? p.site.head + '\n' : '') +
|
|
81
|
+
(p.assets.fonts ?? [])
|
|
82
|
+
.map(
|
|
83
|
+
(f) =>
|
|
84
|
+
`<link rel="preload" as="font" type="${esc(f.type)}" href="${esc(f.href)}" crossorigin>\n`,
|
|
85
|
+
)
|
|
86
|
+
.join('')
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** What this render's head needs, from its HTML. */
|
|
91
|
+
export function shaped(p: PageState) {
|
|
92
|
+
const payload = withLevels(payloadOf(p.r, false, p.options), p.stream.shell, p.statics, false)
|
|
93
|
+
const parts = islandsOf(p.stream.shell)
|
|
94
|
+
// A Boundary is waiting: the client starts now, not at the last byte.
|
|
95
|
+
const streaming = Object.values(p.stream.data()?.b ?? {}).includes('p')
|
|
96
|
+
const now = !parts.lazy.length || payload.m.some((m) => !m.s) || !!payload.i?.length
|
|
97
|
+
// The entry's own script tag already fetches it; preloads go above the styles.
|
|
98
|
+
const preloads = modulepreloads(
|
|
99
|
+
[
|
|
100
|
+
...(p.assets.js ?? []),
|
|
101
|
+
...pageChunks(payload, p.options.chunks, saveData(p.request) ? [] : parts.fold, now),
|
|
102
|
+
],
|
|
103
|
+
p.assets,
|
|
104
|
+
)
|
|
105
|
+
return {
|
|
106
|
+
payload,
|
|
107
|
+
parts,
|
|
108
|
+
streaming,
|
|
109
|
+
now,
|
|
110
|
+
preloads,
|
|
111
|
+
sheets: pageSheets(p.assets.css, parts, p.options.chunks),
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** First-paint rules for this render's sheets. */
|
|
116
|
+
export function table(p: PageState, shape: ReturnType<typeof shaped>) {
|
|
117
|
+
return p.assets.pieces && tableStyles(p.assets.pieces, shape.sheets, p.site.head, p.lang)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** From the app element to what remains to stream. */
|
|
121
|
+
export function pageShell(p: PageState, shape: ReturnType<typeof shaped>, after = '') {
|
|
122
|
+
return (
|
|
123
|
+
`<div id="app">${p.stream.shell}</div>\n` +
|
|
124
|
+
after +
|
|
125
|
+
`<script type="application/json" id="weft-route">${json(shape.payload)}</script>\n` +
|
|
126
|
+
(p.scope.form
|
|
127
|
+
? `<script type="application/json" id="weft-form">${json(p.scope.form)}</script>\n`
|
|
128
|
+
: '') +
|
|
129
|
+
// Async, and after all it reads, so it can run while the stream goes on.
|
|
130
|
+
(shape.streaming
|
|
131
|
+
? (since(p), dataTags({ ...pageData(p.stream.data(), p.scope)!, w: 1 }, news(p)))
|
|
132
|
+
: '') +
|
|
133
|
+
(p.assets.entry && shape.streaming
|
|
134
|
+
? `<script type="module" async src="${esc(p.assets.entry)}"></script>\n`
|
|
135
|
+
: '')
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Finish the page with the resolved cache and live cursor. */
|
|
140
|
+
export async function finish(p: PageState, streaming: boolean) {
|
|
141
|
+
const cached = wire(p.scope)
|
|
142
|
+
// Read after the queries ran: the first live connect replays what is published from here.
|
|
143
|
+
if (p.scope.live) await hub().ready()
|
|
144
|
+
const liveScript = p.scope.live
|
|
145
|
+
? `<script type="application/json" id="weft-rt">${json({ c: hub().cursor(), b: p.options.build })}</script>\n`
|
|
146
|
+
: ''
|
|
147
|
+
// A client that starts after this finds the whole page, as if it had never streamed.
|
|
148
|
+
const stale = streaming
|
|
149
|
+
? '<script>["weft-data","weft-cache"].forEach(function(i){(i=document.getElementById(i))&&i.remove()})</script>\n'
|
|
150
|
+
: ''
|
|
151
|
+
return (
|
|
152
|
+
stale + dataTags(pageData(p.stream.data(), p.scope), cached) + liveScript + '</body>\n</html>\n'
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Flush each remaining chunk with its styles. */
|
|
157
|
+
export async function rest(
|
|
158
|
+
p: PageState,
|
|
159
|
+
{
|
|
160
|
+
controller,
|
|
161
|
+
styles,
|
|
162
|
+
streaming,
|
|
163
|
+
}: {
|
|
164
|
+
controller: ReadableStreamDefaultController<Uint8Array>
|
|
165
|
+
styles: Pick<ReturnType<typeof pageStyles>, 'late'>
|
|
166
|
+
streaming: boolean
|
|
167
|
+
},
|
|
168
|
+
) {
|
|
169
|
+
try {
|
|
170
|
+
for await (const chunk of p.stream.chunks)
|
|
171
|
+
controller.enqueue(
|
|
172
|
+
p.enc.encode(
|
|
173
|
+
styles.late(pageSheets([], islandsOf(chunk), p.options.chunks), chunk) + chunk + '\n',
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
} catch (e) {
|
|
177
|
+
p.options.onDiagnostic?.({ code: 'E_STREAM', severity: 'error', message: String(e) })
|
|
178
|
+
}
|
|
179
|
+
controller.enqueue(p.enc.encode(await finish(p, streaming)))
|
|
180
|
+
controller.close()
|
|
181
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Merged, Stream } from '@weft-js/core/ssr'
|
|
2
|
+
import type { createRouteState, RoutePayload } from '../router'
|
|
3
|
+
import type { Scope } from '../exec'
|
|
4
|
+
import type { HandlerOptions, Resolved } from './types'
|
|
5
|
+
|
|
6
|
+
/** A render this long (ms) sends its head first. */
|
|
7
|
+
export const EARLY_MS = 4
|
|
8
|
+
/** Boundary data that arrives within this hold goes in the page. */
|
|
9
|
+
export const HOLD = 25
|
|
10
|
+
|
|
11
|
+
/** A remembered head for a route shape. */
|
|
12
|
+
export interface Head {
|
|
13
|
+
ms: number
|
|
14
|
+
sheets: { href: string }[]
|
|
15
|
+
preloads: string
|
|
16
|
+
now: boolean
|
|
17
|
+
capture: boolean
|
|
18
|
+
fold: boolean
|
|
19
|
+
split: boolean
|
|
20
|
+
found: Set<string>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PageDeps {
|
|
24
|
+
options: HandlerOptions
|
|
25
|
+
site: NonNullable<HandlerOptions['site']>
|
|
26
|
+
statics: Set<string>
|
|
27
|
+
timing: { timeout?: number }
|
|
28
|
+
passes: number
|
|
29
|
+
resolve: (path: string, request: Request) => Promise<Resolved>
|
|
30
|
+
stateOf: (r: Resolved, payload: RoutePayload) => ReturnType<typeof createRouteState>
|
|
31
|
+
complete: (r: Resolved, html?: string) => Merged
|
|
32
|
+
heads: Map<string, Head | null>
|
|
33
|
+
remember: (shape: string, head: Head) => void
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PageState extends PageDeps {
|
|
37
|
+
url: URL
|
|
38
|
+
request: Request
|
|
39
|
+
scope: Scope
|
|
40
|
+
path: string
|
|
41
|
+
r: Resolved
|
|
42
|
+
route: ReturnType<typeof createRouteState>
|
|
43
|
+
sent: Set<string>
|
|
44
|
+
told: { r: Record<string, unknown>; b: Record<string, unknown>; s?: string }
|
|
45
|
+
stream: Stream
|
|
46
|
+
assets: NonNullable<HandlerOptions['assets']>
|
|
47
|
+
lang: string
|
|
48
|
+
dev: string
|
|
49
|
+
status: number
|
|
50
|
+
enc: TextEncoder
|
|
51
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// A known head can go out before the new render finishes.
|
|
2
|
+
import { LAYER_ORDER, pageStyles, type keptSheets } from '../inline.mjs'
|
|
3
|
+
import { capture, speculate } from '../capture'
|
|
4
|
+
import { withHeaders } from './request'
|
|
5
|
+
import { esc } from './text'
|
|
6
|
+
import { pageShell, render, rest, shaped, table, top } from './page-stream'
|
|
7
|
+
import { HOLD, type Head, type PageState } from './page-types'
|
|
8
|
+
|
|
9
|
+
type Styles = ReturnType<typeof pageStyles>
|
|
10
|
+
|
|
11
|
+
/** Render the shell behind a head that has already gone out. */
|
|
12
|
+
async function warmContent(
|
|
13
|
+
p: PageState,
|
|
14
|
+
known: Head,
|
|
15
|
+
sent: {
|
|
16
|
+
cold: string
|
|
17
|
+
set: number
|
|
18
|
+
styles: Styles | undefined
|
|
19
|
+
controller: ReadableStreamDefaultController<Uint8Array>
|
|
20
|
+
},
|
|
21
|
+
) {
|
|
22
|
+
const { cold, set, styles, controller } = sent
|
|
23
|
+
const t0 = performance.now()
|
|
24
|
+
p.stream = render(p)
|
|
25
|
+
await p.stream.settle() // queries read outside a Boundary have nowhere to stream to
|
|
26
|
+
await p.stream.hold(p.options.hold ?? HOLD)
|
|
27
|
+
const ms = performance.now() - t0
|
|
28
|
+
const shape = shaped(p)
|
|
29
|
+
const t = table(p, shape)
|
|
30
|
+
const capturing = !!p.options.lazy || shape.streaming
|
|
31
|
+
// What this page has beyond the head: sheets, rules, and chunks that render didn't reach.
|
|
32
|
+
const have = new Set(known.preloads.split(/(?=<link )/))
|
|
33
|
+
const more = shape.preloads
|
|
34
|
+
.split(/(?=<link )/)
|
|
35
|
+
.filter((l) => l && !have.has(l))
|
|
36
|
+
.join('')
|
|
37
|
+
const rules = t && t.rules(p.stream.shell)
|
|
38
|
+
const lead =
|
|
39
|
+
(t ? (rules ? `<style>${rules}</style>\n` : '') : styles!.late(shape.sheets, p.stream.shell)) +
|
|
40
|
+
more +
|
|
41
|
+
(capturing && !known.capture
|
|
42
|
+
? `<script>${capture(shape.now)}</script>\n` + speculate(shape)
|
|
43
|
+
: '')
|
|
44
|
+
controller.enqueue(
|
|
45
|
+
p.enc.encode(
|
|
46
|
+
lead +
|
|
47
|
+
pageShell(p, shape, t ? t.links : '') +
|
|
48
|
+
(p.assets.entry && !shape.streaming
|
|
49
|
+
? `<script type="module" src="${esc(p.assets.entry)}"></script>\n`
|
|
50
|
+
: ''),
|
|
51
|
+
),
|
|
52
|
+
)
|
|
53
|
+
// A render that set a header after they went can't use this: the route goes back to rendering first.
|
|
54
|
+
if ([...p.scope.headers].length !== set) {
|
|
55
|
+
p.heads.set(cold, null).set(cold + ' w', null)
|
|
56
|
+
p.options.onDiagnostic?.({
|
|
57
|
+
code: 'E_HEAD',
|
|
58
|
+
severity: 'warning',
|
|
59
|
+
message: `${p.path}: a render set a response header after the head was sent, so this route now renders before sending anything.`,
|
|
60
|
+
})
|
|
61
|
+
} else
|
|
62
|
+
p.remember(styles?.links ? cold + ' w' : cold, {
|
|
63
|
+
...shape,
|
|
64
|
+
ms,
|
|
65
|
+
split: styles?.split ?? false,
|
|
66
|
+
found: styles?.found() ?? new Set(),
|
|
67
|
+
capture: capturing,
|
|
68
|
+
fold: !!shape.parts.fold.length,
|
|
69
|
+
preloads: [...have, ...more.split(/(?=<link )/)].filter(Boolean).join(''),
|
|
70
|
+
})
|
|
71
|
+
await rest(p, { controller, styles: t || styles!, streaming: shape.streaming })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Send the remembered head, then flush the render behind it. */
|
|
75
|
+
export async function warmPage(
|
|
76
|
+
p: PageState,
|
|
77
|
+
known: Head,
|
|
78
|
+
cold: string,
|
|
79
|
+
kept: ReturnType<typeof keptSheets>,
|
|
80
|
+
) {
|
|
81
|
+
p.complete(p.r)
|
|
82
|
+
// A session read can set cookies, which have to be in the headers: read it before they go.
|
|
83
|
+
if (p.request.headers.has('cookie')) await p.scope.ctx().catch(() => {})
|
|
84
|
+
const set = [...p.scope.headers].length
|
|
85
|
+
const styles = p.assets.pieces
|
|
86
|
+
? undefined
|
|
87
|
+
: pageStyles(p.assets, known.sheets, '', {
|
|
88
|
+
head: p.site.head,
|
|
89
|
+
seen: known,
|
|
90
|
+
have: kept,
|
|
91
|
+
keep: p.request.headers.has('cookie'),
|
|
92
|
+
})
|
|
93
|
+
// With a table, the rules come with the shell; here there is only the layer order.
|
|
94
|
+
const head =
|
|
95
|
+
top(p) +
|
|
96
|
+
known.preloads +
|
|
97
|
+
(p.assets.entry ? `<link rel="modulepreload" href="${esc(p.assets.entry)}">\n` : '') +
|
|
98
|
+
(styles ? styles.head : `<style>${LAYER_ORDER}${p.assets.pieces!.layers}</style>\n`) +
|
|
99
|
+
(known.capture ? `<script>${capture(known.now)}</script>\n` + speculate(known) : '') +
|
|
100
|
+
p.dev +
|
|
101
|
+
'</head>\n<body>\n'
|
|
102
|
+
const init = {
|
|
103
|
+
status: p.status,
|
|
104
|
+
headers: {
|
|
105
|
+
'content-type': 'text/html; charset=utf-8',
|
|
106
|
+
'cache-control': 'no-cache',
|
|
107
|
+
...(known.fold && { vary: 'Save-Data, Sec-CH-Prefers-Reduced-Data' }),
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
const body = new ReadableStream<Uint8Array>({
|
|
111
|
+
async start(controller) {
|
|
112
|
+
controller.enqueue(p.enc.encode(head))
|
|
113
|
+
try {
|
|
114
|
+
await warmContent(p, known, { cold, set, styles, controller })
|
|
115
|
+
} catch (e) {
|
|
116
|
+
p.heads.set(cold, null).set(cold + ' w', null)
|
|
117
|
+
p.options.onDiagnostic?.({
|
|
118
|
+
code: 'E_HEAD',
|
|
119
|
+
severity: 'error',
|
|
120
|
+
message: `${p.path}: ${String(e)}`,
|
|
121
|
+
})
|
|
122
|
+
controller.enqueue(
|
|
123
|
+
p.enc.encode('<p>Something went wrong. <a href="">Reload</a></p>\n</body>\n</html>\n'),
|
|
124
|
+
)
|
|
125
|
+
controller.close()
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
return withHeaders(new Response(body, init), p.scope)
|
|
130
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Select a remembered head or render before sending the page.
|
|
2
|
+
import { keptSheets, sheetId } from '../inline.mjs'
|
|
3
|
+
import type { Scope } from '../exec'
|
|
4
|
+
import { payloadOf } from './payload'
|
|
5
|
+
import { saveData } from './render'
|
|
6
|
+
import { redirectBody } from './request'
|
|
7
|
+
import { esc, moved } from './text'
|
|
8
|
+
import { coldPage } from './page-cold'
|
|
9
|
+
import { leftToPage } from './head'
|
|
10
|
+
import { warmPage } from './page-warm'
|
|
11
|
+
import { EARLY_MS, type PageDeps, type PageState } from './page-types'
|
|
12
|
+
|
|
13
|
+
/** The page renderer for one handler and its remembered heads. */
|
|
14
|
+
export function makePage(deps: PageDeps) {
|
|
15
|
+
return async function page(url: URL, request: Request, scope: Scope): Promise<Response> {
|
|
16
|
+
const path = url.pathname + url.search
|
|
17
|
+
const r = await deps.resolve(path, request)
|
|
18
|
+
if (r.redirect) {
|
|
19
|
+
const headers = new Headers(r.redirectHeaders)
|
|
20
|
+
headers.set('location', r.redirect.href)
|
|
21
|
+
if (!headers.has('cache-control')) headers.set('cache-control', 'no-cache')
|
|
22
|
+
return redirectBody(
|
|
23
|
+
new Response(moved(r.redirect.href), { status: r.redirect.status, headers }),
|
|
24
|
+
scope,
|
|
25
|
+
'text/html; charset=utf-8',
|
|
26
|
+
r.redirect.href,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
scope.started = true // from here a redirect can't change the status (Q007)
|
|
30
|
+
// The render reads raw deferred data; the page carries the settled part.
|
|
31
|
+
const route = deps.stateOf(r, payloadOf(r, false))
|
|
32
|
+
const sent = new Set<string>()
|
|
33
|
+
const told: PageState['told'] = { r: {}, b: {} }
|
|
34
|
+
const assets = deps.options.assets ?? {}
|
|
35
|
+
const p: PageState = {
|
|
36
|
+
...deps,
|
|
37
|
+
url,
|
|
38
|
+
request,
|
|
39
|
+
scope,
|
|
40
|
+
path,
|
|
41
|
+
r,
|
|
42
|
+
route,
|
|
43
|
+
sent,
|
|
44
|
+
told,
|
|
45
|
+
stream: undefined!,
|
|
46
|
+
assets,
|
|
47
|
+
lang: deps.site.locale ? ` lang="${esc(deps.site.locale.replace('_', '-'))}"` : '',
|
|
48
|
+
dev: deps.options.dev
|
|
49
|
+
? '<script type="module" src="/@vite/client"></script>\n<script type="module" src="/@weft-agent.js"></script>\n'
|
|
50
|
+
: '',
|
|
51
|
+
status: r.status === 200 && scope.form ? scope.form.status : r.status,
|
|
52
|
+
enc: new TextEncoder(),
|
|
53
|
+
}
|
|
54
|
+
// A browser with the page's sheets gets the remembered link-only head. A table has no separate head.
|
|
55
|
+
const cold = `${p.status} ${saveData(request) ? 1 : 0} ${r.levels.map((l) => l.id).join()}`
|
|
56
|
+
const kept = assets.pieces ? undefined : keptSheets(request.headers.get('cookie'))
|
|
57
|
+
const warm = kept && p.heads.get(cold + ' w')
|
|
58
|
+
const shape = warm && warm.sheets.every((c) => kept.has(sheetId(c.href))) ? cold + ' w' : cold
|
|
59
|
+
const known = p.heads.get(shape)
|
|
60
|
+
// A level left words to the page, which are only known after it renders.
|
|
61
|
+
const worded =
|
|
62
|
+
!!r.metas &&
|
|
63
|
+
r.status === 200 &&
|
|
64
|
+
(leftToPage(r.source!.title) || leftToPage(r.source!.description))
|
|
65
|
+
// Only a render slow enough to wait on sends its remembered head first.
|
|
66
|
+
if (known && known.ms >= EARLY_MS && !worded) return warmPage(p, known, cold, kept)
|
|
67
|
+
return coldPage(p, known, cold, kept)
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { Stream } from '@weft-js/core/ssr'
|
|
2
|
+
import type { RoutePayload } from '../router'
|
|
3
|
+
import type { Wire } from '../data'
|
|
4
|
+
import * as exec from '../exec'
|
|
5
|
+
import type { Scope } from '../exec'
|
|
6
|
+
import { selectRouteAssets } from '../asset-receipt.mjs'
|
|
7
|
+
import { json, thenable } from './text'
|
|
8
|
+
import type { HandlerOptions, Resolved } from './types'
|
|
9
|
+
|
|
10
|
+
export function fill(pattern: string, params: Record<string, string>, splat?: string) {
|
|
11
|
+
return pattern.replace(/\$([\w$]*)/g, (_, n) =>
|
|
12
|
+
n ? encodeURIComponent(params[n]!) : (params[splat!] ?? ''),
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The data without its deferred (promise) fields, and their names. */
|
|
17
|
+
export function split(raw: unknown): { data: unknown; d?: string[] } {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { data: raw }
|
|
19
|
+
const fields = raw as Record<string, unknown>
|
|
20
|
+
const d = Object.keys(raw).filter((k) => thenable(fields[k]))
|
|
21
|
+
if (!d.length) return { data: raw }
|
|
22
|
+
for (const k of d) Promise.resolve(fields[k]).catch(() => {}) // a deferred failure surfaces in its Boundary
|
|
23
|
+
return { data: Object.fromEntries(Object.entries(raw).filter(([k]) => !d.includes(k))), d }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** What `meta` and cards see: the settled data. Reading a deferred field is H007 (it's still streaming). */
|
|
27
|
+
export function settledView(
|
|
28
|
+
raw: unknown,
|
|
29
|
+
deferred: string[] | undefined,
|
|
30
|
+
id: string,
|
|
31
|
+
options: HandlerOptions,
|
|
32
|
+
) {
|
|
33
|
+
if (!deferred?.length) return raw
|
|
34
|
+
return new Proxy(raw as object, {
|
|
35
|
+
get(t, k, r) {
|
|
36
|
+
if (typeof k === 'string' && deferred.includes(k)) {
|
|
37
|
+
const message = `H007 \`meta\` (or a card) of route ${id} reads \`data.${k}\`, a deferred field that is still streaming. Await it in \`load\`, or leave it out of \`meta\`.`
|
|
38
|
+
options.onDiagnostic?.({ code: 'H007', severity: 'error', message, route: id })
|
|
39
|
+
throw Object.assign(new Error(message), { code: 'H007' })
|
|
40
|
+
}
|
|
41
|
+
return Reflect.get(t, k, r)
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function payloadOf(r: Resolved, withHead: boolean, options?: HandlerOptions): RoutePayload {
|
|
47
|
+
const { jsonLd: _, ...head } = r.head // only a first response carries it (`metaTags`)
|
|
48
|
+
const assetBytes = selectRouteAssets(
|
|
49
|
+
options?.routeAssetBytes,
|
|
50
|
+
options?.build,
|
|
51
|
+
r.levels.map((level) => level.id),
|
|
52
|
+
)
|
|
53
|
+
return {
|
|
54
|
+
m: r.levels.map(({ id, params, data, d, t }) => ({
|
|
55
|
+
id,
|
|
56
|
+
params,
|
|
57
|
+
data,
|
|
58
|
+
...(d && { d }),
|
|
59
|
+
...(t && { t }),
|
|
60
|
+
})),
|
|
61
|
+
p: r.path,
|
|
62
|
+
s: r.status,
|
|
63
|
+
...(assetBytes && { a: assetBytes }),
|
|
64
|
+
...(withHead && { h: head }),
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const dataTags = (data: unknown, cached: Wire[]) =>
|
|
69
|
+
`<script type="application/json" id="weft-data">${json(data)}</script>\n` +
|
|
70
|
+
(cached.length
|
|
71
|
+
? `<script type="application/json" id="weft-cache">${json(cached)}</script>\n`
|
|
72
|
+
: '')
|
|
73
|
+
|
|
74
|
+
/** A render's data as a page may carry it: what failed says only what `exec.shown` let through. */
|
|
75
|
+
export function pageData(data: ReturnType<Stream['data']>, scope: Scope) {
|
|
76
|
+
return data && { ...data, r: scrubbed(data.r, scope) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const scrubbed = (r: Record<string, { v?: unknown; e?: string }>, scope: Scope) =>
|
|
80
|
+
Object.fromEntries(
|
|
81
|
+
Object.entries(r).map(([k, v]) => [k, v.e === undefined ? v : { e: exec.said(v.e, scope) }]),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
/** The request's resolved queries, as the client cache seeds them (failures are left for the client to retry). */
|
|
85
|
+
export function wire(scope: Scope): Wire[] {
|
|
86
|
+
return [...scope.entries.values()]
|
|
87
|
+
.filter((e) => e.done && e.e === undefined)
|
|
88
|
+
.map((e) => [e.id, e.input, e.v, e.tags])
|
|
89
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Merged } from '@weft-js/core/ssr'
|
|
2
|
+
import { pathPages } from '../derive.mjs'
|
|
3
|
+
import { esc } from './text'
|
|
4
|
+
import { fill } from './payload'
|
|
5
|
+
import type { Manifest, Resolved, RouteModule } from './types'
|
|
6
|
+
|
|
7
|
+
export function makePrerender(
|
|
8
|
+
manifest: Manifest,
|
|
9
|
+
mod: (file?: string) => RouteModule | undefined,
|
|
10
|
+
resolve: (path: string, request: Request) => Promise<Resolved>,
|
|
11
|
+
finished: (r: Resolved, request: Request) => Promise<Merged>,
|
|
12
|
+
) {
|
|
13
|
+
/** Every path `weft build` prerenders: pages from `prerender`, and the card of each page that has a generated one. */
|
|
14
|
+
async function prerenderPaths(request: Request): Promise<string[]> {
|
|
15
|
+
const paths: string[] = []
|
|
16
|
+
for (const route of manifest.routes) {
|
|
17
|
+
if (route.kind !== 'page') continue
|
|
18
|
+
const p = mod(route.file)?.prerender
|
|
19
|
+
const given = typeof p === 'function' ? await p() : p
|
|
20
|
+
const list: Record<string, string>[] =
|
|
21
|
+
given === true ? [{}] : (given ?? (route.params.length ? [] : [{}]))
|
|
22
|
+
for (const params of list) {
|
|
23
|
+
const path = fill(route.pattern, params, route.splat)
|
|
24
|
+
const r = await resolve(path, request)
|
|
25
|
+
if (given && r.status === 200) paths.push(path)
|
|
26
|
+
const image = (await finished(r, request)).og.image
|
|
27
|
+
if (
|
|
28
|
+
r.status === 200 &&
|
|
29
|
+
typeof image === 'object' &&
|
|
30
|
+
image &&
|
|
31
|
+
image.url.startsWith('/_weft/og/')
|
|
32
|
+
)
|
|
33
|
+
paths.push(image.url.replace(/\?.*/, ''))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return paths
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build mode (research 20 §4.3): the card URLs `weft build` prerenders, queued through Nitro's `x-nitro-prerender`.
|
|
41
|
+
* It's a header, so a big site's list comes a page at a time: too long a header is an error that Nitro's client
|
|
42
|
+
* logs and carries on from, and the build ends with no cards.
|
|
43
|
+
*/
|
|
44
|
+
let listing: Promise<string[][]> | undefined
|
|
45
|
+
async function prerenderList(request: Request, url: URL): Promise<Response> {
|
|
46
|
+
const pages = await (listing ??= prerenderPaths(request).then((p) => pathPages(p)))
|
|
47
|
+
const n = Number(url.searchParams.get('page') ?? 0)
|
|
48
|
+
const paths = [
|
|
49
|
+
...(pages[n] ?? []),
|
|
50
|
+
...(n + 1 < pages.length ? [`/_weft/og-routes?page=${n + 1}`] : []),
|
|
51
|
+
]
|
|
52
|
+
// HTML, because Nitro's prerenderer only follows links (and this header) from HTML responses.
|
|
53
|
+
const list = paths.map((p) => `<a href="${esc(p)}">${esc(p)}</a>`).join('\n')
|
|
54
|
+
return new Response(`<!doctype html>\n<meta name="robots" content="noindex">\n${list}\n`, {
|
|
55
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'x-nitro-prerender': paths.join(',') },
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return prerenderList
|
|
60
|
+
}
|