@weft-js/start 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +51 -10
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +124 -490
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +146 -155
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +97 -168
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +113 -35
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -174
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -964
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import { renderToStream, type Merged } from '@weft-js/core/ssr'
|
|
3
|
+
import type { createRouteState, RoutePayload } from '../router'
|
|
4
|
+
import * as exec from '../exec'
|
|
5
|
+
import type { Scope } from '../exec'
|
|
6
|
+
import { App } from '../view'
|
|
7
|
+
import { leftToPage } from './head'
|
|
8
|
+
import { payloadOf } from './payload'
|
|
9
|
+
import { cookieJar } from './request'
|
|
10
|
+
import type { Resolved } from './types'
|
|
11
|
+
|
|
12
|
+
/** Finish page metadata and inspect a route in its request scope. */
|
|
13
|
+
export function makePreview({
|
|
14
|
+
als,
|
|
15
|
+
scopeOptions,
|
|
16
|
+
resolve,
|
|
17
|
+
stateOf,
|
|
18
|
+
timing,
|
|
19
|
+
complete,
|
|
20
|
+
PASSES,
|
|
21
|
+
}: {
|
|
22
|
+
als: AsyncLocalStorage<Scope>
|
|
23
|
+
scopeOptions: Parameters<typeof exec.createScope>[1]
|
|
24
|
+
resolve: (path: string, request: Request) => Promise<Resolved>
|
|
25
|
+
stateOf: (r: Resolved, payload: RoutePayload) => ReturnType<typeof createRouteState>
|
|
26
|
+
timing: { timeout?: number }
|
|
27
|
+
complete: (r: Resolved, html?: string) => Merged
|
|
28
|
+
PASSES: number
|
|
29
|
+
}) {
|
|
30
|
+
/** What `page` shows a reader, without the streaming: the same pass, so a page, its navigation and its card agree. */
|
|
31
|
+
async function shell(r: Resolved, request: Request) {
|
|
32
|
+
const route = stateOf(r, payloadOf(r, false))
|
|
33
|
+
const s = renderToStream(() => <App route={route} />, {
|
|
34
|
+
...timing,
|
|
35
|
+
passes: PASSES,
|
|
36
|
+
app: { cookies: cookieJar(request) },
|
|
37
|
+
})
|
|
38
|
+
await s.settle()
|
|
39
|
+
return s.shell
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The finished head of a request that doesn't otherwise render: it renders only when a level left words to the page.
|
|
44
|
+
* A render that fails here (a build with no database) leaves the words to the config; the page still shows its own.
|
|
45
|
+
*/
|
|
46
|
+
const finished = async (r: Resolved, request: Request) => {
|
|
47
|
+
if (
|
|
48
|
+
!r.metas ||
|
|
49
|
+
r.status !== 200 ||
|
|
50
|
+
!(leftToPage(r.source!.title) || leftToPage(r.source!.description))
|
|
51
|
+
)
|
|
52
|
+
return complete(r)
|
|
53
|
+
return complete(r, await shell(r, request).catch(() => undefined))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `weft routes --meta`: one page's finished head, its HTML and where its words came from, in a request scope. */
|
|
57
|
+
const inspect = (path: string, request: Request) =>
|
|
58
|
+
als.run(exec.createScope(request, scopeOptions), async () => {
|
|
59
|
+
const r = await resolve(path, request)
|
|
60
|
+
if (r.redirect) return { ...r, html: '' }
|
|
61
|
+
const html = await shell(r, request).catch(() => '')
|
|
62
|
+
complete(r, html)
|
|
63
|
+
return { ...r, html }
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return { finished, inspect }
|
|
67
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { RoutePayload } from '../router'
|
|
2
|
+
import { firstPaint } from '../first-paint'
|
|
3
|
+
import { pick } from '../sheets.mjs'
|
|
4
|
+
import type { HandlerOptions } from './types'
|
|
5
|
+
import { esc, hash } from './text'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A page's styles from the build's first-paint table (`firstPaint`): the rules its HTML reaches, inline, and its sheets as
|
|
9
|
+
* links past the shell, where they hold nothing above it. `late` gives a streamed chunk the rules it adds, and a link for
|
|
10
|
+
* a sheet none of the page's has: an island in a Boundary the shell didn't show.
|
|
11
|
+
*/
|
|
12
|
+
export function tableStyles(
|
|
13
|
+
pieces: NonNullable<NonNullable<HandlerOptions['assets']>['pieces']>,
|
|
14
|
+
css: { href: string }[],
|
|
15
|
+
siteHead: string | undefined,
|
|
16
|
+
lang: string,
|
|
17
|
+
) {
|
|
18
|
+
const hrefs = css.map((c) => c.href)
|
|
19
|
+
const late = pick(pieces, hrefs)
|
|
20
|
+
const paint = firstPaint(pieces, hrefs, siteHead)
|
|
21
|
+
const held: string[] = late.sheets.flatMap((c: { has: string[] }) => c.has)
|
|
22
|
+
const carried = new Set([...hrefs, ...held])
|
|
23
|
+
const link = (href: string) => `<link rel="stylesheet" href="${esc(href)}">\n`
|
|
24
|
+
return {
|
|
25
|
+
rules: (shell: string) => paint(`<html${lang}><body><div id="app">${shell}`),
|
|
26
|
+
// A combined sheet's parts as disabled links: Vite's preload helper finds them there and doesn't fetch them again.
|
|
27
|
+
links:
|
|
28
|
+
[...late.sheets.map((c: { href: string }) => c.href), ...late.rest].map(link).join('') +
|
|
29
|
+
held.map((href) => `<link rel="stylesheet" href="${esc(href)}" disabled>\n`).join(''),
|
|
30
|
+
late(sheets: { href: string }[] = [], chunk = '') {
|
|
31
|
+
const more = sheets.map((c) => c.href).filter((h) => !carried.has(h))
|
|
32
|
+
for (const h of more) carried.add(h)
|
|
33
|
+
const rules = paint(chunk)
|
|
34
|
+
return more.map(link).join('') + (rules ? `<style>${rules}</style>\n` : '')
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The payload with its static levels marked: their hash, without their data (the HTML holds it), and with `html` their
|
|
41
|
+
* HTML, each level's next one cut down to its markers. The `aria-current` a navigation moves doesn't count as a change.
|
|
42
|
+
*/
|
|
43
|
+
/**
|
|
44
|
+
* The chunks `payload`'s page will want: its non-static levels and the islands that load with it, and `fold`, the lazy
|
|
45
|
+
* ones in the first view. Those stay preloaded, fetched but not run, so a first click on them doesn't wait; the rest
|
|
46
|
+
* load when they're needed. `$client`: the router and runtime, whenever any of that will run.
|
|
47
|
+
*/
|
|
48
|
+
export const pageChunks = (
|
|
49
|
+
payload: RoutePayload,
|
|
50
|
+
chunks: HandlerOptions['chunks'] = {},
|
|
51
|
+
fold: string[] = [],
|
|
52
|
+
now = true,
|
|
53
|
+
) => {
|
|
54
|
+
const ids = [...payload.m.filter((m) => !m.s).map((m) => m.id), ...(payload.i ?? []), ...fold]
|
|
55
|
+
return [...(now || ids.length ? ['$client'] : []), ...ids].flatMap((id) => chunks[id]?.js ?? [])
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 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
|
|
60
|
+
* route's chunk graph; without this the server's HTML paints bare and the sheets arrive with the script.
|
|
61
|
+
*/
|
|
62
|
+
export const pageSheets = (
|
|
63
|
+
css: { href: string }[] = [],
|
|
64
|
+
parts: { now: string[]; lazy: string[]; fold: string[] },
|
|
65
|
+
chunks: HandlerOptions['chunks'] = {},
|
|
66
|
+
) => {
|
|
67
|
+
const found = [...parts.now, ...parts.lazy, ...parts.fold].flatMap((id) => chunks[id]?.css ?? [])
|
|
68
|
+
return [...new Map([...css, ...found].map((c) => [c.href, c])).values()]
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const saveData = (request: Request) =>
|
|
72
|
+
request.headers.get('save-data') === 'on' ||
|
|
73
|
+
request.headers.get('sec-ch-prefers-reduced-data') === 'reduce'
|
|
74
|
+
|
|
75
|
+
/** Where the first view ends, in px: past most laptops' and phones' first screen. */
|
|
76
|
+
const FOLD = 900
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* About how far down the page `html` runs out, in px. The server has no layout, so this counts what makes a page tall:
|
|
80
|
+
* blocks, and lines of their text. A wrong guess costs one chunk's fetch, either way: it's a preload, not a decision.
|
|
81
|
+
*/
|
|
82
|
+
function depth(html: string) {
|
|
83
|
+
const plain = html.replace(/<(svg|style|template)\b[\s\S]*?<\/\1>|<!--[\s\S]*?-->/g, '')
|
|
84
|
+
const blocks =
|
|
85
|
+
plain.match(
|
|
86
|
+
/<(?:section|article|header|footer|h[1-6]|p|li|tr|img|form|pre|blockquote|video|figure)\b/g,
|
|
87
|
+
)?.length ?? 0
|
|
88
|
+
return blocks * 32 + (plain.replace(/<[^>]*>/g, '').length / 60) * 24
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** What a page's HTML says about its islands: those that load with it, those that wait, and the waiting ones in view. */
|
|
92
|
+
export function islandsOf(html: string) {
|
|
93
|
+
const known = renderedOf(html)
|
|
94
|
+
if (known.p) return known.p
|
|
95
|
+
const parts = { now: [] as string[], lazy: [] as string[], fold: [] as string[] }
|
|
96
|
+
let past = false
|
|
97
|
+
for (const m of html.matchAll(/<!--wi:(\[.*?\])-->/g)) {
|
|
98
|
+
const [id, , , when] = JSON.parse(m[1]!) as [string, string, unknown[], string?]
|
|
99
|
+
const list = when ? parts.lazy : parts.now
|
|
100
|
+
if (!list.includes(id)) list.push(id)
|
|
101
|
+
past ||= depth(html.slice(0, m.index)) >= FOLD
|
|
102
|
+
if (when && !past && !parts.fold.includes(id)) parts.fold.push(id)
|
|
103
|
+
}
|
|
104
|
+
return (known.p = parts)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function modulepreloads(
|
|
108
|
+
chunks: { href: string }[],
|
|
109
|
+
assets: NonNullable<HandlerOptions['assets']>,
|
|
110
|
+
) {
|
|
111
|
+
const hrefs = new Set(chunks.map((c) => c.href))
|
|
112
|
+
hrefs.delete(assets.entry!)
|
|
113
|
+
return [...hrefs].map((href) => `<link rel="modulepreload" href="${esc(href)}">`).join('')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** What a render's HTML says about its levels, worked out as asked and kept by the exact text. */
|
|
117
|
+
interface Rendered {
|
|
118
|
+
h: Record<number, string>
|
|
119
|
+
x: Record<number, string>
|
|
120
|
+
p?: { now: string[]; lazy: string[]; fold: string[] }
|
|
121
|
+
}
|
|
122
|
+
const RENDERED_MAX = 64
|
|
123
|
+
const rendered = new Map<string, Rendered>()
|
|
124
|
+
|
|
125
|
+
function renderedOf(html: string) {
|
|
126
|
+
let r = rendered.get(html)
|
|
127
|
+
if (!r) {
|
|
128
|
+
if (rendered.size >= RENDERED_MAX) rendered.clear()
|
|
129
|
+
rendered.set(html, (r = { h: {}, x: {} }))
|
|
130
|
+
}
|
|
131
|
+
return r
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function withLevels(
|
|
135
|
+
payload: RoutePayload,
|
|
136
|
+
html: string,
|
|
137
|
+
statics: Set<string>,
|
|
138
|
+
sendHtml: boolean,
|
|
139
|
+
): RoutePayload {
|
|
140
|
+
if (!payload.m.some((m) => statics.has(m.id))) return payload
|
|
141
|
+
const known = renderedOf(html)
|
|
142
|
+
const slice = (d: number) => {
|
|
143
|
+
const open = html.indexOf(`<!--wl:${d}:`)
|
|
144
|
+
const end = `<!--/wl:${d}-->`
|
|
145
|
+
const close = html.indexOf(end)
|
|
146
|
+
if (open < 0 || close < 0) return ''
|
|
147
|
+
const body = html.slice(html.indexOf('-->', open) + 3, close)
|
|
148
|
+
const kid = body.indexOf(`<!--wl:${d + 1}:`)
|
|
149
|
+
const tail = body.indexOf(`<!--/wl:${d + 1}-->`)
|
|
150
|
+
return kid < 0 || tail < 0
|
|
151
|
+
? body
|
|
152
|
+
: body.slice(0, body.indexOf('-->', kid) + 3) + body.slice(tail)
|
|
153
|
+
}
|
|
154
|
+
const m = payload.m.map((lv, d) => {
|
|
155
|
+
if (!statics.has(lv.id)) return lv
|
|
156
|
+
const { data: _, ...rest } = lv
|
|
157
|
+
const h = (known.h[d] ??= slice(d))
|
|
158
|
+
return {
|
|
159
|
+
...rest,
|
|
160
|
+
s: 1 as const,
|
|
161
|
+
x: (known.x[d] ??= hash(h.replaceAll(' aria-current="page"', ''))),
|
|
162
|
+
...(sendHtml && { h }),
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
const i = islandsOf(html).now
|
|
166
|
+
return { ...payload, m, ...(i.length && { i }) }
|
|
167
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { CLIENT_IP, decodeForm } from '../data'
|
|
2
|
+
import type { Scope } from '../exec'
|
|
3
|
+
|
|
4
|
+
/** The request with the socket's address in CLIENT_IP, replacing any the client sent. Nitro's server sets `.ip`. */
|
|
5
|
+
export function stamped(request: Request): Request {
|
|
6
|
+
const headers = new Headers(request.headers)
|
|
7
|
+
const { ip } = request as Request & { ip?: string }
|
|
8
|
+
headers.delete(CLIENT_IP)
|
|
9
|
+
if (ip) headers.set(CLIENT_IP, ip)
|
|
10
|
+
// `new Request(request, …)` refuses the server's request wrapper; the parts are enough.
|
|
11
|
+
return new Request(request.url, {
|
|
12
|
+
method: request.method,
|
|
13
|
+
headers,
|
|
14
|
+
...(request.body && { body: request.body, duplex: 'half' }),
|
|
15
|
+
})
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The request's cookies for `persist: 'cookie'`, parsed on first read: a page with none pays nothing. */
|
|
19
|
+
export function cookieJar(request: Request) {
|
|
20
|
+
let jar: Map<string, string> | undefined
|
|
21
|
+
return {
|
|
22
|
+
get(name: string) {
|
|
23
|
+
jar ??= new Map(
|
|
24
|
+
(request.headers.get('cookie') ?? '')
|
|
25
|
+
.split(/;\s*/)
|
|
26
|
+
.map((c) => [c.slice(0, c.indexOf('=')), c.slice(c.indexOf('=') + 1)]),
|
|
27
|
+
)
|
|
28
|
+
return jar.get(name)
|
|
29
|
+
},
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const refresh = (v: FormDataEntryValue | null) => (typeof v === 'string' ? v : '[]')
|
|
34
|
+
|
|
35
|
+
/** The multipart body `<Form>` sends when a form holds a file: the fields, and the refresh list in `$r`. */
|
|
36
|
+
export async function multipartBody(request: Request) {
|
|
37
|
+
const data = await request.formData()
|
|
38
|
+
return {
|
|
39
|
+
i: decodeForm(data),
|
|
40
|
+
f: JSON.parse(refresh(data.get('$r'))) as [string, unknown][],
|
|
41
|
+
c: 1 as const,
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What a failed form's page gives back: no secrets (the HTML outlives the request) and no files. */
|
|
46
|
+
export function echoed(input: Record<string, unknown>): Record<string, unknown> {
|
|
47
|
+
const keep = (v: unknown): unknown =>
|
|
48
|
+
typeof v === 'string'
|
|
49
|
+
? v
|
|
50
|
+
: Array.isArray(v)
|
|
51
|
+
? v.filter((x) => typeof x === 'string')
|
|
52
|
+
: v && typeof v === 'object' && !(v instanceof Blob)
|
|
53
|
+
? echoed(v as Record<string, unknown>)
|
|
54
|
+
: undefined
|
|
55
|
+
return Object.fromEntries(
|
|
56
|
+
Object.entries(input)
|
|
57
|
+
.filter(([k]) => !/pass|secret|token|card|cvv|cvc/i.test(k))
|
|
58
|
+
.map(([k, v]) => [k, keep(v)]),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The cookies and headers server functions set (`ctx.response.headers`), on the response. */
|
|
63
|
+
export function withHeaders(res: Response, scope: Scope): Response {
|
|
64
|
+
for (const [k, v] of scope.headers) res.headers.append(k, v)
|
|
65
|
+
return res
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Merge scope headers, then replace metadata for the redirect's new response body. */
|
|
69
|
+
export function redirectBody(
|
|
70
|
+
res: Response,
|
|
71
|
+
scope: Scope,
|
|
72
|
+
type: string,
|
|
73
|
+
location?: string,
|
|
74
|
+
): Response {
|
|
75
|
+
withHeaders(res, scope)
|
|
76
|
+
for (const name of [
|
|
77
|
+
'content-length',
|
|
78
|
+
'content-encoding',
|
|
79
|
+
'content-range',
|
|
80
|
+
'content-location',
|
|
81
|
+
'content-language',
|
|
82
|
+
'content-disposition',
|
|
83
|
+
'transfer-encoding',
|
|
84
|
+
'trailer',
|
|
85
|
+
'accept-ranges',
|
|
86
|
+
'etag',
|
|
87
|
+
'last-modified',
|
|
88
|
+
'digest',
|
|
89
|
+
'content-digest',
|
|
90
|
+
'repr-digest',
|
|
91
|
+
'content-md5',
|
|
92
|
+
])
|
|
93
|
+
res.headers.delete(name)
|
|
94
|
+
res.headers.set('content-type', type)
|
|
95
|
+
if (location === undefined) res.headers.delete('location')
|
|
96
|
+
else res.headers.set('location', location)
|
|
97
|
+
return res
|
|
98
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import {
|
|
3
|
+
RouterCore,
|
|
4
|
+
createNonReactiveMutableStore,
|
|
5
|
+
createNonReactiveReadonlyStore,
|
|
6
|
+
isNotFound,
|
|
7
|
+
} from '@tanstack/router-core'
|
|
8
|
+
import { createMemoryHistory } from '@tanstack/history'
|
|
9
|
+
import { mergeMeta, type Merged, type Meta as MetaObject } from '@weft-js/core/ssr'
|
|
10
|
+
import type { Component } from '../router'
|
|
11
|
+
import { StartError } from '../data'
|
|
12
|
+
import * as exec from '../exec'
|
|
13
|
+
import type { Scope } from '../exec'
|
|
14
|
+
import { cleanParams, type routeTree, type LoadContext } from './routes'
|
|
15
|
+
import { split, settledView } from './payload'
|
|
16
|
+
import type {
|
|
17
|
+
HandlerOptions,
|
|
18
|
+
LayoutEntry,
|
|
19
|
+
Level,
|
|
20
|
+
MetaSource,
|
|
21
|
+
Resolved,
|
|
22
|
+
RouteEntry,
|
|
23
|
+
RouteModule,
|
|
24
|
+
} from './types'
|
|
25
|
+
|
|
26
|
+
type RouterOptions = ConstructorParameters<typeof RouterCore>[0]
|
|
27
|
+
export interface ResolverDeps {
|
|
28
|
+
als: AsyncLocalStorage<Scope>
|
|
29
|
+
scopeOptions: Parameters<typeof exec.createScope>[1]
|
|
30
|
+
tree: ReturnType<typeof routeTree>
|
|
31
|
+
site: NonNullable<HandlerOptions['site']>
|
|
32
|
+
manifest: HandlerOptions['manifest']
|
|
33
|
+
byId: Map<string, RouteEntry>
|
|
34
|
+
mod: (file?: string) => RouteModule | undefined
|
|
35
|
+
comps: Record<string, Component>
|
|
36
|
+
options: HandlerOptions
|
|
37
|
+
originOf: (request: Request) => string | undefined
|
|
38
|
+
}
|
|
39
|
+
const stores = () => ({
|
|
40
|
+
createMutableStore: createNonReactiveMutableStore,
|
|
41
|
+
createReadonlyStore: createNonReactiveReadonlyStore,
|
|
42
|
+
batch: (f: () => void) => f(),
|
|
43
|
+
})
|
|
44
|
+
/** The origin a head's URLs are built on, or none. */
|
|
45
|
+
export function pointAt(head: Merged, origin: string | undefined) {
|
|
46
|
+
if (origin) head.url = origin
|
|
47
|
+
else delete head.url
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveRoute(
|
|
51
|
+
deps: ResolverDeps,
|
|
52
|
+
path: string,
|
|
53
|
+
request: Request,
|
|
54
|
+
): Promise<Resolved> {
|
|
55
|
+
const { als, scopeOptions, tree } = deps
|
|
56
|
+
const scope = als.getStore() ?? exec.createScope(request, scopeOptions)
|
|
57
|
+
const hold: LoadContext = { request, scope, tags: new Map(), out: {} }
|
|
58
|
+
const router = new RouterCore(
|
|
59
|
+
{
|
|
60
|
+
routeTree: tree,
|
|
61
|
+
history: createMemoryHistory({ initialEntries: [path] }),
|
|
62
|
+
isServer: true,
|
|
63
|
+
context: hold,
|
|
64
|
+
} as RouterOptions,
|
|
65
|
+
stores,
|
|
66
|
+
)
|
|
67
|
+
// The page's `[...name]`, so its layouts see the catch-all under that name too (the loaders copy `hold` as they start).
|
|
68
|
+
hold.splat = (
|
|
69
|
+
router.getMatchedRoutes(router.latestLocation.pathname)[2] as
|
|
70
|
+
| { options: { weft?: RouteEntry } }
|
|
71
|
+
| undefined
|
|
72
|
+
)?.options.weft?.splat
|
|
73
|
+
await router.load()
|
|
74
|
+
return resolvedMatches(deps, router, hold, path, request)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function redirectOf(
|
|
78
|
+
deps: ResolverDeps,
|
|
79
|
+
router: InstanceType<typeof RouterCore>,
|
|
80
|
+
hold: LoadContext,
|
|
81
|
+
path: string,
|
|
82
|
+
): Resolved | undefined {
|
|
83
|
+
const native =
|
|
84
|
+
router._serverResult?.type === 'redirect' ? router._serverResult.redirect : undefined
|
|
85
|
+
const href = native?.headers.get('location')
|
|
86
|
+
const goes =
|
|
87
|
+
hold.out.redirect ??
|
|
88
|
+
(native && href ? { isRedirect: true as const, href, status: native.status } : undefined)
|
|
89
|
+
if (!goes) return undefined
|
|
90
|
+
return {
|
|
91
|
+
status: goes.status,
|
|
92
|
+
path,
|
|
93
|
+
levels: [],
|
|
94
|
+
head: mergeMeta(deps.site),
|
|
95
|
+
redirect: goes,
|
|
96
|
+
...(!hold.out.redirect && native ? { redirectHeaders: native.headers } : {}),
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function resolvedMatches(
|
|
101
|
+
deps: ResolverDeps,
|
|
102
|
+
router: InstanceType<typeof RouterCore>,
|
|
103
|
+
hold: LoadContext,
|
|
104
|
+
path: string,
|
|
105
|
+
request: Request,
|
|
106
|
+
): Promise<Resolved> {
|
|
107
|
+
const { manifest, byId, mod, comps, options } = deps
|
|
108
|
+
const redirected = redirectOf(deps, router, hold, path)
|
|
109
|
+
if (redirected) return redirected
|
|
110
|
+
const matches = router.state.matches.map((m) => ({
|
|
111
|
+
m,
|
|
112
|
+
weft: (router.routesById as Record<string, { options: { weft?: RouteEntry | LayoutEntry } }>)[
|
|
113
|
+
m.routeId
|
|
114
|
+
]?.options.weft,
|
|
115
|
+
}))
|
|
116
|
+
const params = (m: { params: Record<string, unknown> }, splat?: string) =>
|
|
117
|
+
cleanParams(m.params, splat)
|
|
118
|
+
const leaf = matches.at(-1)?.weft
|
|
119
|
+
const route = leaf && 'kind' in leaf ? leaf : undefined
|
|
120
|
+
const failed = matches.find((x) => x.m.status === 'error')
|
|
121
|
+
const missing = !route || matches.some((x) => x.m.status === 'notFound' || isNotFound(x.m.error))
|
|
122
|
+
const rootLayout = manifest.layouts.find((l) => l.dir === '')
|
|
123
|
+
const rootOk =
|
|
124
|
+
rootLayout && matches[0]?.weft === rootLayout && matches[0].m.status === 'success'
|
|
125
|
+
? matches[0]
|
|
126
|
+
: undefined
|
|
127
|
+
const levelOf = (x: (typeof matches)[number]): Level => {
|
|
128
|
+
const raw = x.m.loaderData
|
|
129
|
+
const t = hold.tags.get(x.weft!.file)
|
|
130
|
+
return {
|
|
131
|
+
id: x.weft!.id,
|
|
132
|
+
params: params(x.m, hold.splat),
|
|
133
|
+
mod: mod(x.weft!.file),
|
|
134
|
+
raw,
|
|
135
|
+
...split(raw),
|
|
136
|
+
...(t?.size && { t: [...t] }),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const special = (id: '404' | 'error', status: number, error?: unknown): Promise<Resolved> => {
|
|
140
|
+
const entry = byId.get(id)
|
|
141
|
+
if (error instanceof StartError && error.code)
|
|
142
|
+
options.onDiagnostic?.({ code: error.code, severity: 'error', message: error.message })
|
|
143
|
+
const shown =
|
|
144
|
+
id === 'error'
|
|
145
|
+
? {
|
|
146
|
+
status,
|
|
147
|
+
message:
|
|
148
|
+
options.dev && error instanceof Error ? error.message : 'Something went wrong.',
|
|
149
|
+
}
|
|
150
|
+
: { path }
|
|
151
|
+
const levels: Level[] = [
|
|
152
|
+
...(rootOk && !(failed && failed === rootOk) ? [levelOf(rootOk)] : []),
|
|
153
|
+
{ id, params: {}, data: shown, raw: shown, mod: mod(entry?.file) },
|
|
154
|
+
]
|
|
155
|
+
if (entry && mod(entry.file)?.default) comps[id] = mod(entry.file)!.default!
|
|
156
|
+
return finish(levels, status, undefined, error)
|
|
157
|
+
}
|
|
158
|
+
const finish = (shown: Level[], status: number, matched?: RouteEntry, error?: unknown) =>
|
|
159
|
+
finishResolution(deps, { path, request }, { shown, status, matched, error })
|
|
160
|
+
|
|
161
|
+
if (failed) {
|
|
162
|
+
const e = exec.dbError(failed.m.error)
|
|
163
|
+
return special('error', (e as { code?: string } | null)?.code === 'Q016' ? 503 : 500, e)
|
|
164
|
+
}
|
|
165
|
+
if (missing) return special('404', 404)
|
|
166
|
+
const levels = matches.filter((x) => x.weft).map(levelOf)
|
|
167
|
+
try {
|
|
168
|
+
return await finish(levels, 200, route)
|
|
169
|
+
} catch (e) {
|
|
170
|
+
if (isNotFound(e)) return special('404', 404)
|
|
171
|
+
return special('error', 500, e)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function finishResolution(
|
|
176
|
+
deps: ResolverDeps,
|
|
177
|
+
{ path, request }: { path: string; request: Request },
|
|
178
|
+
{
|
|
179
|
+
shown,
|
|
180
|
+
status,
|
|
181
|
+
matched,
|
|
182
|
+
error,
|
|
183
|
+
}: {
|
|
184
|
+
shown: Level[]
|
|
185
|
+
status: number
|
|
186
|
+
matched?: RouteEntry | undefined
|
|
187
|
+
error?: unknown
|
|
188
|
+
},
|
|
189
|
+
): Promise<Resolved> {
|
|
190
|
+
const { site, options, originOf } = deps
|
|
191
|
+
const metas: MetaObject[] = []
|
|
192
|
+
const source: MetaSource = {
|
|
193
|
+
title: site.name ? 'config' : 'none',
|
|
194
|
+
description: site.description ? 'config' : 'none',
|
|
195
|
+
image: 'none',
|
|
196
|
+
}
|
|
197
|
+
for (const lv of shown) {
|
|
198
|
+
const m =
|
|
199
|
+
lv.mod?.meta ??
|
|
200
|
+
(lv.id === '404'
|
|
201
|
+
? { title: 'Not found' }
|
|
202
|
+
: lv.id === 'error'
|
|
203
|
+
? { title: 'Error' }
|
|
204
|
+
: undefined)
|
|
205
|
+
if (!m) continue
|
|
206
|
+
const value =
|
|
207
|
+
typeof m === 'function'
|
|
208
|
+
? await m({ params: lv.params, data: settledView(lv.raw, lv.d, lv.id, options) })
|
|
209
|
+
: m
|
|
210
|
+
metas.push(value)
|
|
211
|
+
const who = lv === shown.at(-1) ? 'route' : 'layout'
|
|
212
|
+
if (value?.title != null) source.title = who
|
|
213
|
+
if (value?.description != null) source.description = who
|
|
214
|
+
}
|
|
215
|
+
const resolved: Resolved = {
|
|
216
|
+
status,
|
|
217
|
+
path,
|
|
218
|
+
levels: shown,
|
|
219
|
+
head: mergeMeta(site, ...metas),
|
|
220
|
+
route: matched,
|
|
221
|
+
error,
|
|
222
|
+
metas,
|
|
223
|
+
source,
|
|
224
|
+
origin: originOf(request),
|
|
225
|
+
}
|
|
226
|
+
pointAt(resolved.head, resolved.origin)
|
|
227
|
+
return resolved
|
|
228
|
+
}
|