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