@weft-js/start 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +38 -8
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +90 -166
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +112 -37
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -178
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -971
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
package/src/rust.ts
CHANGED
|
@@ -13,13 +13,28 @@
|
|
|
13
13
|
// `WEFT_RUST_SECRET` for the sidecars they supervise, and a sidecar deployed on its own needs both set by hand.
|
|
14
14
|
// Nothing here imports a Node module, so none of it reaches a bundle that doesn't call it.
|
|
15
15
|
|
|
16
|
-
import {
|
|
16
|
+
import { StartError, type Context, type Fn } from './data'
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_TIMEOUT,
|
|
19
|
+
callContext,
|
|
20
|
+
defaultClaims,
|
|
21
|
+
encoder,
|
|
22
|
+
env,
|
|
23
|
+
settled,
|
|
24
|
+
wire,
|
|
25
|
+
} from './rust/common'
|
|
26
|
+
import { createNative } from './rust/native'
|
|
17
27
|
|
|
18
28
|
/** Whoever `ctx.user` is in this app, once it declares `Context`. */
|
|
19
29
|
type User = Context extends { user: infer U } ? U | null : unknown
|
|
20
30
|
|
|
21
31
|
/** What a sidecar may know of the user. Sent signed; a sidecar never sees a cookie or a session token. */
|
|
22
|
-
export interface Claims {
|
|
32
|
+
export interface Claims {
|
|
33
|
+
id: string
|
|
34
|
+
roles?: string[]
|
|
35
|
+
tenant?: string | null
|
|
36
|
+
[key: string]: unknown
|
|
37
|
+
}
|
|
23
38
|
|
|
24
39
|
/** `remote()`: the address, the deadline, and how a user becomes claims. */
|
|
25
40
|
export interface RemoteOptions {
|
|
@@ -34,9 +49,18 @@ export interface RemoteOptions {
|
|
|
34
49
|
}
|
|
35
50
|
|
|
36
51
|
/** One function call on its way to a sidecar. */
|
|
37
|
-
export interface RemoteCall {
|
|
52
|
+
export interface RemoteCall {
|
|
53
|
+
fn: Fn
|
|
54
|
+
input: unknown
|
|
55
|
+
ctx: Context
|
|
56
|
+
request: Request
|
|
57
|
+
}
|
|
38
58
|
/** What a sidecar answered: the value, and the tags it says to refresh. */
|
|
39
|
-
export interface RemoteResult {
|
|
59
|
+
export interface RemoteResult {
|
|
60
|
+
value: unknown
|
|
61
|
+
tags: string[]
|
|
62
|
+
touched: string[]
|
|
63
|
+
}
|
|
40
64
|
|
|
41
65
|
/** What `remote()` returns: pass it as a function's `impl`. */
|
|
42
66
|
export interface Remote {
|
|
@@ -67,88 +91,43 @@ export interface Native {
|
|
|
67
91
|
}
|
|
68
92
|
|
|
69
93
|
const TARGET = /^rust:\/\/([a-z][a-z0-9_-]*)$/
|
|
70
|
-
const NAME = /^[a-z][a-z0-9_-]*$/
|
|
71
|
-
const DEFAULT_TIMEOUT = 10_000
|
|
72
|
-
const encoder = new TextEncoder()
|
|
73
|
-
|
|
74
|
-
// An app's tsc may have no Node types, so what's read of `process` is named here.
|
|
75
|
-
const nodeHost = globalThis as unknown as { process?: { env: Record<string, string | undefined>; platform: string; arch: string; getBuiltinModule?(name: string): unknown; dlopen(mod: object, file: string): void } }
|
|
76
|
-
const env = (name: string): string | undefined => nodeHost.process?.env?.[name] || undefined
|
|
77
94
|
/** `search-index` → `WEFT_RUST_SEARCH_INDEX_URL`. */
|
|
78
|
-
export const urlVariable = (service: string) =>
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
95
|
+
export const urlVariable = (service: string) =>
|
|
96
|
+
`WEFT_RUST_${service.toUpperCase().replaceAll('-', '_')}_URL`
|
|
97
|
+
|
|
98
|
+
const hex = (b: ArrayBuffer) =>
|
|
99
|
+
Array.from(new Uint8Array(b), (x) => x.toString(16).padStart(2, '0')).join('')
|
|
100
|
+
const base64url = (s: string) =>
|
|
101
|
+
btoa(String.fromCharCode(...encoder.encode(s)))
|
|
102
|
+
.replaceAll('+', '-')
|
|
103
|
+
.replaceAll('/', '_')
|
|
104
|
+
.replace(/=+$/, '')
|
|
84
105
|
const keys = new Map<string, Promise<CryptoKey>>()
|
|
85
106
|
const keyOf = (secret: string) => {
|
|
86
107
|
let k = keys.get(secret)
|
|
87
|
-
if (!k)
|
|
108
|
+
if (!k)
|
|
109
|
+
keys.set(
|
|
110
|
+
secret,
|
|
111
|
+
(k = crypto.subtle.importKey(
|
|
112
|
+
'raw',
|
|
113
|
+
encoder.encode(secret),
|
|
114
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
115
|
+
false,
|
|
116
|
+
['sign'],
|
|
117
|
+
)),
|
|
118
|
+
)
|
|
88
119
|
return k
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
/** `weft-sig`: the crate's `verify` checks the same bytes. It covers the function and the body, not just the claims. */
|
|
92
123
|
export const sign = async (secret: string, id: string, ctx: string, body: string) =>
|
|
93
|
-
hex(
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const TRACEPARENT = /^00-(?!0{32})[0-9a-f]{32}-(?!0{16})[0-9a-f]{16}-[0-9a-f]{2}$/
|
|
101
|
-
|
|
102
|
-
/** The caller's trace with a new span, or a new trace. */
|
|
103
|
-
function traceparent(request: Request): string {
|
|
104
|
-
const inbound = request.headers.get('traceparent')
|
|
105
|
-
const trace = inbound && TRACEPARENT.test(inbound) ? inbound.split('-')[1] : randomHex(16)
|
|
106
|
-
return `00-${trace}-${randomHex(8)}-01`
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** The value as a sidecar reads it: what the schema parsed, encoded back to the wire shape (codecs, defaults filled). */
|
|
110
|
-
function wire(fn: Fn, input: unknown): unknown {
|
|
111
|
-
const schema = fn.def.input as { encodeAsync?: (value: unknown) => Promise<unknown> } | undefined
|
|
112
|
-
if (typeof schema?.encodeAsync === 'function') return schema.encodeAsync(input)
|
|
113
|
-
return input
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
async function checkedOutput(fn: Fn, value: unknown, validate: boolean): Promise<unknown> {
|
|
117
|
-
const schema = fn.def.output
|
|
118
|
-
if (!schema || !validate) return value
|
|
119
|
-
const r = await schema['~standard'].validate(value)
|
|
120
|
-
if (!r.issues) return r.value
|
|
121
|
-
const issues = r.issues.map(i => `${i.path?.map(p => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${i.message}`).join('; ')
|
|
122
|
-
throw new StartError('R103', `\`${fn.$id}\`'s sidecar returned a value its \`output\` schema rejects: ${issues}`, 'the sidecar was built from another contract, or sets a field the schema refuses: rebuild it, or fix the schema', { status: 502 })
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** A declared error: its data checked like any output, with the status a client can act on. */
|
|
126
|
-
async function declared(fn: Fn, error: { code: string; data?: unknown }): Promise<never> {
|
|
127
|
-
const schema = fn.def.errors?.[error.code]
|
|
128
|
-
if (!schema) throw new StartError('R103', `\`${fn.$id}\`'s sidecar answered with the undeclared error \`${error.code}\``, 'declare it in `errors`, or rebuild the sidecar from the current contract', { status: 502 })
|
|
129
|
-
const r = await schema['~standard'].validate(error.data ?? null)
|
|
130
|
-
if (r.issues) throw new StartError('R103', `\`${fn.$id}\`'s sidecar sent \`${error.code}\` with data its schema rejects`, 'rebuild the sidecar from the current contract', { status: 502 })
|
|
131
|
-
raise(error.code, r.value)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** A shared value goes by no one's claims: the crate's `SharedCtx` has no user to read, and neither has the wire. */
|
|
135
|
-
function callContext(fn: Fn, ctx: Context, request: Request, timeout: number, claims: (user: User) => Claims | null) {
|
|
136
|
-
const shared = fn.def.cache && fn.def.cache.scope !== 'user'
|
|
137
|
-
return { user: shared ? null : claims((ctx as { user?: User }).user ?? null) ?? null, requestId: randomHex(8), deadlineMs: timeout, traceparent: traceparent(request) }
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** What a reply comes to: the same for a sidecar's body and an addon's string. */
|
|
141
|
-
async function settled(fn: Fn, reply: Reply | undefined, target: string, timeout: number, validate: boolean): Promise<RemoteResult> {
|
|
142
|
-
if (reply?.ok === true) return { value: await checkedOutput(fn, reply.value, validate), tags: reply.tags ?? [], touched: reply.touched ?? [] }
|
|
143
|
-
const error = reply?.error
|
|
144
|
-
if (error?.code === 'Refused') throw new StartError('R106', `\`${target}\` refused \`${fn.$id}\`'s signature`, '`WEFT_RUST_SECRET` must be the same on both sides, and the clocks within a minute', { status: 503 })
|
|
145
|
-
if (error?.code === 'Deadline') throw new StartError('R107', `\`${fn.$id}\` ran past its ${timeout} ms`, 'raise `timeout`, or make the Rust answer sooner', { status: 504 })
|
|
146
|
-
if (error?.code === 'Drift') throw new StartError('R103', `\`${fn.$id}\` doesn't match \`${target}\`: ${error.message}`, 'rebuild it from the current contract', { status: 502 })
|
|
147
|
-
if (error?.code && error.code !== 'Internal') await declared(fn, { code: error.code, data: error.data })
|
|
148
|
-
// The detail stays in a log, under this id: a sidecar's own, or this server's for what ran in this process.
|
|
149
|
-
if (error?.detail) console.error(`[weft] \`${fn.$id}\` failed (request ${error.requestId}): ${error.detail}`)
|
|
150
|
-
throw new StartError('R108', `\`${fn.$id}\` failed in \`${target}\` (request ${error?.requestId ?? 'unknown'})`, 'find the request id in the server\'s or the sidecar\'s log', { status: 500 })
|
|
151
|
-
}
|
|
124
|
+
hex(
|
|
125
|
+
await crypto.subtle.sign(
|
|
126
|
+
'HMAC',
|
|
127
|
+
await keyOf(secret),
|
|
128
|
+
encoder.encode(`weft1\n${id}\n${ctx}\n${body}`),
|
|
129
|
+
),
|
|
130
|
+
)
|
|
152
131
|
|
|
153
132
|
/**
|
|
154
133
|
* A Rust sidecar implementing the functions that point at it, as `impl: search`. `run` and `impl` are one or the other:
|
|
@@ -156,178 +135,73 @@ async function settled(fn: Fn, reply: Reply | undefined, target: string, timeout
|
|
|
156
135
|
*/
|
|
157
136
|
export function remote(target: string, options: RemoteOptions = {}): Remote {
|
|
158
137
|
const service = TARGET.exec(target)?.[1]
|
|
159
|
-
if (!service)
|
|
138
|
+
if (!service)
|
|
139
|
+
throw new StartError(
|
|
140
|
+
'R106',
|
|
141
|
+
`\`remote('${target}')\` isn't a sidecar target`,
|
|
142
|
+
"`remote('rust://search')`, with the address in `WEFT_RUST_SEARCH_URL` or the `url` option",
|
|
143
|
+
)
|
|
160
144
|
const { timeout = DEFAULT_TIMEOUT, claims = defaultClaims, validate = true } = options
|
|
161
145
|
return {
|
|
162
|
-
kind: 'remote',
|
|
146
|
+
kind: 'remote',
|
|
147
|
+
target,
|
|
148
|
+
service,
|
|
163
149
|
async call({ fn, input, ctx, request }) {
|
|
164
150
|
const url = options.url ?? env(urlVariable(service))
|
|
165
|
-
if (!url)
|
|
151
|
+
if (!url)
|
|
152
|
+
throw new StartError(
|
|
153
|
+
'R106',
|
|
154
|
+
`\`${fn.$id}\` is implemented by \`${target}\`, and this server has no address for it`,
|
|
155
|
+
`set \`${urlVariable(service)}\` (\`weft dev\` and \`weft start\` do it for a sidecar they supervise), or pass \`url\` to \`remote()\``,
|
|
156
|
+
{ status: 503 },
|
|
157
|
+
)
|
|
166
158
|
const secret = env('WEFT_RUST_SECRET')
|
|
167
|
-
if (!secret)
|
|
168
|
-
|
|
169
|
-
|
|
159
|
+
if (!secret)
|
|
160
|
+
throw new StartError(
|
|
161
|
+
'R106',
|
|
162
|
+
`\`${fn.$id}\` can't sign its call: \`WEFT_RUST_SECRET\` isn't set`,
|
|
163
|
+
'set the same value on the server and the sidecar',
|
|
164
|
+
{ status: 503 },
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
const envelope = base64url(
|
|
168
|
+
JSON.stringify({ ...callContext(fn, ctx, request, timeout, claims), iat: Date.now() }),
|
|
169
|
+
)
|
|
170
170
|
const body = JSON.stringify({ input: (await wire(fn, input)) ?? null })
|
|
171
171
|
|
|
172
172
|
let res: Response
|
|
173
173
|
try {
|
|
174
174
|
res = await fetch(`${url.replace(/\/$/, '')}/_weft/fn/${encodeURIComponent(fn.$id)}`, {
|
|
175
175
|
method: 'POST',
|
|
176
|
-
headers: {
|
|
176
|
+
headers: {
|
|
177
|
+
'content-type': 'application/json',
|
|
178
|
+
'weft-ctx': envelope,
|
|
179
|
+
'weft-sig': await sign(secret, fn.$id, envelope, body),
|
|
180
|
+
},
|
|
177
181
|
body,
|
|
178
182
|
signal: AbortSignal.any([AbortSignal.timeout(timeout), request.signal]),
|
|
179
183
|
})
|
|
180
184
|
} catch (e) {
|
|
181
|
-
if ((e as Error | undefined)?.name === 'TimeoutError')
|
|
182
|
-
|
|
185
|
+
if ((e as Error | undefined)?.name === 'TimeoutError')
|
|
186
|
+
throw new StartError(
|
|
187
|
+
'R107',
|
|
188
|
+
`\`${fn.$id}\` took longer than its ${timeout} ms`,
|
|
189
|
+
'raise `timeout` on `remote()`, or make the sidecar answer sooner',
|
|
190
|
+
{ status: 504 },
|
|
191
|
+
)
|
|
192
|
+
throw new StartError(
|
|
193
|
+
'R107',
|
|
194
|
+
`\`${fn.$id}\`: \`${target}\` didn't answer`,
|
|
195
|
+
'check that the sidecar is running. Nothing else on this server is affected',
|
|
196
|
+
{ status: 503 },
|
|
197
|
+
)
|
|
183
198
|
}
|
|
184
199
|
return settled(fn, await res.json().catch(() => undefined), target, timeout, validate)
|
|
185
200
|
},
|
|
186
201
|
}
|
|
187
202
|
}
|
|
188
203
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
/** The exports a `.wasm` built by the crate has. */
|
|
192
|
-
interface Wasm { memory: WebAssembly.Memory; weft_init: () => void; weft_free: (ptr: number, len: number) => void; weft_alloc: (len: number) => number; weft_contract: () => bigint; weft_call: (i: number, il: number, c: number, cl: number, b: number, bl: number) => bigint }
|
|
193
|
-
interface Reply { ok?: boolean; value?: unknown; tags?: string[]; touched?: string[]; error?: { code?: string; message?: string; detail?: string; requestId?: string; data?: unknown } }
|
|
194
|
-
interface Engine { kind: 'addon' | 'wasm'; call(id: string, ctx: string, body: string): Promise<string> | string }
|
|
195
|
-
interface Entry { kind?: string; hash: string; addon?: string; wasm?: string; failure?: string }
|
|
196
|
-
|
|
197
|
-
interface ReadFile { (file: string, encoding: 'utf8'): string; (file: string): Uint8Array<ArrayBuffer> }
|
|
198
|
-
interface Builtins { 'node:fs': { existsSync(file: string): boolean; readFileSync: ReadFile }; 'node:path': { dirname(path: string): string; join(...parts: string[]): string }; 'node:url': { fileURLToPath(url: string): string } }
|
|
199
|
-
const builtin = <K extends keyof Builtins>(name: K) => nodeHost.process?.getBuiltinModule?.(name) as Builtins[K] | undefined
|
|
200
|
-
const platform = () => { const p = nodeHost.process; return `${p?.platform}-${p?.arch}` }
|
|
201
|
-
|
|
202
|
-
/** `.output/server/rust/` from wherever the bundle put this code. `WEFT_NATIVE_DIR` is `weft dev`'s, and `weft build`'s. */
|
|
203
|
-
function nativeDir(): string | undefined {
|
|
204
|
-
const fs = builtin('node:fs'), path = builtin('node:path')!, url = builtin('node:url')!
|
|
205
|
-
if (!fs) return undefined
|
|
206
|
-
const set = env('WEFT_NATIVE_DIR')
|
|
207
|
-
if (set) return set
|
|
208
|
-
let dir = path.dirname(url.fileURLToPath(import.meta.url))
|
|
209
|
-
for (let i = 0; i < 6; i++, dir = path.dirname(dir)) if (fs.existsSync(path.join(dir, 'rust', 'manifest.json'))) return path.join(dir, 'rust')
|
|
210
|
-
return undefined
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function addonEngine(file: string, hash: string, target: string): Engine {
|
|
214
|
-
const mod = { exports: {} as { contract: () => string; call: Engine['call'] } }
|
|
215
|
-
nodeHost.process!.dlopen(mod, file)
|
|
216
|
-
const built = JSON.parse(mod.exports.contract())
|
|
217
|
-
if (built.hash !== hash) throw new StartError('R103', `\`${target}\`'s library was built from contract ${built.hash}, and this app's is ${hash}`, 'rebuild it: `weft build`', { status: 502 })
|
|
218
|
-
return { kind: 'addon', call: mod.exports.call }
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/**
|
|
222
|
-
* The same functions as a `.wasm`, on this thread. A panic traps, which ends an instance: what it said arrives first,
|
|
223
|
-
* and the next call gets a new one, as a sidecar's crash gets a restart.
|
|
224
|
-
*/
|
|
225
|
-
async function wasmEngine(file: string, hash: string, target: string): Promise<Engine> {
|
|
226
|
-
const module = await WebAssembly.compile(builtin('node:fs')!.readFileSync(file))
|
|
227
|
-
const decoder = new TextDecoder()
|
|
228
|
-
let said = ''
|
|
229
|
-
let x: Wasm | undefined
|
|
230
|
-
const text = (ptr: number, len: number) => decoder.decode(new Uint8Array(x!.memory.buffer, ptr, len))
|
|
231
|
-
const fresh = () => {
|
|
232
|
-
x = new WebAssembly.Instance(module, { weft: { now: () => performance.now(), panicked: (ptr: number, len: number) => { said = text(ptr, len) } } }).exports as unknown as Wasm
|
|
233
|
-
x.weft_init()
|
|
234
|
-
}
|
|
235
|
-
const taken = (packed: bigint) => {
|
|
236
|
-
const ptr = Number(packed >> 32n), len = Number(packed & 0xffffffffn)
|
|
237
|
-
const out = text(ptr, len)
|
|
238
|
-
x!.weft_free(ptr, len)
|
|
239
|
-
return out
|
|
240
|
-
}
|
|
241
|
-
const put = (s: string): [number, number] => {
|
|
242
|
-
const bytes = encoder.encode(s)
|
|
243
|
-
const ptr = x!.weft_alloc(bytes.length)
|
|
244
|
-
new Uint8Array(x!.memory.buffer, ptr, bytes.length).set(bytes)
|
|
245
|
-
return [ptr, bytes.length]
|
|
246
|
-
}
|
|
247
|
-
fresh()
|
|
248
|
-
const built = JSON.parse(taken(x!.weft_contract()))
|
|
249
|
-
if (built.hash !== hash) throw new StartError('R103', `\`${target}\`'s library was built from contract ${built.hash}, and this app's is ${hash}`, 'rebuild it: `weft build`', { status: 502 })
|
|
250
|
-
return {
|
|
251
|
-
kind: 'wasm',
|
|
252
|
-
call(id, ctx, body) {
|
|
253
|
-
if (!x) fresh()
|
|
254
|
-
const [i, il] = put(id), [c, cl] = put(ctx), [b, bl] = put(body)
|
|
255
|
-
try {
|
|
256
|
-
const reply = taken(x!.weft_call(i, il, c, cl, b, bl))
|
|
257
|
-
x!.weft_free(i, il)
|
|
258
|
-
x!.weft_free(c, cl)
|
|
259
|
-
return reply
|
|
260
|
-
} catch (e) {
|
|
261
|
-
if (!(e instanceof WebAssembly.RuntimeError)) throw e
|
|
262
|
-
x = undefined
|
|
263
|
-
return JSON.stringify({ ok: false, error: { code: 'Internal', requestId: JSON.parse(ctx).requestId, detail: said || e.message } })
|
|
264
|
-
}
|
|
265
|
-
},
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/**
|
|
270
|
-
* A Rust library implementing the functions that point at it, in this process, as `impl: media`. An addon runs each call
|
|
271
|
-
* on the worker pool, so the event loop stays free; where one can't load, its `.wasm` runs them on this thread. Either
|
|
272
|
-
* way the declaration, the client and the caches are the same as for `run`.
|
|
273
|
-
*/
|
|
204
|
+
/** A Rust library implementing functions in this process. */
|
|
274
205
|
export function native(name: string, options: NativeOptions = {}): Native {
|
|
275
|
-
|
|
276
|
-
const { timeout = DEFAULT_TIMEOUT, claims = defaultClaims, validate = true } = options
|
|
277
|
-
const target = `native('${name}')`
|
|
278
|
-
// Before the pool's first use: its default of 4 is a ceiling on calls at once that an app would never think to raise.
|
|
279
|
-
const proc = nodeHost.process
|
|
280
|
-
if (proc?.env && !proc.env.UV_THREADPOOL_SIZE) proc.env.UV_THREADPOOL_SIZE = String(Math.max(4, globalThis.navigator?.hardwareConcurrency ?? 4))
|
|
281
|
-
|
|
282
|
-
let loaded: Promise<Engine> | undefined, stamp: string | undefined
|
|
283
|
-
|
|
284
|
-
async function load(dir: string, entry: Entry): Promise<Engine> {
|
|
285
|
-
const fs = builtin('node:fs')!, path = builtin('node:path')!
|
|
286
|
-
const addon = entry.addon && path.join(dir, entry.addon)
|
|
287
|
-
if (addon && fs.existsSync(addon)) {
|
|
288
|
-
try {
|
|
289
|
-
return addonEngine(addon, entry.hash, target)
|
|
290
|
-
} catch (e) {
|
|
291
|
-
if (e instanceof StartError) throw e
|
|
292
|
-
console.warn(`[weft] ${target}: the addon didn't load here (${String((e as { message?: unknown }).message).split('\n')[0]})${entry.wasm ? ': using its WASM' : ''}`)
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
if (entry.wasm) return wasmEngine(path.join(dir, entry.wasm), entry.hash, target)
|
|
296
|
-
throw new StartError('R104', `\`${target}\` has no addon for ${platform()} and no WASM`, 'build on this platform, drop `wasm: false` from `rust.services`, or use `remote()`', { status: 503 })
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** The engine for now. Under `weft dev` a rebuild is a new file: the next call loads it, and calls on the old one finish. */
|
|
300
|
-
function engine(): Promise<Engine> {
|
|
301
|
-
const fs = builtin('node:fs')!, path = builtin('node:path')!
|
|
302
|
-
const dir = nativeDir()
|
|
303
|
-
if (!dir) throw new StartError('R104', `\`${target}\` has nothing to load here`, 'this runtime has no file system, or the build has no `rust/` folder: use `remote()`, or run `weft build`', { status: 503 })
|
|
304
|
-
const file = path.join(dir, 'manifest.json')
|
|
305
|
-
const watching = !!env('WEFT_NATIVE_DIR')
|
|
306
|
-
// Its text, not its mtime: two writes can land in one tick of a coarse clock.
|
|
307
|
-
const text: string | undefined = watching && fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : undefined
|
|
308
|
-
if (loaded && text === stamp) return loaded
|
|
309
|
-
const entry: Entry | undefined = fs.existsSync(file) ? JSON.parse(text ?? fs.readFileSync(file, 'utf8'))[name] : undefined
|
|
310
|
-
if (!entry) throw new StartError('R104', `\`${target}\` isn't in this build`, 'run `weft build`, or check `crates/' + name + '`', { status: 503 })
|
|
311
|
-
if (entry.failure) throw new StartError('R103', `\`${target}\` didn't build: ${entry.failure}`, 'fix the crate: `weft dev` retries on every edit', { status: 503 })
|
|
312
|
-
stamp = text
|
|
313
|
-
const held = loaded
|
|
314
|
-
const next = load(dir, entry)
|
|
315
|
-
// A bad build keeps what was serving, as a sidecar's does.
|
|
316
|
-
loaded = next.catch(e => { if (held) { loaded = held; return held } loaded = undefined; throw e })
|
|
317
|
-
return loaded
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
kind: 'native', target: `native://${name}`, service: name,
|
|
322
|
-
engine: async () => (await engine()).kind,
|
|
323
|
-
async call({ fn, input, ctx, request }) {
|
|
324
|
-
const context = JSON.stringify(callContext(fn, ctx, request, timeout, claims))
|
|
325
|
-
const body = JSON.stringify({ input: (await wire(fn, input)) ?? null })
|
|
326
|
-
const running = (await engine()).call(fn.$id, context, body)
|
|
327
|
-
let timer: ReturnType<typeof setTimeout> | undefined
|
|
328
|
-
const reply = typeof running === 'string' ? running : await Promise.race([running, new Promise<string>(ok => { timer = setTimeout(ok, timeout, '{"ok":false,"error":{"code":"Deadline"}}') })])
|
|
329
|
-
clearTimeout(timer)
|
|
330
|
-
return settled(fn, JSON.parse(reply), target, timeout, validate)
|
|
331
|
-
},
|
|
332
|
-
}
|
|
206
|
+
return createNative(name, options, import.meta.url)
|
|
333
207
|
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { renderToString } from '@weft-js/core/ssr'
|
|
2
|
+
import type { Merged } from '@weft-js/core/ssr'
|
|
3
|
+
import { DefaultCard } from '../og/DefaultCard'
|
|
4
|
+
import { renderCard } from '../og/index.mjs'
|
|
5
|
+
import { settledView } from './payload'
|
|
6
|
+
import type { HandlerOptions, Manifest, Resolved, RouteModule } from './types'
|
|
7
|
+
export function makeCard({
|
|
8
|
+
resolve,
|
|
9
|
+
finished,
|
|
10
|
+
mod,
|
|
11
|
+
manifest,
|
|
12
|
+
options,
|
|
13
|
+
site,
|
|
14
|
+
CARD,
|
|
15
|
+
}: {
|
|
16
|
+
resolve: (path: string, request: Request) => Promise<Resolved>
|
|
17
|
+
finished: (r: Resolved, request: Request) => Promise<Merged>
|
|
18
|
+
mod: (file?: string) => RouteModule | undefined
|
|
19
|
+
manifest: Manifest
|
|
20
|
+
options: HandlerOptions
|
|
21
|
+
site: NonNullable<HandlerOptions['site']>
|
|
22
|
+
CARD: { width: number; height: number }
|
|
23
|
+
}) {
|
|
24
|
+
return async function og(url: URL, request: Request): Promise<Response> {
|
|
25
|
+
const path =
|
|
26
|
+
'/' +
|
|
27
|
+
url.pathname
|
|
28
|
+
.slice('/_weft/og/'.length)
|
|
29
|
+
.replace(/\.png$/, '')
|
|
30
|
+
.replace(/^index$/, '')
|
|
31
|
+
const r = await resolve(path, request)
|
|
32
|
+
if (r.status !== 200 || !r.route) return new Response('Not found', { status: 404 }) // only params the route's own load accepts
|
|
33
|
+
const head = await finished(r, request)
|
|
34
|
+
const image = head.og.image
|
|
35
|
+
if (typeof image !== 'object' || !image.url.startsWith('/_weft/og/'))
|
|
36
|
+
return new Response('Not found', { status: 404 }) // this page has no generated card
|
|
37
|
+
const want = new URL(image.url, url).searchParams.get('v')
|
|
38
|
+
const cardMod = mod(r.route.card ?? manifest.defaultCard)
|
|
39
|
+
const leaf = r.levels.at(-1)!
|
|
40
|
+
const cardProps = {
|
|
41
|
+
params: leaf.params,
|
|
42
|
+
data: settledView(leaf.raw, leaf.d, leaf.id, options),
|
|
43
|
+
meta: head,
|
|
44
|
+
site: { ...site, url: r.origin },
|
|
45
|
+
path: r.path,
|
|
46
|
+
title: head.og.title ?? head.title,
|
|
47
|
+
description: head.og.description ?? head.description ?? '',
|
|
48
|
+
}
|
|
49
|
+
const Card = (cardMod?.default ?? DefaultCard) as (props: typeof cardProps) => unknown
|
|
50
|
+
const html = renderToString(() => <Card {...cardProps} />, { hydratable: false })
|
|
51
|
+
const png = await renderCard(html, cardMod?.size ?? CARD, options.fonts ?? [], {
|
|
52
|
+
strict: false,
|
|
53
|
+
onDiagnostic: (d: { code: string; severity: string; message: string }) =>
|
|
54
|
+
options.onDiagnostic?.({ ...d, route: r.route!.id }),
|
|
55
|
+
})
|
|
56
|
+
// A signed-in request can see what a public cache must not keep.
|
|
57
|
+
const fresh = url.searchParams.get('v') === want && !request.headers.has('cookie')
|
|
58
|
+
const cache = options.dev
|
|
59
|
+
? 'no-store'
|
|
60
|
+
: fresh
|
|
61
|
+
? 'public, max-age=31536000, immutable'
|
|
62
|
+
: 'no-cache'
|
|
63
|
+
return new Response(png, { headers: { 'content-type': 'image/png', 'cache-control': cache } })
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type * as NodeZlib from 'node:zlib'
|
|
2
|
+
|
|
3
|
+
/** A complete page's text. Its Response holds no body until `compressed` gives it one: compressed, it's never read. */
|
|
4
|
+
export const whole = new WeakMap<Response, string>()
|
|
5
|
+
|
|
6
|
+
type Encoding = 'br' | 'gzip'
|
|
7
|
+
type Zlib = typeof NodeZlib
|
|
8
|
+
|
|
9
|
+
let loaded: Promise<Zlib | undefined> | undefined
|
|
10
|
+
const loadZlib = () => (loaded ??= import('node:zlib').catch(() => undefined))
|
|
11
|
+
const brotli = (zlib: Zlib, sizeHint: number) => ({
|
|
12
|
+
params: {
|
|
13
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 5,
|
|
14
|
+
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: sizeHint,
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
const PACKED_MAX = 16 * 1024 * 1024 // characters of text held, per encoding
|
|
19
|
+
const PACKED_ENTRY_MAX = 1024 * 1024
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compressed bodies by their whole text, most recently used last. The text is the key, not a hash of it, so a response
|
|
23
|
+
* is only ever reused for the same bytes: two users can share an entry only if each was already being sent that page.
|
|
24
|
+
*/
|
|
25
|
+
const packed: Record<Encoding, { entries: Map<string, Promise<Uint8Array>>; held: number }> = {
|
|
26
|
+
br: { entries: new Map(), held: 0 },
|
|
27
|
+
gzip: { entries: new Map(), held: 0 },
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function compressWhole(zlib: Zlib, encoding: Encoding, text: string): Promise<Uint8Array> {
|
|
31
|
+
const cache = packed[encoding]
|
|
32
|
+
const hit = cache.entries.get(text)
|
|
33
|
+
if (hit) {
|
|
34
|
+
cache.entries.delete(text)
|
|
35
|
+
cache.entries.set(text, hit)
|
|
36
|
+
return hit
|
|
37
|
+
}
|
|
38
|
+
const out = new Promise<Uint8Array>((ok, no) => {
|
|
39
|
+
const done = (e: Error | null, b: Buffer) => (e ? no(e) : ok(b))
|
|
40
|
+
if (encoding === 'br') zlib.brotliCompress(text, brotli(zlib, Buffer.byteLength(text)), done)
|
|
41
|
+
else zlib.gzip(text, done)
|
|
42
|
+
})
|
|
43
|
+
if (text.length > PACKED_ENTRY_MAX) return out
|
|
44
|
+
cache.entries.set(text, out)
|
|
45
|
+
cache.held += text.length
|
|
46
|
+
for (const key of cache.entries.keys()) {
|
|
47
|
+
if (cache.held <= PACKED_MAX) break
|
|
48
|
+
cache.entries.delete(key)
|
|
49
|
+
cache.held -= key.length
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A stream of chunks, each flushed as it arrives so a Boundary reaches the client when it resolves. */
|
|
55
|
+
function compressStream(
|
|
56
|
+
zlib: Zlib,
|
|
57
|
+
encoding: Encoding,
|
|
58
|
+
source: ReadableStream<Uint8Array>,
|
|
59
|
+
): ReadableStream<Uint8Array> {
|
|
60
|
+
const z = encoding === 'br' ? zlib.createBrotliCompress(brotli(zlib, 0)) : zlib.createGzip()
|
|
61
|
+
const reader = source.getReader()
|
|
62
|
+
return new ReadableStream<Uint8Array>({
|
|
63
|
+
start(out) {
|
|
64
|
+
z.on('data', (d: Buffer) => out.enqueue(new Uint8Array(d)))
|
|
65
|
+
z.on('end', () => out.close())
|
|
66
|
+
z.on('error', (e) => out.error(e))
|
|
67
|
+
;(async () => {
|
|
68
|
+
for (;;) {
|
|
69
|
+
const { done, value } = await reader.read()
|
|
70
|
+
if (done) return void z.end()
|
|
71
|
+
z.write(value)
|
|
72
|
+
await new Promise((ok) => {
|
|
73
|
+
z.flush(ok as () => void)
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
})().catch((e) => out.error(e))
|
|
77
|
+
},
|
|
78
|
+
cancel(reason) {
|
|
79
|
+
z.destroy()
|
|
80
|
+
return reader.cancel(reason)
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* HTML and data go out brotli or gzipped when the client takes it. A response that is complete is compressed once and
|
|
87
|
+
* kept, since most pages come out the same for every visitor; one still streaming is flushed chunk by chunk. A runtime
|
|
88
|
+
* with no node:zlib sits behind a platform that compresses.
|
|
89
|
+
*/
|
|
90
|
+
export async function compressed(request: Request, response: Response): Promise<Response> {
|
|
91
|
+
const page = whole.get(response)
|
|
92
|
+
const plain = () => (page === undefined ? response : new Response(page, response))
|
|
93
|
+
if (page === undefined && (!response.body || response.headers.has('content-encoding')))
|
|
94
|
+
return response
|
|
95
|
+
const type = response.headers.get('content-type') ?? ''
|
|
96
|
+
if (!/^(text\/html|application\/json)/.test(type)) return plain()
|
|
97
|
+
const accepts = request.headers.get('accept-encoding') ?? ''
|
|
98
|
+
const encoding = /\bbr\b/.test(accepts) ? 'br' : /\bgzip\b/.test(accepts) ? 'gzip' : undefined
|
|
99
|
+
const zlib = encoding && (await loadZlib())
|
|
100
|
+
if (!zlib) return plain()
|
|
101
|
+
const text = page ?? (type.startsWith('application/json') ? await response.text() : undefined)
|
|
102
|
+
const body =
|
|
103
|
+
text === undefined
|
|
104
|
+
? compressStream(zlib, encoding, response.body!)
|
|
105
|
+
: await compressWhole(zlib, encoding, text)
|
|
106
|
+
const headers = new Headers(response.headers)
|
|
107
|
+
headers.set('content-encoding', encoding)
|
|
108
|
+
headers.append('vary', 'Accept-Encoding')
|
|
109
|
+
headers.delete('content-length')
|
|
110
|
+
return new Response(body as BodyInit, {
|
|
111
|
+
status: response.status,
|
|
112
|
+
statusText: response.statusText,
|
|
113
|
+
headers,
|
|
114
|
+
})
|
|
115
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { region } from '@weft-js/core/ssr'
|
|
3
|
+
import { createRouteState, type Component, type RoutePayload } from '../router'
|
|
4
|
+
import { ErrorPage, NotFoundPage } from '../builtin'
|
|
5
|
+
import { deployUrl } from '../derive.mjs'
|
|
6
|
+
import type { Scope } from '../exec'
|
|
7
|
+
import { routeTree } from './routes'
|
|
8
|
+
import type { Head } from './page-types'
|
|
9
|
+
import type { HandlerOptions, Resolved } from './types'
|
|
10
|
+
|
|
11
|
+
const HEADS_MAX = 512
|
|
12
|
+
|
|
13
|
+
/** Handler-local routing, component and head-cache state. */
|
|
14
|
+
export function makeCore(options: HandlerOptions, als: AsyncLocalStorage<Scope>) {
|
|
15
|
+
const { manifest, modules, site = {} } = options
|
|
16
|
+
const byId = new Map(manifest.routes.map((r) => [r.id, r]))
|
|
17
|
+
const mod = (file?: string) => (file ? modules[file] : undefined)
|
|
18
|
+
const comps: Record<string, Component> = { 404: NotFoundPage, error: ErrorPage }
|
|
19
|
+
for (const l of manifest.layouts) if (mod(l.file)?.default) comps[l.id] = mod(l.file)!.default!
|
|
20
|
+
for (const r of manifest.routes) if (mod(r.file)?.default) comps[r.id] = mod(r.file)!.default!
|
|
21
|
+
const statics = new Set(options.statics)
|
|
22
|
+
/**
|
|
23
|
+
* What each route's last render needed in its head, by status, Save-Data and levels: the next request sends that head
|
|
24
|
+
* before it renders. `null`: a route whose render sets headers, so it renders first.
|
|
25
|
+
*/
|
|
26
|
+
const heads = new Map<string, Head | null>()
|
|
27
|
+
const remember = (shape: string, head: Head) => {
|
|
28
|
+
if (heads.size >= HEADS_MAX && !heads.has(shape)) heads.clear()
|
|
29
|
+
heads.set(shape, head)
|
|
30
|
+
}
|
|
31
|
+
const publicUrl = deployUrl(
|
|
32
|
+
site,
|
|
33
|
+
(globalThis as unknown as { process?: { env: Record<string, string | undefined> } }).process
|
|
34
|
+
?.env,
|
|
35
|
+
)
|
|
36
|
+
let warned = false
|
|
37
|
+
/**
|
|
38
|
+
* The origin canonical, og:url and og:image are built on. Without a configured one, the request's: right for what
|
|
39
|
+
* a browser asked, but it's whatever `Host` said, so a production server says so once. A prerender has no public
|
|
40
|
+
* request at all (it would write `localhost` into a file), so its pages stay relative and H001 reports them.
|
|
41
|
+
*/
|
|
42
|
+
const originOf = (request: Request) => {
|
|
43
|
+
if (publicUrl) return publicUrl
|
|
44
|
+
if (request.headers.has('x-nitro-prerender') || options.requestOrigin === false)
|
|
45
|
+
return undefined
|
|
46
|
+
if (!options.dev && !warned) {
|
|
47
|
+
warned = true
|
|
48
|
+
options.onDiagnostic?.({
|
|
49
|
+
code: 'H001',
|
|
50
|
+
severity: 'warning',
|
|
51
|
+
message:
|
|
52
|
+
"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.",
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
return new URL(request.url).origin
|
|
56
|
+
}
|
|
57
|
+
/** The route state of a server render: where a static level is in it, it marks where each level starts (`region`). */
|
|
58
|
+
const stateOf = (r: Resolved, payload: RoutePayload) => {
|
|
59
|
+
const route = createRouteState(
|
|
60
|
+
{ ...payload, m: r.levels.map((lv) => ({ id: lv.id, params: lv.params, data: lv.raw })) },
|
|
61
|
+
comps,
|
|
62
|
+
)
|
|
63
|
+
if (r.levels.some((l) => statics.has(l.id)))
|
|
64
|
+
route.around = (d, id, body) =>
|
|
65
|
+
region(statics.has(id), d, statics.has(r.levels[d - 1]?.id ?? ''), body)
|
|
66
|
+
return route
|
|
67
|
+
}
|
|
68
|
+
const tree = routeTree(manifest, modules, als)
|
|
69
|
+
return { manifest, site, byId, mod, comps, statics, heads, remember, originOf, stateOf, tree }
|
|
70
|
+
}
|