@weft-js/start 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +41 -2
  3. package/package.json +107 -4
  4. package/src/app.mjs +82 -0
  5. package/src/auth/better-auth.ts +58 -0
  6. package/src/auth/drizzle.ts +17 -0
  7. package/src/auth.ts +47 -0
  8. package/src/boot.ts +44 -0
  9. package/src/builtin.tsx +19 -0
  10. package/src/bus/postgres.ts +91 -0
  11. package/src/bus.ts +48 -0
  12. package/src/capture.d.mts +1 -0
  13. package/src/capture.mjs +15 -0
  14. package/src/changes-sql.d.mts +14 -0
  15. package/src/changes-sql.mjs +99 -0
  16. package/src/changes.ts +253 -0
  17. package/src/channel-fake.ts +55 -0
  18. package/src/channel.ts +616 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +308 -0
  21. package/src/data.ts +929 -0
  22. package/src/db/bun-sqlite.d.ts +10 -0
  23. package/src/db/changes-mysql.ts +86 -0
  24. package/src/db/changes-postgres.ts +159 -0
  25. package/src/db/changes-sqlite.ts +116 -0
  26. package/src/db/core.ts +63 -0
  27. package/src/db/index.ts +27 -0
  28. package/src/db/mysql.ts +67 -0
  29. package/src/db/postgres.ts +65 -0
  30. package/src/db/sqlite.ts +139 -0
  31. package/src/derive.d.mts +16 -0
  32. package/src/derive.mjs +140 -0
  33. package/src/dev-runtime.mjs +42 -0
  34. package/src/exec.ts +519 -0
  35. package/src/extension.ts +38 -0
  36. package/src/first-paint.ts +57 -0
  37. package/src/form.tsx +134 -0
  38. package/src/gate.css +6 -0
  39. package/src/hub.ts +822 -0
  40. package/src/index.ts +106 -0
  41. package/src/inline.mjs +2 -0
  42. package/src/inspect.mjs +280 -0
  43. package/src/link.ts +456 -0
  44. package/src/list-drizzle.ts +50 -0
  45. package/src/list.ts +224 -0
  46. package/src/listen.ts +28 -0
  47. package/src/live-fake.ts +42 -0
  48. package/src/live-target.mjs +144 -0
  49. package/src/live.ts +499 -0
  50. package/src/media.ts +43 -0
  51. package/src/migrations.mjs +19 -0
  52. package/src/mux-session.ts +6 -0
  53. package/src/mux.ts +135 -0
  54. package/src/og/DefaultCard.tsx +60 -0
  55. package/src/og/fonts.mjs +64 -0
  56. package/src/og/images.mjs +54 -0
  57. package/src/og/index.mjs +146 -0
  58. package/src/og/native.mjs +29 -0
  59. package/src/range.mjs +19 -0
  60. package/src/router.ts +149 -0
  61. package/src/routes.mjs +142 -0
  62. package/src/run.tsx +198 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1014 -0
  65. package/src/shape.d.mts +3 -0
  66. package/src/shape.mjs +73 -0
  67. package/src/share-server.ts +208 -0
  68. package/src/share.ts +503 -0
  69. package/src/sheets.mjs +75 -0
  70. package/src/sidecar.mjs +503 -0
  71. package/src/socket.ts +389 -0
  72. package/src/stream.ts +13 -0
  73. package/src/verify.ts +251 -0
  74. package/src/view.tsx +47 -0
  75. package/src/vite.mjs +899 -0
package/src/server.tsx ADDED
@@ -0,0 +1,1014 @@
1
+ // The request handler (research 25 §3–§5): match the path and run the loaders on @tanstack/router-core, merge the
2
+ // metadata (research 20 §1: awaited before the first byte, plain data), then stream the page: the head with every
3
+ // tag and the shell first, each Boundary's content as it resolves, the page data last. The same resolve step answers
4
+ // client navigations (`/_weft/data/<path>`) and renders share cards (`/_weft/og/<path>.png`).
5
+ //
6
+ // Status codes are decided before the first byte: 404 when nothing matches or a loader returns `notFound()`, 500 when
7
+ // a loader or `meta` throws, 307 (or the given status) when a loader calls `redirect()`. 404 and 500 render inside
8
+ // the root layout when it loaded.
9
+ //
10
+ // A build's stylesheets go in the page, a round trip saved before the first paint. Each `<style>` keeps a disabled link
11
+ // beside it: a disabled link is never fetched, and it is how Vite's preload helper, on importing a route's chunk, sees
12
+ // the sheet is there and doesn't apply it twice.
13
+ //
14
+ // Server functions (research 27): each request gets a scope (AsyncLocalStorage), so `q.fetch()` in loaders and `q()`
15
+ // in the render share one request cache; `/_weft/fn/<id>` answers the client's calls; the page ends with the
16
+ // scope's resolved entries (`#weft-cache`) so the client cache starts seeded and hydration fetches nothing.
17
+ //
18
+ // Forms and media (research 29): a form POST to a page URL runs its mutation, then answers 303 or the page with the
19
+ // errors in it, so `<Form>` works without JS. `/_weft/m/<hash>/<width>.<fmt>` serves media through the `media.server`
20
+ // provider. The request's cookies reach `persist: 'cookie'` boxes, and the `rt.isolated` below makes a scope reached
21
+ // outside a render S002: this process serves many users, so it has no "the tab's app".
22
+
23
+ import { AsyncLocalStorage } from 'node:async_hooks'
24
+ import type * as NodeZlib from 'node:zlib'
25
+ import type { AnyRoute } from '@tanstack/router-core'
26
+ import { BaseRootRoute, BaseRoute, RouterCore, createNonReactiveMutableStore, createNonReactiveReadonlyStore, isNotFound } from '@tanstack/router-core'
27
+ import { createMemoryHistory } from '@tanstack/history'
28
+ import { rt } from '@weft-js/core'
29
+ import { headTags, mergeMeta, metaTags, region, renderToStream, renderToString, renderToStringAsync, type Merged, type Meta as MetaObject, type Site } from '@weft-js/core/ssr'
30
+ import { createRouteState, type Component, type Match, type RoutePayload } from './router'
31
+ import { sessionChanged, type AuthProvider } from './auth'
32
+ import { EXTENSION, type ExtensionEntry, type ServerExtension } from './extension'
33
+ import { deployUrl, pageText, pathPages } from './derive.mjs'
34
+ import { CLIENT_IP, decodeForm, fieldsOf, FnError, fns, isRedirect, runtime, StartError, type Context, type Redirect, type SchemaIssue, type Wire } from './data'
35
+ import * as exec from './exec'
36
+ import type { MediaServer, Scope, Store } from './exec'
37
+ import { configure, hub, rtRoute, type RealtimeOptions } from './hub'
38
+ import { socketRoute } from './socket'
39
+ import { App } from './view'
40
+ import { DefaultCard } from './og/DefaultCard'
41
+ import { ErrorPage, NotFoundPage } from './builtin'
42
+ import { renderCard } from './og/index.mjs'
43
+ import { firstPaint, type Pieces } from './first-paint'
44
+ import { pick } from './sheets.mjs'
45
+ import { INLINE_CSS_MAX } from './inline.mjs'
46
+ import { capture } from './capture.mjs'
47
+
48
+ /** A page route from the file tree. */
49
+ export interface RouteEntry { id: string; file: string; layouts: string[]; pattern: string; params: string[]; splat?: string; card?: string; kind: 'page' | '404' | 'error' }
50
+ /** A layout from the file tree. */
51
+ export interface LayoutEntry { id: string; file: string; dir: string; parent?: string }
52
+ /** The routes and layouts `weft build` found. */
53
+ export interface Manifest { dir: string; routes: RouteEntry[]; layouts: LayoutEntry[]; defaultCard?: string }
54
+
55
+ /** What a route file exports. */
56
+ export interface RouteModule {
57
+ default?: Component
58
+ load?: (args: { params: Record<string, string>; request: Request; signal: AbortSignal; ctx: Context }) => unknown
59
+ meta?: MetaObject | ((args: { params: Record<string, string>; data: unknown }) => MetaObject | Promise<MetaObject>)
60
+ prerender?: true | Record<string, string>[] | (() => Record<string, string>[] | Promise<Record<string, string>[]>)
61
+ size?: { width: number; height: number }
62
+ }
63
+
64
+ /** A font for generated cards. */
65
+ export interface Font { name: string; data: Uint8Array; weight?: number; style?: 'normal' | 'italic' }
66
+
67
+ /** `createHandler`: the app as the build describes it, and how to serve it. */
68
+ export interface HandlerOptions {
69
+ manifest: Manifest
70
+ /** Route, layout and card modules by manifest file. */
71
+ modules: Record<string, RouteModule>
72
+ site?: Site
73
+ /** The client entry and what to preload (Nitro's `?assets=client`), plus the font build's preloads (`weft:fonts/preloads`). */
74
+ assets?: { entry?: string; js?: { href: string }[]; css?: { href: string }[]; fonts?: { href: string; type: string }[]; pieces?: Pieces & { shared?: { href: string; has: string[] }; routes: { href: string; has: string[] }[] }; inline?: Record<string, string> }
75
+ /** Changes with every build: part of each card's `?v=` hash. */
76
+ build?: string
77
+ dev?: boolean
78
+ fonts?: Font[]
79
+ /** Route and layout ids with no client code (PLAN §11): the server sends their HTML. */
80
+ statics?: string[]
81
+ /** Client chunks by level or island id: the page's own are preloaded, so they don't wait for the entry to ask. */
82
+ chunks?: Record<string, { js?: { href: string }[]; css?: { href: string }[] }>
83
+ /** Some island can wait, so every page carries the capture script: a navigation can arrive at one that does. */
84
+ lazy?: boolean
85
+ /** Stream timeout for Boundaries (ms). */
86
+ timeout?: number
87
+ /** The app's context for `load` and server functions (`src/context.server.ts`'s default export). */
88
+ context?: (args: { request: Request }) => object | Promise<object>
89
+ /** The shared cache for `cache: { life }` queries (memory by default). */
90
+ store?: Store
91
+ /** `src/auth.server.ts`'s default export, any provider: mounted at its base path, checked on the first request. */
92
+ auth?: Pick<AuthProvider, 'handler' | 'basePath' | '$weft'>
93
+ /** `server.extension` providers with an app file (`virtual:weft/extensions`), loaded on the first request. */
94
+ extensions?: ExtensionEntry[]
95
+ /** The app's migrations (`drizzle/`); dev reads them each request, so `weft db generate` needs no restart. */
96
+ migrations?: () => { tag: string; sql: string }[]
97
+ /** The `media.server` provider (`virtual:weft/media`), loaded on first use. */
98
+ media?: () => Promise<MediaServer>
99
+ /** `src/realtime.server.ts`'s default export: the bus, origins and limits. */
100
+ realtime?: RealtimeOptions
101
+ /** `false`: never build URLs on the request's origin (`weft routes --meta`, whose requests aren't real). */
102
+ requestOrigin?: false
103
+ /** Queries marked `live: { required: true }` (`scanRequired`, at build). */
104
+ liveRequired?: string[]
105
+ /** Dev and tests: diagnostics found while serving (H005–H008, Q002–Q010). */
106
+ onDiagnostic?: (d: { code: string; severity: string; message: string; route?: string }) => void
107
+ }
108
+
109
+ /** One matched route level, with its module and the data before serialising. */
110
+ export interface Level extends Match { mod?: RouteModule | undefined; raw: unknown }
111
+
112
+ /** Where the words on a page came from, and its image: what `weft routes --meta` and `weft check` report. */
113
+ export interface MetaSource {
114
+ title: 'route' | 'layout' | 'auto' | 'config' | 'none'
115
+ description: 'route' | 'layout' | 'auto' | 'config' | 'none'
116
+ image: 'explicit' | 'card' | 'auto' | 'none'
117
+ }
118
+
119
+ /** What a path comes to: the status, the route and its levels. */
120
+ export interface Resolved {
121
+ status: number
122
+ path: string
123
+ levels: Level[]
124
+ head: Merged
125
+ /** The page route, when one matched. */
126
+ route?: RouteEntry | undefined
127
+ error?: unknown
128
+ /** A loader called `redirect()`: nothing renders. */
129
+ redirect?: Redirect
130
+ /** The metas the head is merged from, until `complete` has finished it. */
131
+ metas?: MetaObject[] | undefined
132
+ source?: MetaSource
133
+ /** The origin URLs are built on, when one is known. */
134
+ origin?: string | undefined
135
+ }
136
+
137
+ type RouterOptions = ConstructorParameters<typeof RouterCore>[0]
138
+
139
+ const als = new AsyncLocalStorage<Scope>()
140
+ runtime.scope = () => als.getStore()
141
+ runtime.enter = (scope, fn) => als.run(scope as Scope, fn)
142
+ runtime.exec = exec
143
+ rt.isolated = true
144
+ runtime.poke = (tags, origin) => hub().poke(tags, origin)
145
+
146
+ const CARD = { width: 1200, height: 630 }
147
+ const stores = () => ({ createMutableStore: createNonReactiveMutableStore, createReadonlyStore: createNonReactiveReadonlyStore, batch: (f: () => void) => f() })
148
+ const json = (v: unknown) => JSON.stringify(v ?? null).replace(/</g, '\\u003c')
149
+ const esc = (s: string) => s.replace(/[&<"]/g, c => (c === '&' ? '&amp;' : c === '<' ? '&lt;' : '&quot;'))
150
+ const thenable = (v: unknown): v is PromiseLike<unknown> => typeof (v as Partial<PromiseLike<unknown>> | null | undefined)?.then === 'function'
151
+
152
+ /** The origin a head's URLs are built on, or none: then canonical and og:url stay out. */
153
+ function pointAt(head: Merged, origin: string | undefined) {
154
+ if (origin) head.url = origin
155
+ else delete head.url
156
+ }
157
+
158
+ /** `/` → `index`, `/users/42` → `users/42`: the card path under `/_weft/og/`. */
159
+ export const cardPath = (path: string) => (path.replace(/[?#].*/, '').replace(/^\/+|\/+$/g, '') || 'index')
160
+
161
+ /** FNV-1a, twice (different seeds), in base 36: a stable, sync content hash for `?v=`. */
162
+ export function hash(s: string): string {
163
+ let a = 0x811c9dc5, b = 0x01000193 ^ 0x5bd1e995
164
+ for (let i = 0; i < s.length; i++) {
165
+ const c = s.charCodeAt(i)
166
+ a = Math.imul(a ^ c, 0x01000193)
167
+ b = Math.imul(b ^ c, 0x5bd1e995)
168
+ }
169
+ return ((a >>> 0).toString(36) + (b >>> 0).toString(36)).slice(0, 10)
170
+ }
171
+
172
+ /** The app as a `(Request) => Response`: pages, data, functions and cards. */
173
+ export function createHandler(options: HandlerOptions) {
174
+ const { manifest, modules, site = {} } = options
175
+ const byId = new Map(manifest.routes.map(r => [r.id, r]))
176
+ const mod = (file?: string) => (file ? modules[file] : undefined)
177
+ const comps: Record<string, Component> = { 404: NotFoundPage, error: ErrorPage }
178
+ for (const l of manifest.layouts) if (mod(l.file)?.default) comps[l.id] = mod(l.file)!.default!
179
+ for (const r of manifest.routes) if (mod(r.file)?.default) comps[r.id] = mod(r.file)!.default!
180
+ const statics = new Set(options.statics)
181
+ const publicUrl = deployUrl(site, (globalThis as unknown as { process?: { env: Record<string, string | undefined> } }).process?.env)
182
+ let warned = false
183
+ /**
184
+ * The origin canonical, og:url and og:image are built on. Without a configured one, the request's: right for what
185
+ * a browser asked, but it's whatever `Host` said, so a production server says so once. A prerender has no public
186
+ * request at all (it would write `localhost` into a file), so its pages stay relative and H001 reports them.
187
+ */
188
+ const originOf = (request: Request) => {
189
+ if (publicUrl) return publicUrl
190
+ if (request.headers.has('x-nitro-prerender') || options.requestOrigin === false) return undefined
191
+ if (!options.dev && !warned) {
192
+ warned = true
193
+ options.onDiagnostic?.({ code: 'H001', severity: 'warning', message: 'H001 `site.url` is not set, so canonical, og:url and og:image use the request\'s Host header. Set `site.url`, or `WEFT_SITE_URL` in the deploy environment.' })
194
+ }
195
+ return new URL(request.url).origin
196
+ }
197
+ /** The route state of a server render: where a static level is in it, it marks where each level starts (`region`). */
198
+ const stateOf = (r: Resolved, payload: RoutePayload) => {
199
+ const route = createRouteState({ ...payload, m: r.levels.map(lv => ({ id: lv.id, params: lv.params, data: lv.raw })) }, comps)
200
+ if (r.levels.some(l => statics.has(l.id))) route.around = (d, id, body) => region(statics.has(id), d, statics.has(r.levels[d - 1]?.id ?? ''), body)
201
+ return route
202
+ }
203
+ const tree = routeTree(manifest, modules)
204
+ let provider: Promise<MediaServer> | undefined
205
+ const mediaServer = () => (provider ??= options.media?.())
206
+ const scopeOptions = { context: options.context, store: options.store, dev: options.dev, onDiagnostic: options.onDiagnostic, media: mediaServer, extensions: undefined as ServerExtension[] | undefined }
207
+ let checked = !options.dev
208
+ const authBase = (options.auth?.basePath ?? '/api/auth').replace(/\/$/, '')
209
+ const extensions = new Map<string, ServerExtension>()
210
+ let loading: Promise<void> | undefined
211
+ const loadExtensions = () => (loading ??= (async () => {
212
+ for (const e of options.extensions ?? []) extensions.set(e.key, await e.load())
213
+ scopeOptions.extensions = [...extensions.values()]
214
+ })())
215
+ let applied = -1
216
+ configure({ ...options.realtime, build: options.build, dev: options.dev, store: options.store, required: options.liveRequired, onDiagnostic: options.onDiagnostic })
217
+
218
+ /**
219
+ * Before the first request (and in dev, when `drizzle/` grows): the auth secret, and the list each database applies
220
+ * before its first statement (db/core.ts), so a page with no queries doesn't need one. A client whose statements can't
221
+ * be held is migrated here; if that fails the request goes on, and Q016 is what using the database then gives.
222
+ */
223
+ async function boot() {
224
+ const list = options.migrations?.() ?? []
225
+ if (applied !== list.length) {
226
+ options.auth?.$weft?.check()
227
+ for (const e of extensions.values()) e.check?.(options.dev)
228
+ applied = list.length
229
+ const report = (e: unknown) => options.onDiagnostic?.({ code: 'Q016', severity: 'error', message: `Q016: ${String((e as Error | null)?.message ?? e)}` })
230
+ runtime.migrations = { list, report }
231
+ }
232
+ if (runtime.dbs?.size) await Promise.all([...runtime.dbs].map(ready => Promise.resolve(ready()).catch(() => {})))
233
+ }
234
+
235
+ async function resolve(path: string, request: Request): Promise<Resolved> {
236
+ const scope = als.getStore() ?? exec.createScope(request, scopeOptions)
237
+ const hold: LoadContext = { request, scope, tags: new Map(), out: {} }
238
+ const router = new RouterCore({ routeTree: tree, history: createMemoryHistory({ initialEntries: [path] }), isServer: true, context: hold } as RouterOptions, stores)
239
+ // The page's `[...name]`, so its layouts see the catch-all under that name too (the loaders copy `hold` as they start).
240
+ hold.splat = (router.getMatchedRoutes(router.latestLocation.pathname)[2] as { options: { weft?: RouteEntry } } | undefined)?.options.weft?.splat
241
+ await router.load()
242
+ const moved = hold.out.redirect
243
+ if (moved) return { status: moved.status, path, levels: [], head: mergeMeta(site), redirect: moved }
244
+ const matches = router.state.matches.map(m => ({ m, weft: (router.routesById as Record<string, { options: { weft?: RouteEntry | LayoutEntry } }>)[m.routeId]?.options.weft }))
245
+ const params = (m: { params: Record<string, unknown> }, splat?: string) => cleanParams(m.params, splat)
246
+ const leaf = matches.at(-1)?.weft
247
+ const route = leaf && 'kind' in leaf ? leaf : undefined
248
+ const failed = matches.find(x => x.m.status === 'error')
249
+ const missing = !route || matches.some(x => x.m.status === 'notFound' || isNotFound(x.m.error))
250
+ const rootLayout = manifest.layouts.find(l => l.dir === '')
251
+ const rootOk = rootLayout && matches[0]?.weft === rootLayout && matches[0].m.status === 'success' ? matches[0] : undefined
252
+ const levelOf = (x: (typeof matches)[number]): Level => {
253
+ const raw = x.m.loaderData
254
+ const t = hold.tags.get(x.weft!.file)
255
+ return { id: x.weft!.id, params: params(x.m, hold.splat), mod: mod(x.weft!.file), raw, ...split(raw), ...(t?.size && { t: [...t] }) }
256
+ }
257
+ const special = (id: '404' | 'error', status: number, error?: unknown): Promise<Resolved> => {
258
+ const entry = byId.get(id)
259
+ if (error instanceof StartError && error.code) options.onDiagnostic?.({ code: error.code, severity: 'error', message: error.message })
260
+ const shown = id === 'error' ? { status, message: options.dev && error instanceof Error ? error.message : 'Something went wrong.' } : { path }
261
+ const levels: Level[] = [...(rootOk && !(failed && failed === rootOk) ? [levelOf(rootOk)] : []), { id, params: {}, data: shown, raw: shown, mod: mod(entry?.file) }]
262
+ if (entry && mod(entry.file)?.default) comps[id] = mod(entry.file)!.default!
263
+ return finish(levels, status, undefined, error)
264
+ }
265
+ const finish = async (shown: Level[], status: number, matched?: RouteEntry, error?: unknown): Promise<Resolved> => {
266
+ const metas: MetaObject[] = []
267
+ const source: MetaSource = { title: site.name ? 'config' : 'none', description: site.description ? 'config' : 'none', image: 'none' }
268
+ for (const lv of shown) {
269
+ const m = lv.mod?.meta ?? (lv.id === '404' ? { title: 'Not found' } : lv.id === 'error' ? { title: 'Error' } : undefined)
270
+ if (!m) continue
271
+ const value = typeof m === 'function' ? await m({ params: lv.params, data: settledView(lv.raw, lv.d, lv.id, options) }) : m
272
+ metas.push(value)
273
+ const who = lv === shown.at(-1) ? 'route' : 'layout'
274
+ if (value?.title != null) source.title = who
275
+ if (value?.description != null) source.description = who
276
+ }
277
+ const resolved: Resolved = { status, path, levels: shown, head: mergeMeta(site, ...metas), route: matched, error, metas, source, origin: originOf(request) }
278
+ pointAt(resolved.head, resolved.origin)
279
+ return resolved
280
+ }
281
+
282
+ if (failed) {
283
+ const e = exec.dbError(failed.m.error)
284
+ return special('error', (e as { code?: string } | null)?.code === 'Q016' ? 503 : 500, e)
285
+ }
286
+ if (missing) return special('404', 404)
287
+ const levels = matches.filter(x => x.weft).map(levelOf)
288
+ try {
289
+ return await finish(levels, 200, route)
290
+ } catch (e) {
291
+ if (isNotFound(e)) return special('404', 404)
292
+ return special('error', 500, e)
293
+ }
294
+ }
295
+
296
+ const timing = options.timeout === undefined ? {} : { timeout: options.timeout }
297
+ const leftToPage = (s: MetaSource['title']) => s === 'config' || s === 'none'
298
+
299
+ /**
300
+ * The content hash of a generated card: what it draws. The built-in card reads the head and the site's look and
301
+ * nothing else, so a loader's timestamp can't change its URL; an `.og.tsx` also gets the page's data.
302
+ */
303
+ function cardUrl(route: RouteEntry, levels: Level[], head: Merged, path: string) {
304
+ const card = route.card ?? manifest.defaultCard
305
+ const size = mod(card)?.size ?? CARD
306
+ const { image: _, ...graph } = head.og
307
+ const leaf = levels.at(-1)!
308
+ const v = hash([options.build ?? '', card ?? 'builtin', JSON.stringify(size), JSON.stringify({ ...head, og: graph }), JSON.stringify(site.card ?? null), card ? JSON.stringify(leaf.data ?? null) : ''].join('\0'))
309
+ return `/_weft/og/${cardPath(path)}.png?v=${v}`
310
+ }
311
+
312
+ /**
313
+ * The rest of the head, once the page's HTML can speak for it (research 20 §10): a level that set no title or
314
+ * description gets the page's own, under anything a layout or the route wrote, and `image: 'auto'` becomes the
315
+ * card, whose URL hashes the text it shows. Called once per request, before anything is written.
316
+ */
317
+ function complete(r: Resolved, html?: string) {
318
+ if (!r.metas || !r.source) return r.head
319
+ const { metas, source } = r
320
+ r.metas = undefined
321
+ const left = leftToPage(source.title) || leftToPage(source.description)
322
+ const words = left && r.status === 200 && html ? pageText(html) : {}
323
+ const title = leftToPage(source.title) ? words.title : undefined
324
+ const description = leftToPage(source.description) ? words.description : undefined
325
+ if (title) source.title = 'auto'
326
+ if (description) source.description = 'auto'
327
+ const head = (r.head = mergeMeta(site, { ...(title !== undefined && { title }), ...(description !== undefined && { description }) }, ...metas))
328
+ pointAt(head, r.origin)
329
+ if (head.jsonLd === undefined && r.route?.pattern === '/' && r.status === 200 && r.origin && site.name) head.jsonLd = { '@context': 'https://schema.org', '@type': 'WebSite', name: site.name, url: r.origin, ...(site.description && { description: site.description }) }
330
+ if (head.og.image === 'auto') {
331
+ const showing = r.status === 200 && r.route
332
+ if (showing) head.og.image = { url: cardUrl(r.route!, r.levels, head, r.path), ...CARD, alt: head.og.title ?? head.title }
333
+ else delete head.og.image
334
+ if (showing) source.image = r.route!.card || manifest.defaultCard ? 'card' : 'auto'
335
+ } else if (head.og.image) source.image = 'explicit'
336
+ return head
337
+ }
338
+
339
+ /** What `page` shows a reader, without the streaming: the same pass, so a page, its navigation and its card agree. */
340
+ async function shell(r: Resolved, request: Request) {
341
+ const route = stateOf(r, payloadOf(r, false))
342
+ const s = renderToStream(() => <App route={route} />, { ...timing, app: { cookies: cookieJar(request) } })
343
+ await s.settle()
344
+ return s.shell
345
+ }
346
+
347
+ /**
348
+ * The finished head of a request that doesn't otherwise render: it renders only when a level left words to the page.
349
+ * A render that fails here (a build with no database) leaves the words to the config; the page still shows its own.
350
+ */
351
+ const finished = async (r: Resolved, request: Request) => {
352
+ if (!r.metas || r.status !== 200 || !(leftToPage(r.source!.title) || leftToPage(r.source!.description))) return complete(r)
353
+ return complete(r, await shell(r, request).catch(() => undefined))
354
+ }
355
+
356
+ /** `weft routes --meta`: one page's finished head, its HTML and where its words came from, in a request scope. */
357
+ const inspect = (path: string, request: Request) => als.run(exec.createScope(request, scopeOptions), async () => {
358
+ const r = await resolve(path, request)
359
+ if (r.redirect) return { ...r, html: '' }
360
+ const html = await shell(r, request).catch(() => '')
361
+ complete(r, html)
362
+ return { ...r, html }
363
+ })
364
+
365
+ async function page(url: URL, request: Request, scope: Scope): Promise<Response> {
366
+ const path = url.pathname + url.search
367
+ const r = await resolve(path, request)
368
+ if (r.redirect) return withHeaders(new Response(null, { status: r.redirect.status, headers: { location: r.redirect.href, 'cache-control': 'no-cache' } }), scope)
369
+ scope.started = true // from here a redirect can't change the status (Q007)
370
+ // The server renders from the raw data (deferred fields are still promises, for Boundaries to stream); the page
371
+ // carries the settled part.
372
+ const route = stateOf(r, payloadOf(r, false))
373
+ const s = renderToStream(() => <App route={route} />, { ...timing, app: { cookies: cookieJar(request) } })
374
+ await s.settle() // queries read outside a Boundary have nowhere to stream to
375
+ complete(r, s.shell)
376
+ const payload = withLevels(payloadOf(r, false), s.shell, statics, false)
377
+ const assets = options.assets ?? {}
378
+ const parts = islandsOf(s.shell)
379
+ const now = !parts.lazy.length || payload.m.some(m => !m.s) || !!payload.i?.length
380
+ const lang = site.locale ? ` lang="${esc(site.locale.replace('_', '-'))}"` : ''
381
+ const css = pageSheets(assets.css, parts, options.chunks).map(c => c.href)
382
+ const late = assets.pieces && pick(assets.pieces, css)
383
+ const paint = late && firstPaint(assets.pieces!, css, site.head)
384
+ const head =
385
+ `<!doctype html>\n<html${lang}>\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
386
+ metaTags(r.head, url.pathname) + '\n' +
387
+ (site.head ? site.head + '\n' : '') +
388
+ (assets.fonts ?? []).map(f => `<link rel="preload" as="font" type="${esc(f.type)}" href="${esc(f.href)}" crossorigin>\n`).join('') +
389
+ (paint ? `<style>${assets.pieces!.layers}${paint(`<html${lang}><body><div id="app">${s.shell}`)}</style>\n` : sheets({ ...assets, css: pageSheets(assets.css, parts, options.chunks) })) +
390
+ // The entry's own script tag, just below, already fetches it.
391
+ modulepreloads([...(assets.js ?? []), ...pageChunks(payload, options.chunks, saveData(request) ? [] : parts.fold, now)], assets) +
392
+ (options.lazy ? `<script>${capture(now)}</script>\n` : '') +
393
+ // Dev: Vite's client (HMR) and weft's agent bridge, which transformIndexHtml adds to HTML-file apps.
394
+ (options.dev ? '<script type="module" src="/@vite/client"></script>\n<script type="module" src="/@weft-agent.js"></script>\n' : '') +
395
+ (assets.entry ? `<script type="module" src="${esc(assets.entry)}"></script>\n` : '') +
396
+ `</head>\n<body>\n<div id="app">${s.shell}</div>\n` +
397
+ // Past the shell: it can't hold the first paint, a Boundary streamed after it waits for it, and so do the scripts.
398
+ (late ? [...late.sheets.map(c => c.href), ...late.rest].map(href => `<link rel="stylesheet" href="${esc(href)}">\n`).join('') + late.sheets.flatMap(c => c.has).map(href => `<link rel="stylesheet" href="${esc(href)}" disabled>\n`).join('') : '') +
399
+ `<script type="application/json" id="weft-route">${json(payload)}</script>\n` +
400
+ (scope.form ? `<script type="application/json" id="weft-form">${json(scope.form)}</script>\n` : '')
401
+ const finish = async () => {
402
+ const cached = wire(scope)
403
+ // Read after the queries ran: what's published from here on, the first connect replays (research 30 §1).
404
+ if (scope.live) await hub().ready()
405
+ const liveScript = scope.live ? `<script type="application/json" id="weft-rt">${json({ c: hub().cursor(), b: options.build })}</script>\n` : ''
406
+ return `<script type="application/json" id="weft-data">${json(s.data())}</script>\n` + (cached.length ? `<script type="application/json" id="weft-cache">${json(cached)}</script>\n` : '') + liveScript + '</body>\n</html>\n'
407
+ }
408
+ const status = r.status === 200 && scope.form ? scope.form.status : r.status
409
+ const init = { status, headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-cache', ...(parts.fold.length && { vary: 'Save-Data, Sec-CH-Prefers-Reduced-Data' }) } }
410
+ // No Boundary is waiting, so there's nothing to stream: the page goes out in one piece.
411
+ if (!Object.values(s.data()?.b ?? {}).includes('p')) {
412
+ const res = new Response(null, init)
413
+ whole.set(res, head + (await finish()))
414
+ return withHeaders(res, scope)
415
+ }
416
+ const enc = new TextEncoder()
417
+ const body = new ReadableStream<Uint8Array>({
418
+ async start(controller) {
419
+ controller.enqueue(enc.encode(head))
420
+ try {
421
+ for await (const chunk of s.chunks) {
422
+ const sheet = paint?.(chunk)
423
+ controller.enqueue(enc.encode((sheet ? `<style>${sheet}</style>\n` : '') + chunk + '\n'))
424
+ }
425
+ } catch (e) {
426
+ options.onDiagnostic?.({ code: 'E_STREAM', severity: 'error', message: String(e) })
427
+ }
428
+ controller.enqueue(enc.encode(await finish()))
429
+ controller.close()
430
+ },
431
+ })
432
+ return withHeaders(new Response(body, init), scope)
433
+ }
434
+
435
+ /**
436
+ * A form POST to a page (research 29 §5): the mutation, then 303 (Post/Redirect/Get) or the page again with the
437
+ * errors and what was typed. A form can't send `x-weft`, so the CSRF proof is the browser's own origin headers.
438
+ */
439
+ async function form(url: URL, request: Request, scope: Scope): Promise<Response> {
440
+ const origin = request.headers.get('origin')
441
+ const from = request.headers.get('sec-fetch-site')
442
+ if (!(origin ? origin === url.origin && (!from || from === 'same-origin') : from === 'same-origin')) {
443
+ options.onDiagnostic?.({ code: 'F002', severity: 'error', message: `F002: a form POST to ${url.pathname} without the same-origin proof (\`Origin\` or \`Sec-Fetch-Site\`)` })
444
+ return new Response('Forbidden', { status: 403 })
445
+ }
446
+ const fields = await request.formData()
447
+ const named = fields.get('$fn')
448
+ const id = typeof named === 'string' ? named : ''
449
+ const f = fns.get(id)
450
+ if (f?.kind !== 'mutation') {
451
+ options.onDiagnostic?.({ code: 'F003', severity: 'error', message: `F003: a form POST names \`${id}\`, which is no mutation (the page is older than the server)` })
452
+ return new Response('Not found', { status: 404 })
453
+ }
454
+ const see = (href: string) => withHeaders(new Response(null, { status: 303, headers: { location: href, 'cache-control': 'no-cache' } }), scope)
455
+ const input = decodeForm(fields)
456
+ try {
457
+ await exec.runMutation(scope, f, input, [], true)
458
+ return see(url.pathname + url.search)
459
+ } catch (e) {
460
+ if (isRedirect(e)) return see(e.href)
461
+ const known = e instanceof StartError || e instanceof FnError
462
+ if (!known) options.onDiagnostic?.({ code: 'E_FN', severity: 'error', message: `${id}: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
463
+ const failure = e as { issues?: SchemaIssue[]; status?: number; message?: string } | null
464
+ const issues = failure?.issues
465
+ const { errors, message } = fieldsOf(issues)
466
+ const key = fields.get('$form')
467
+ scope.form = {
468
+ id, ...(typeof key === 'string' && { key }), values: echoed(input), errors,
469
+ message: issues ? message : known || options.dev ? failure?.message : 'Server function failed.',
470
+ status: issues ? 422 : failure?.status ?? 500,
471
+ }
472
+ return page(url, request, scope)
473
+ }
474
+ }
475
+
476
+ /** `/_weft/m/<hash>/<width>.<fmt>` (research 29 §6): content-addressed, so cached for good; `auto` varies on `Accept`. */
477
+ async function media(url: URL, request: Request): Promise<Response> {
478
+ const m = /^\/_weft\/m\/([0-9a-f]{32})\/(\d+)\.([a-z0-9]+)$/.exec(url.pathname)
479
+ const server = await mediaServer()
480
+ if (!server) options.onDiagnostic?.({ code: 'M017', severity: 'error', message: 'M017: a media URL was requested, but nothing provides `media.server`. Add `media()` from `@weft-js/plugin-media` to `plugins`' })
481
+ const out = m && (await server?.serve({ hash: m[1]!, width: Number(m[2]), format: m[3]!, accept: request.headers.get('accept') ?? '' }))
482
+ if (!out) return new Response('Not found', { status: 404 })
483
+ const headers = new Headers({ 'content-type': out.type, 'cache-control': 'public, max-age=31536000, immutable', etag: `"${m[1]}-${out.width}-${out.format}"`, 'x-content-type-options': 'nosniff' })
484
+ if (out.negotiated) headers.set('vary', 'Accept')
485
+ if (out.type === 'image/svg+xml') headers.set('content-security-policy', "default-src 'none'; style-src 'unsafe-inline'; sandbox")
486
+ if (out.temporary) { // The original, standing in for a rung still encoding.
487
+ headers.set('cache-control', 'no-store')
488
+ headers.delete('etag')
489
+ }
490
+ if (request.headers.get('if-none-match') === headers.get('etag')) return new Response(null, { status: 304, headers })
491
+ const range = /^(video|audio)\//.test(out.type) && /^bytes=(\d+)-(\d*)$/.exec(request.headers.get('range') ?? '')
492
+ if (!range) return new Response(out.bytes as BodyInit, { headers })
493
+ const start = Number(range[1])
494
+ const end = Math.min(range[2] ? Number(range[2]) : Infinity, out.bytes.length - 1)
495
+ if (start > end) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${out.bytes.length}` } })
496
+ headers.set('content-range', `bytes ${start}-${end}/${out.bytes.length}`)
497
+ return new Response(out.bytes.subarray(start, end + 1) as BodyInit, { status: 206, headers })
498
+ }
499
+
500
+ /** The route as JSON for client navigation: deferred fields awaited (no streaming on navigation yet). */
501
+ async function data(path: string, request: Request, scope: Scope): Promise<Response> {
502
+ const r = await resolve(path, request)
503
+ if (r.redirect) return withHeaders(Response.json({ m: [], p: path, s: r.redirect.status, r: r.redirect.href } satisfies RoutePayload, { headers: { 'cache-control': 'no-cache' } }), scope)
504
+ for (const lv of r.levels) if (lv.d?.length) lv.data = { ...(lv.data as object), ...Object.fromEntries(await Promise.all(lv.d.map(async k => [k, await (lv.raw as Record<string, unknown>)[k]]))) }
505
+ await finished(r, request)
506
+ const c = wire(scope)
507
+ let payload = payloadOf(r, true)
508
+ if (r.levels.some(lv => statics.has(lv.id))) {
509
+ const route = stateOf(r, payload)
510
+ const out = await renderToStringAsync(() => <App route={route} />, { app: { cookies: cookieJar(request) } })
511
+ const at = out.lastIndexOf('<script type="application/json" data-weft>')
512
+ payload = { ...withLevels(payload, at < 0 ? out : out.slice(0, at), statics, true), ...(at >= 0 && { b: JSON.parse(out.slice(at).replace(/^<[^>]*>/, '').replace(/<\/script>$/, '')) }) }
513
+ }
514
+ return withHeaders(Response.json({ ...payload, ...(c.length && { c }) }, { status: 200, headers: { 'cache-control': 'no-cache' } }), scope)
515
+ }
516
+
517
+ /** `/_weft/fn/<id>`: GET runs a query, POST a mutation (CSRF-checked), both answering JSON (research 27 §3). */
518
+ async function fn(url: URL, request: Request, scope: Scope): Promise<Response> {
519
+ const id = decodeURIComponent(url.pathname.slice('/_weft/fn/'.length))
520
+ const f = fns.get(id)
521
+ const out = (body: unknown, status = 200) => withHeaders(Response.json(body, { status, headers: { 'cache-control': 'no-store' } }), scope)
522
+ const fail = (code: string, message: string, status: number, extra: object = {}) => {
523
+ options.onDiagnostic?.({ code, severity: 'error', message })
524
+ return out({ e: { code, message, ...extra } }, status)
525
+ }
526
+ if (!f) return fail('Q009', `Q009: no server function \`${id}\` (the page is older than the server, or the module isn't a server module): reload`, 404)
527
+ let fromForm = false
528
+ if (f.kind === 'stream') return fail('Q009', `Q009: \`${id}\` is a stream: its client reads it over \`/_weft/rt\``, 405)
529
+ try {
530
+ if (f.kind === 'query') {
531
+ if (request.method !== 'GET' && request.method !== 'HEAD') return fail('Q009', `Q009: \`${id}\` is a query: GET only`, 405)
532
+ const raw = url.searchParams.get('i')
533
+ const e = await exec.fetchQuery(scope, f, raw === null ? undefined : JSON.parse(raw))
534
+ return out({ v: e.v, t: e.tags })
535
+ }
536
+ if (request.method !== 'POST') return fail('Q010', `Q010: mutation \`${id}\` needs POST`, 405)
537
+ const origin = request.headers.get('origin')
538
+ const from = request.headers.get('sec-fetch-site')
539
+ const cross = (origin && origin !== url.origin) || (from && from !== 'same-origin' && from !== 'none')
540
+ const type = request.headers.get('content-type') ?? ''
541
+ const multipart = type.startsWith('multipart/form-data') // `<Form>` with a file; `x-weft` still forces a preflight
542
+ if (cross || request.headers.get('x-weft') !== '1' || !(multipart || type.startsWith('application/json')))
543
+ return fail('Q010', `Q010: mutation \`${id}\` without the CSRF proof (POST + JSON + \`x-weft: 1\`, same origin): call it through its stub`, 403)
544
+ const body = multipart ? await multipartBody(request) : ((await request.json()) as { i?: unknown; f?: [string, unknown][]; c?: 1 })
545
+ fromForm = !!body.c
546
+ const answer = await exec.runMutation(scope, f, body.i, Array.isArray(body.f) ? body.f.slice(0, 200) : [], fromForm)
547
+ if (options.dev && sessionChanged(scope.headers)) options.onDiagnostic?.({ code: 'A004', severity: 'warning', message: `\`${id}\` changed the session cookie without a redirect: the tab keeps what the last session cached. \`redirect()\` after it` })
548
+ return out(answer)
549
+ } catch (e) {
550
+ // The session changed: a full load, so nothing cached for the last user survives in the tab (research 28 §3).
551
+ if (isRedirect(e)) return out({ r: e.href, ...(sessionChanged(scope.headers) && { x: 1 }) })
552
+ if (e instanceof FnError) return out({ e: { code: e.code, message: e.message } }, e.status)
553
+ // A wrong field in a form is an answer, not a fault: nothing to log.
554
+ if (e instanceof StartError && fromForm && e.extra.issues) return out({ e: { code: e.code, message: e.message, issues: e.extra.issues } }, 422)
555
+ if (e instanceof StartError) return fail(e.code, e.message, (e.extra.status as number) ?? 500, e.extra.issues ? { issues: e.extra.issues } : e.extra.typed ? { data: e.extra.data } : {})
556
+ options.onDiagnostic?.({ code: 'E_FN', severity: 'error', message: `${id}: ${e instanceof Error ? e.stack ?? e.message : String(e)}` })
557
+ return out({ e: { message: options.dev && e instanceof Error ? e.message : 'Server function failed.' } }, 500)
558
+ }
559
+ }
560
+
561
+ async function og(url: URL, request: Request): Promise<Response> {
562
+ const path = '/' + url.pathname.slice('/_weft/og/'.length).replace(/\.png$/, '').replace(/^index$/, '')
563
+ const r = await resolve(path, request)
564
+ if (r.status !== 200 || !r.route) return new Response('Not found', { status: 404 }) // only params the route's own load accepts
565
+ const head = await finished(r, request)
566
+ const image = head.og.image
567
+ if (typeof image !== 'object' || !image.url.startsWith('/_weft/og/')) return new Response('Not found', { status: 404 }) // this page has no generated card
568
+ const want = new URL(image.url, url).searchParams.get('v')
569
+ const cardMod = mod(r.route.card ?? manifest.defaultCard)
570
+ const leaf = r.levels.at(-1)!
571
+ const cardProps = { params: leaf.params, data: settledView(leaf.raw, leaf.d, leaf.id, options), meta: head, site: { ...site, url: r.origin }, path: r.path, title: head.og.title ?? head.title, description: head.og.description ?? head.description ?? '' }
572
+ const Card = (cardMod?.default ?? DefaultCard) as (props: typeof cardProps) => unknown
573
+ const html = renderToString(() => <Card {...cardProps} />, { hydratable: false })
574
+ const png = await renderCard(html, cardMod?.size ?? CARD, options.fonts ?? [], {
575
+ strict: false,
576
+ onDiagnostic: (d: { code: string; severity: string; message: string }) => options.onDiagnostic?.({ ...d, route: r.route!.id }),
577
+ })
578
+ // A signed-in request can see what a public cache must not keep.
579
+ const fresh = url.searchParams.get('v') === want && !request.headers.has('cookie')
580
+ const cache = options.dev ? 'no-store' : fresh ? 'public, max-age=31536000, immutable' : 'no-cache'
581
+ return new Response(png, { headers: { 'content-type': 'image/png', 'cache-control': cache } })
582
+ }
583
+
584
+ /** Every path `weft build` prerenders: pages from `prerender`, and the card of each page that has a generated one. */
585
+ async function prerenderPaths(request: Request): Promise<string[]> {
586
+ const paths: string[] = []
587
+ for (const route of manifest.routes) {
588
+ if (route.kind !== 'page') continue
589
+ const p = mod(route.file)?.prerender
590
+ const given = typeof p === 'function' ? await p() : p
591
+ const list: Record<string, string>[] = given === true ? [{}] : given ?? (route.params.length ? [] : [{}])
592
+ for (const params of list) {
593
+ const path = fill(route.pattern, params, route.splat)
594
+ const r = await resolve(path, request)
595
+ if (given && r.status === 200) paths.push(path)
596
+ const image = (await finished(r, request)).og.image
597
+ if (r.status === 200 && typeof image === 'object' && image && image.url.startsWith('/_weft/og/')) paths.push(image.url.replace(/\?.*/, ''))
598
+ }
599
+ }
600
+ return paths
601
+ }
602
+
603
+ /**
604
+ * Build mode (research 20 §4.3): the card URLs `weft build` prerenders, queued through Nitro's `x-nitro-prerender`.
605
+ * 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
606
+ * logs and carries on from, and the build ends with no cards.
607
+ */
608
+ let listing: Promise<string[][]> | undefined
609
+ async function prerenderList(request: Request, url: URL): Promise<Response> {
610
+ const pages = await (listing ??= prerenderPaths(request).then(p => pathPages(p)))
611
+ const n = Number(url.searchParams.get('page') ?? 0)
612
+ const paths = [...(pages[n] ?? []), ...(n + 1 < pages.length ? [`/_weft/og-routes?page=${n + 1}`] : [])]
613
+ // HTML, because Nitro's prerenderer only follows links (and this header) from HTML responses.
614
+ const list = paths.map(p => `<a href="${esc(p)}">${esc(p)}</a>`).join('\n')
615
+ return new Response(`<!doctype html>\n<meta name="robots" content="noindex">\n${list}\n`, { headers: { 'content-type': 'text/html; charset=utf-8', 'x-nitro-prerender': paths.join(',') } })
616
+ }
617
+
618
+ /** Everything under `/_weft/x/<key>/`, for the extension with that key. Nothing else reaches it. */
619
+ async function extensionRoute(url: URL, request: Request, scope: Scope) {
620
+ const [key = '', ...rest] = url.pathname.slice(EXTENSION.length).split('/')
621
+ const extension = extensions.get(key)
622
+ if (!extension?.handle) return new Response('Not found', { status: 404 })
623
+ return extension.handle(request, rest.join('/'), { invalidate: tags => exec.invalidate(scope, tags), report: d => options.onDiagnostic?.(d) })
624
+ }
625
+
626
+ const route = (request: Request, scope: Scope): Promise<Response> => {
627
+ const url = new URL(request.url)
628
+ if (options.auth && (url.pathname === authBase || url.pathname.startsWith(authBase + '/'))) return Promise.resolve(options.auth.handler(request))
629
+ if (url.pathname.startsWith('/_weft/fn/')) return fn(url, request, scope)
630
+ if (url.pathname.startsWith(EXTENSION)) return extensionRoute(url, request, scope)
631
+ if (request.method === 'POST' && !url.pathname.startsWith('/_weft/') && /^(application\/x-www-form-urlencoded|multipart\/form-data)/.test(request.headers.get('content-type') ?? '')) return form(url, request, scope)
632
+ if (url.pathname === '/_weft/rt') return rtRoute(request, scope, options.realtime?.origins)
633
+ if (url.pathname === '/_weft/ws') return socketRoute(request, scope, options.realtime?.origins)
634
+ if (request.method !== 'GET' && request.method !== 'HEAD') return Promise.resolve(new Response('Method not allowed', { status: 405 }))
635
+ if (url.pathname.startsWith('/_weft/m/')) return media(url, request)
636
+ if (url.pathname.startsWith('/_weft/og/') && url.pathname.endsWith('.png')) return og(url, request)
637
+ if (url.pathname.startsWith('/_weft/data/') || url.pathname === '/_weft/data') return data((url.pathname.slice('/_weft/data'.length) || '/') + url.search, request, scope)
638
+ if (url.pathname === '/_weft/og-routes') return prerenderList(request, url)
639
+ return page(url, request, scope)
640
+ }
641
+ const handler = async (incoming: Request): Promise<Response> => {
642
+ const request = options.auth ? stamped(incoming) : incoming
643
+ await loadExtensions()
644
+ if (!checked) {
645
+ checked = true
646
+ exec.checkTags(d => options.onDiagnostic?.(d))
647
+ }
648
+ const scope = exec.createScope(request, scopeOptions)
649
+ // The build's prerender only lists cards: it holds no secrets and may have no database.
650
+ if (new URL(request.url).pathname === '/_weft/og-routes') return als.run(scope, () => route(request, scope))
651
+ await boot()
652
+ if (options.liveRequired?.length) await hub().probed
653
+ if (hub().refused) return Response.json({ e: { code: 'R015', message: options.dev ? hub().refused!.message : 'Live data is unavailable.' } }, { status: 503, headers: { 'cache-control': 'no-store' } })
654
+ return compressed(request, await als.run(scope, () => route(request, scope)))
655
+ }
656
+ return Object.assign(handler, { resolve, inspect })
657
+ }
658
+
659
+ /** A complete page's text. Its Response holds no body until `compressed` gives it one: compressed, it's never read. */
660
+ const whole = new WeakMap<Response, string>()
661
+
662
+ type Encoding = 'br' | 'gzip'
663
+ type Zlib = typeof NodeZlib
664
+
665
+ let loaded: Promise<Zlib | undefined> | undefined
666
+ const loadZlib = () => (loaded ??= import('node:zlib').catch(() => undefined))
667
+ const brotli = (zlib: Zlib, sizeHint: number) => ({ params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 5, [zlib.constants.BROTLI_PARAM_SIZE_HINT]: sizeHint } })
668
+
669
+ const PACKED_MAX = 16 * 1024 * 1024 // characters of text held, per encoding
670
+ const PACKED_ENTRY_MAX = 1024 * 1024
671
+
672
+ /**
673
+ * Compressed bodies by their whole text, most recently used last. The text is the key, not a hash of it, so a response
674
+ * is only ever reused for the same bytes: two users can share an entry only if each was already being sent that page.
675
+ */
676
+ const packed: Record<Encoding, { entries: Map<string, Promise<Uint8Array>>; held: number }> = { br: { entries: new Map(), held: 0 }, gzip: { entries: new Map(), held: 0 } }
677
+
678
+ function compressWhole(zlib: Zlib, encoding: Encoding, text: string): Promise<Uint8Array> {
679
+ const cache = packed[encoding]
680
+ const hit = cache.entries.get(text)
681
+ if (hit) {
682
+ cache.entries.delete(text)
683
+ cache.entries.set(text, hit)
684
+ return hit
685
+ }
686
+ const out = new Promise<Uint8Array>((ok, no) => {
687
+ const done = (e: Error | null, b: Buffer) => (e ? no(e) : ok(b))
688
+ if (encoding === 'br') zlib.brotliCompress(text, brotli(zlib, Buffer.byteLength(text)), done)
689
+ else zlib.gzip(text, done)
690
+ })
691
+ if (text.length > PACKED_ENTRY_MAX) return out
692
+ cache.entries.set(text, out)
693
+ cache.held += text.length
694
+ for (const key of cache.entries.keys()) {
695
+ if (cache.held <= PACKED_MAX) break
696
+ cache.entries.delete(key)
697
+ cache.held -= key.length
698
+ }
699
+ return out
700
+ }
701
+
702
+ /** A stream of chunks, each flushed as it arrives so a Boundary reaches the client when it resolves. */
703
+ function compressStream(zlib: Zlib, encoding: Encoding, source: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
704
+ const z = encoding === 'br' ? zlib.createBrotliCompress(brotli(zlib, 0)) : zlib.createGzip()
705
+ const reader = source.getReader()
706
+ return new ReadableStream<Uint8Array>({
707
+ start(out) {
708
+ z.on('data', (d: Buffer) => out.enqueue(new Uint8Array(d)))
709
+ z.on('end', () => out.close())
710
+ z.on('error', e => out.error(e))
711
+ ;(async () => {
712
+ for (;;) {
713
+ const { done, value } = await reader.read()
714
+ if (done) return void z.end()
715
+ z.write(value)
716
+ await new Promise(ok => { z.flush(ok as () => void) })
717
+ }
718
+ })().catch(e => out.error(e))
719
+ },
720
+ cancel(reason) {
721
+ z.destroy()
722
+ return reader.cancel(reason)
723
+ },
724
+ })
725
+ }
726
+
727
+ /**
728
+ * HTML and data go out brotli or gzipped when the client takes it. A response that is complete is compressed once and
729
+ * kept, since most pages come out the same for every visitor; one still streaming is flushed chunk by chunk. A runtime
730
+ * with no node:zlib sits behind a platform that compresses.
731
+ */
732
+ async function compressed(request: Request, response: Response): Promise<Response> {
733
+ const page = whole.get(response)
734
+ const plain = () => (page === undefined ? response : new Response(page, response))
735
+ if (page === undefined && (!response.body || response.headers.has('content-encoding'))) return response
736
+ const type = response.headers.get('content-type') ?? ''
737
+ if (!/^(text\/html|application\/json)/.test(type)) return plain()
738
+ const accepts = request.headers.get('accept-encoding') ?? ''
739
+ const encoding = /\bbr\b/.test(accepts) ? 'br' : /\bgzip\b/.test(accepts) ? 'gzip' : undefined
740
+ const zlib = encoding && (await loadZlib())
741
+ if (!zlib) return plain()
742
+ const text = page ?? (type.startsWith('application/json') ? await response.text() : undefined)
743
+ const body = text === undefined ? compressStream(zlib, encoding, response.body!) : await compressWhole(zlib, encoding, text)
744
+ const headers = new Headers(response.headers)
745
+ headers.set('content-encoding', encoding)
746
+ headers.append('vary', 'Accept-Encoding')
747
+ headers.delete('content-length')
748
+ return new Response(body as BodyInit, { status: response.status, statusText: response.statusText, headers })
749
+ }
750
+
751
+ /** A page's stylesheets, in order: inline while they fit, then links. */
752
+ function sheets(assets: NonNullable<HandlerOptions['assets']>) {
753
+ let left = INLINE_CSS_MAX
754
+ return (assets.css ?? []).map(c => {
755
+ const css = assets.inline?.[c.href]
756
+ if (css === undefined || css.length > left) return `<link rel="stylesheet" href="${esc(c.href)}">\n`
757
+ left -= css.length
758
+ return `<style>${css}</style>\n<link rel="stylesheet" href="${esc(c.href)}" disabled>\n`
759
+ }).join('')
760
+ }
761
+
762
+ /**
763
+ * What each router-core loader gets as `context`: the request scope, where it leaves tags and redirects (router-core
764
+ * copies the context object, so those go in shared containers), and the matched page's catch-all name.
765
+ */
766
+ interface LoadContext { request: Request; scope: Scope; tags: Map<string, Set<string>>; out: { redirect?: Redirect; chain?: Promise<unknown> }; splat?: string | undefined }
767
+
768
+ interface TreeNode { route: AnyRoute; children: AnyRoute[]; dir: string }
769
+
770
+ /** The router-core tree for the manifest: layouts are path prefixes, routes hang under their innermost layout. */
771
+ function routeTree(manifest: Manifest, modules: Record<string, RouteModule>) {
772
+ const loader = (file: string, splat?: string) => async ({ params, context, abortController }: { params: Record<string, unknown>; context: LoadContext; abortController: AbortController }) => {
773
+ const load = modules[file]?.load
774
+ if (!load) return undefined
775
+ // Parent first (router-core starts them together): a layout's `load` is a guard, so a child never runs for a
776
+ // visitor its parent redirects (research 28 §3).
777
+ const out = context.out
778
+ const before = out.chain
779
+ let done!: () => void
780
+ out.chain = new Promise<void>(ok => { done = ok })
781
+ await before
782
+ if (out.redirect) return done(), undefined
783
+ // Each loader runs in its own frame of the request scope, which records the tags of the queries it read.
784
+ const f = exec.frame(context.scope)
785
+ context.tags.set(file, f.tags!)
786
+ try {
787
+ const ctx = await f.ctx()
788
+ const v = await als.run(f, () => load({ params: cleanParams(params, splat ?? context.splat), request: context.request, signal: abortController.signal, ctx }))
789
+ // oxlint-disable-next-line typescript/only-throw-error -- router-core's own not-found signal, passed on as it came
790
+ if (isNotFound(v)) throw v
791
+ return v
792
+ } catch (e) {
793
+ if (!isRedirect(e)) throw e
794
+ out.redirect ??= e
795
+ return undefined
796
+ } finally {
797
+ done()
798
+ }
799
+ }
800
+ const rootLayout = manifest.layouts.find(l => l.dir === '')
801
+ const root = new BaseRootRoute({ ...(rootLayout && { loader: loader(rootLayout.file), weft: rootLayout }) } as never)
802
+ const top: TreeNode = { route: root, children: [], dir: '' }
803
+ const nodes = new Map<string, TreeNode>()
804
+ const at = (id: string | undefined) => (id ? nodes.get(id) ?? top : top)
805
+ for (const l of manifest.layouts) {
806
+ if (l.dir === '') { nodes.set(l.id, top); continue }
807
+ const parent = at(l.parent)
808
+ const route = new BaseRoute({ getParentRoute: () => parent.route, path: toPath(l.dir, parent.dir), loader: loader(l.file), weft: l } as never)
809
+ parent.children.push(route)
810
+ nodes.set(l.id, { route, children: [], dir: l.dir })
811
+ }
812
+ for (const r of manifest.routes) {
813
+ if (r.kind !== 'page') continue
814
+ const parent = at(r.layouts.at(-1))
815
+ const rel = r.pattern.slice(1).split('/').slice(parent.dir ? parent.dir.split('/').length : 0).join('/')
816
+ parent.children.push(new BaseRoute({ getParentRoute: () => parent.route, path: rel || '/', loader: loader(r.file, r.splat), weft: r } as never))
817
+ }
818
+ // Innermost layouts first, so each route's children are attached before its parent takes it.
819
+ for (const n of [...new Set(nodes.values())].filter(x => x !== top).sort((a, b) => b.dir.length - a.dir.length)) if (n.children.length) n.route.addChildren(n.children)
820
+ return root.addChildren(top.children)
821
+ }
822
+
823
+ /** A layout dir relative to its parent layout's, as a router-core path: `users/[id]` → `users/$id`. */
824
+ function toPath(dir: string, parentDir: string) {
825
+ const rel = dir.split('/').slice(parentDir ? parentDir.split('/').length : 0)
826
+ return rel.map(s => s.replace(/^\[\.\.\.(\w+)\]$/, '$').replace(/^\[(\w+)\]$/, '$$$1')).join('/')
827
+ }
828
+
829
+ /** router-core's params without its splat aliases; a splat is under its file name (`[...rest]` → `rest`). */
830
+ function cleanParams(p: Record<string, unknown> = {}, splat?: string): Record<string, string> {
831
+ const out: Record<string, string> = {}
832
+ for (const k in p) if (k !== '*' && k !== '_splat') out[k] = String(p[k])
833
+ if (splat && p._splat !== undefined) out[splat] = p._splat as string
834
+ return out
835
+ }
836
+
837
+ function fill(pattern: string, params: Record<string, string>, splat?: string) {
838
+ return pattern.replace(/\$([\w$]*)/g, (_, n) => (n ? encodeURIComponent(params[n]!) : params[splat!] ?? ''))
839
+ }
840
+
841
+ /** The data without its deferred (promise) fields, and their names. */
842
+ function split(raw: unknown): { data: unknown; d?: string[] } {
843
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { data: raw }
844
+ const fields = raw as Record<string, unknown>
845
+ const d = Object.keys(raw).filter(k => thenable(fields[k]))
846
+ if (!d.length) return { data: raw }
847
+ for (const k of d) Promise.resolve(fields[k]).catch(() => {}) // a deferred failure surfaces in its Boundary
848
+ return { data: Object.fromEntries(Object.entries(raw).filter(([k]) => !d.includes(k))), d }
849
+ }
850
+
851
+ /** What `meta` and cards see: the settled data. Reading a deferred field is H007 (it's still streaming). */
852
+ function settledView(raw: unknown, deferred: string[] | undefined, id: string, options: HandlerOptions) {
853
+ if (!deferred?.length) return raw
854
+ return new Proxy(raw as object, {
855
+ get(t, k, r) {
856
+ if (typeof k === 'string' && deferred.includes(k)) {
857
+ 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\`.`
858
+ options.onDiagnostic?.({ code: 'H007', severity: 'error', message, route: id })
859
+ throw Object.assign(new Error(message), { code: 'H007' })
860
+ }
861
+ return Reflect.get(t, k, r)
862
+ },
863
+ })
864
+ }
865
+
866
+ function payloadOf(r: Resolved, withHead: boolean): RoutePayload {
867
+ const { jsonLd: _, ...head } = r.head // only a first response carries it (`metaTags`)
868
+ return { m: r.levels.map(({ id, params, data, d, t }) => ({ id, params, data, ...(d && { d }), ...(t && { t }) })), p: r.path, s: r.status, ...(withHead && { h: head }) }
869
+ }
870
+
871
+ /**
872
+ * The payload with its static levels marked: their hash, without their data (the HTML holds it), and with `html` their
873
+ * HTML, each level's next one cut down to its markers. The `aria-current` a navigation moves doesn't count as a change.
874
+ */
875
+ /**
876
+ * The chunks `payload`'s page will want: its non-static levels and the islands that load with it, and `fold`, the lazy
877
+ * ones in the first view. Those stay preloaded, fetched but not run, so a first click on them doesn't wait; the rest
878
+ * load when they're needed. `$client`: the router and runtime, whenever any of that will run.
879
+ */
880
+ const pageChunks = (payload: RoutePayload, chunks: HandlerOptions['chunks'] = {}, fold: string[] = [], now = true) => {
881
+ const ids = [...payload.m.filter(m => !m.s).map(m => m.id), ...(payload.i ?? []), ...fold]
882
+ return [...(now || ids.length ? ['$client'] : []), ...ids].flatMap(id => chunks[id]?.js ?? [])
883
+ }
884
+
885
+ /**
886
+ * The route's stylesheets and those of every island on the page. An island is a dynamic import, so its CSS is not in the
887
+ * route's chunk graph; without this the server's HTML paints bare and the sheets arrive with the script.
888
+ */
889
+ const pageSheets = (css: { href: string }[] = [], parts: { now: string[]; lazy: string[]; fold: string[] }, chunks: HandlerOptions['chunks'] = {}) => {
890
+ const found = [...parts.now, ...parts.lazy, ...parts.fold].flatMap(id => chunks[id]?.css ?? [])
891
+ return [...new Map([...css, ...found].map(c => [c.href, c])).values()]
892
+ }
893
+
894
+ const saveData = (request: Request) => request.headers.get('save-data') === 'on' || request.headers.get('sec-ch-prefers-reduced-data') === 'reduce'
895
+
896
+ /** Where the first view ends, in px: past most laptops' and phones' first screen. */
897
+ const FOLD = 900
898
+
899
+ /**
900
+ * About how far down the page `html` runs out, in px. The server has no layout, so this counts what makes a page tall:
901
+ * blocks, and lines of their text. A wrong guess costs one chunk's fetch, either way: it's a preload, not a decision.
902
+ */
903
+ function depth(html: string) {
904
+ const plain = html.replace(/<(svg|style|template)\b[\s\S]*?<\/\1>|<!--[\s\S]*?-->/g, '')
905
+ const blocks = plain.match(/<(?:section|article|header|footer|h[1-6]|p|li|tr|img|form|pre|blockquote|video|figure)\b/g)?.length ?? 0
906
+ return blocks * 32 + (plain.replace(/<[^>]*>/g, '').length / 60) * 24
907
+ }
908
+
909
+ /** What a page's HTML says about its islands: those that load with it, those that wait, and the waiting ones in view. */
910
+ function islandsOf(html: string) {
911
+ const known = renderedOf(html)
912
+ if (known.p) return known.p
913
+ const parts = { now: [] as string[], lazy: [] as string[], fold: [] as string[] }
914
+ let past = false
915
+ for (const m of html.matchAll(/<!--wi:(\[.*?\])-->/g)) {
916
+ const [id, , , when] = JSON.parse(m[1]!) as [string, string, unknown[], string?]
917
+ const list = when ? parts.lazy : parts.now
918
+ if (!list.includes(id)) list.push(id)
919
+ past ||= depth(html.slice(0, m.index)) >= FOLD
920
+ if (when && !past && !parts.fold.includes(id)) parts.fold.push(id)
921
+ }
922
+ return (known.p = parts)
923
+ }
924
+
925
+ function modulepreloads(chunks: { href: string }[], assets: NonNullable<HandlerOptions['assets']>) {
926
+ const hrefs = new Set(chunks.map(c => c.href))
927
+ hrefs.delete(assets.entry!)
928
+ return [...hrefs].map(href => `<link rel="modulepreload" href="${esc(href)}">\n`).join('')
929
+ }
930
+
931
+ /** What a render's HTML says about its levels, worked out as asked and kept by the exact text. */
932
+ interface Rendered { h: Record<number, string>; x: Record<number, string>; p?: { now: string[]; lazy: string[]; fold: string[] } }
933
+ const RENDERED_MAX = 64
934
+ const rendered = new Map<string, Rendered>()
935
+
936
+ function renderedOf(html: string) {
937
+ let r = rendered.get(html)
938
+ if (!r) {
939
+ if (rendered.size >= RENDERED_MAX) rendered.clear()
940
+ rendered.set(html, (r = { h: {}, x: {} }))
941
+ }
942
+ return r
943
+ }
944
+
945
+ function withLevels(payload: RoutePayload, html: string, statics: Set<string>, sendHtml: boolean): RoutePayload {
946
+ if (!payload.m.some(m => statics.has(m.id))) return payload
947
+ const known = renderedOf(html)
948
+ const slice = (d: number) => {
949
+ const open = html.indexOf(`<!--wl:${d}:`)
950
+ const end = `<!--/wl:${d}-->`
951
+ const close = html.indexOf(end)
952
+ if (open < 0 || close < 0) return ''
953
+ const body = html.slice(html.indexOf('-->', open) + 3, close)
954
+ const kid = body.indexOf(`<!--wl:${d + 1}:`)
955
+ const tail = body.indexOf(`<!--/wl:${d + 1}-->`)
956
+ return kid < 0 || tail < 0 ? body : body.slice(0, body.indexOf('-->', kid) + 3) + body.slice(tail)
957
+ }
958
+ const m = payload.m.map((lv, d) => {
959
+ if (!statics.has(lv.id)) return lv
960
+ const { data: _, ...rest } = lv
961
+ const h = (known.h[d] ??= slice(d))
962
+ return { ...rest, s: 1 as const, x: (known.x[d] ??= hash(h.replaceAll(' aria-current="page"', ''))), ...(sendHtml && { h }) }
963
+ })
964
+ const i = islandsOf(html).now
965
+ return { ...payload, m, ...(i.length && { i }) }
966
+ }
967
+
968
+ /** The request's resolved queries, as the client cache seeds them (failures are left for the client to retry). */
969
+ function wire(scope: Scope): Wire[] {
970
+ return [...scope.entries.values()].filter(e => e.done && e.e === undefined).map(e => [e.id, e.input, e.v, e.tags])
971
+ }
972
+
973
+ /** The request with the socket's address in CLIENT_IP, replacing any the client sent. Nitro's server sets `.ip`. */
974
+ function stamped(request: Request): Request {
975
+ const headers = new Headers(request.headers)
976
+ const { ip } = request as Request & { ip?: string }
977
+ headers.delete(CLIENT_IP)
978
+ if (ip) headers.set(CLIENT_IP, ip)
979
+ // `new Request(request, …)` refuses the server's request wrapper; the parts are enough.
980
+ return new Request(request.url, { method: request.method, headers, ...(request.body && { body: request.body, duplex: 'half' }) })
981
+ }
982
+
983
+ /** The request's cookies for `persist: 'cookie'`, parsed on first read: a page with none pays nothing. */
984
+ function cookieJar(request: Request) {
985
+ let jar: Map<string, string> | undefined
986
+ return {
987
+ get(name: string) {
988
+ jar ??= new Map((request.headers.get('cookie') ?? '').split(/;\s*/).map(c => [c.slice(0, c.indexOf('=')), c.slice(c.indexOf('=') + 1)]))
989
+ return jar.get(name)
990
+ },
991
+ }
992
+ }
993
+
994
+ const refresh = (v: FormDataEntryValue | null) => (typeof v === 'string' ? v : '[]')
995
+
996
+ /** The multipart body `<Form>` sends when a form holds a file: the fields, and the refresh list in `$r`. */
997
+ async function multipartBody(request: Request) {
998
+ const data = await request.formData()
999
+ return { i: decodeForm(data), f: JSON.parse(refresh(data.get('$r'))) as [string, unknown][], c: 1 as const }
1000
+ }
1001
+
1002
+ /** What a failed form's page gives back: no secrets (the HTML outlives the request) and no files. */
1003
+ function echoed(input: Record<string, unknown>): Record<string, unknown> {
1004
+ const keep = (v: unknown): unknown => (typeof v === 'string' ? v : Array.isArray(v) ? v.filter(x => typeof x === 'string') : v && typeof v === 'object' && !(v instanceof Blob) ? echoed(v as Record<string, unknown>) : undefined)
1005
+ return Object.fromEntries(Object.entries(input).filter(([k]) => !/pass|secret|token|card|cvv|cvc/i.test(k)).map(([k, v]) => [k, keep(v)]))
1006
+ }
1007
+
1008
+ /** The cookies and headers server functions set (`ctx.response.headers`), on the response. */
1009
+ function withHeaders(res: Response, scope: Scope): Response {
1010
+ for (const [k, v] of scope.headers) res.headers.append(k, v)
1011
+ return res
1012
+ }
1013
+
1014
+ export { headTags }