@weft-js/start 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (214) hide show
  1. package/README.md +1278 -12
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/README.md +322 -0
  5. package/crate/rust-toolchain.toml +3 -0
  6. package/crate/src/build/emit.rs +103 -0
  7. package/crate/src/build.rs +367 -0
  8. package/crate/src/ctx.rs +186 -0
  9. package/crate/src/envelope.rs +163 -0
  10. package/crate/src/functions.rs +173 -0
  11. package/crate/src/lib.rs +118 -0
  12. package/crate/src/native.rs +212 -0
  13. package/crate/src/otel.rs +62 -0
  14. package/crate/src/server.rs +203 -0
  15. package/package.json +46 -37
  16. package/src/app.mjs +38 -8
  17. package/src/asset-receipt.d.mts +13 -0
  18. package/src/asset-receipt.mjs +61 -0
  19. package/src/auth/better-auth.ts +215 -17
  20. package/src/auth/drizzle.ts +1 -1
  21. package/src/auth.ts +21 -6
  22. package/src/boot.ts +19 -8
  23. package/src/bus/postgres.ts +32 -19
  24. package/src/bus.ts +7 -4
  25. package/src/{capture.mjs → capture.ts} +34 -1
  26. package/src/changes-sql.d.mts +8 -2
  27. package/src/changes-sql.mjs +26 -10
  28. package/src/changes.ts +165 -66
  29. package/src/channel/connections.ts +216 -0
  30. package/src/channel/socket.ts +396 -0
  31. package/src/channel-fake.ts +19 -4
  32. package/src/channel.ts +120 -492
  33. package/src/client.tsx +9 -2
  34. package/src/contract/describe.mjs +231 -0
  35. package/src/contract.mjs +144 -157
  36. package/src/data/cache.ts +354 -0
  37. package/src/data/definitions.ts +94 -0
  38. package/src/data/draft.ts +104 -0
  39. package/src/data/foundation.ts +95 -0
  40. package/src/data/live-api.ts +22 -0
  41. package/src/data/mutation.ts +138 -0
  42. package/src/data/primitives.ts +135 -0
  43. package/src/data/query.ts +106 -0
  44. package/src/data/transport.ts +121 -0
  45. package/src/data/types.ts +303 -0
  46. package/src/data.ts +69 -894
  47. package/src/db/bun-sqlite.d.ts +4 -1
  48. package/src/db/changes-mysql.ts +61 -21
  49. package/src/db/changes-postgres.ts +197 -118
  50. package/src/db/changes-sqlite.ts +131 -64
  51. package/src/db/core.ts +38 -13
  52. package/src/db/index.ts +20 -4
  53. package/src/db/mysql.ts +22 -6
  54. package/src/db/postgres.ts +49 -13
  55. package/src/db/sqlite.ts +53 -27
  56. package/src/derive.d.mts +4 -1
  57. package/src/derive.mjs +136 -39
  58. package/src/dev-runtime.mjs +8 -4
  59. package/src/exec/errors.ts +57 -0
  60. package/src/exec/landing.ts +66 -0
  61. package/src/exec/scope.ts +174 -0
  62. package/src/exec/touch.ts +80 -0
  63. package/src/exec/validation.ts +176 -0
  64. package/src/exec.ts +191 -341
  65. package/src/extension.ts +8 -2
  66. package/src/first-paint.ts +26 -9
  67. package/src/form/value.ts +15 -0
  68. package/src/form.tsx +132 -43
  69. package/src/hub/channels.ts +102 -0
  70. package/src/hub/connections.ts +147 -0
  71. package/src/hub/delivery.ts +62 -0
  72. package/src/hub/feed.ts +168 -0
  73. package/src/hub/presence.ts +64 -0
  74. package/src/hub/route.ts +158 -0
  75. package/src/hub/subscriptions.ts +212 -0
  76. package/src/hub/topic.ts +98 -0
  77. package/src/hub/tracked.ts +12 -0
  78. package/src/hub/types.ts +190 -0
  79. package/src/hub/view.ts +147 -0
  80. package/src/hub.ts +132 -758
  81. package/src/index.ts +84 -10
  82. package/src/inline.mjs +356 -0
  83. package/src/inspect/compare.mjs +162 -0
  84. package/src/inspect/routes.mjs +154 -0
  85. package/src/inspect/samples.mjs +59 -0
  86. package/src/inspect/url.mjs +2 -0
  87. package/src/inspect.mjs +146 -187
  88. package/src/link/election.ts +178 -0
  89. package/src/link/lines.ts +27 -0
  90. package/src/link.ts +90 -166
  91. package/src/list-drizzle.ts +104 -18
  92. package/src/list.ts +127 -36
  93. package/src/listen.ts +32 -6
  94. package/src/live/api.ts +18 -0
  95. package/src/live/backoff.ts +6 -0
  96. package/src/live/frames.ts +42 -0
  97. package/src/live/read.ts +77 -0
  98. package/src/live/sub-types.ts +24 -0
  99. package/src/live/transport.ts +86 -0
  100. package/src/live/types.ts +52 -0
  101. package/src/live-fake.ts +20 -5
  102. package/src/live-target.mjs +112 -37
  103. package/src/live.ts +116 -225
  104. package/src/media.ts +8 -1
  105. package/src/migrations.mjs +14 -6
  106. package/src/mux.ts +161 -64
  107. package/src/og/DefaultCard.tsx +40 -9
  108. package/src/og/fonts.mjs +16 -8
  109. package/src/og/images.mjs +43 -22
  110. package/src/og/index.mjs +119 -43
  111. package/src/og/native.mjs +11 -4
  112. package/src/range.mjs +15 -3
  113. package/src/router.ts +56 -6
  114. package/src/routes.mjs +73 -22
  115. package/src/run/admission.ts +80 -0
  116. package/src/run/boot.tsx +115 -0
  117. package/src/run/cache-presentation.ts +67 -0
  118. package/src/run/cache.ts +254 -0
  119. package/src/run/helpers.ts +197 -0
  120. package/src/run/listeners.ts +155 -0
  121. package/src/run/navigation.ts +382 -0
  122. package/src/run/page-skeleton.tsx +35 -0
  123. package/src/run/prefetch.ts +128 -0
  124. package/src/run/retain-view.ts +18 -0
  125. package/src/run/shape-preparation.ts +97 -0
  126. package/src/run/shape-styles.ts +72 -0
  127. package/src/run/source-context/pin.ts +124 -0
  128. package/src/run/source-context.ts +311 -0
  129. package/src/run.tsx +50 -178
  130. package/src/rust/common.ts +163 -0
  131. package/src/rust/native.ts +282 -0
  132. package/src/rust.ts +103 -229
  133. package/src/server/card.tsx +65 -0
  134. package/src/server/compression.ts +115 -0
  135. package/src/server/core.ts +70 -0
  136. package/src/server/dispatch.ts +70 -0
  137. package/src/server/form.ts +85 -0
  138. package/src/server/functions.ts +129 -0
  139. package/src/server/handler.ts +39 -0
  140. package/src/server/head.ts +96 -0
  141. package/src/server/media.ts +60 -0
  142. package/src/server/navigation-cache.ts +80 -0
  143. package/src/server/navigation.tsx +85 -0
  144. package/src/server/page-cold.ts +95 -0
  145. package/src/server/page-stream.tsx +181 -0
  146. package/src/server/page-types.ts +51 -0
  147. package/src/server/page-warm.ts +130 -0
  148. package/src/server/page.ts +69 -0
  149. package/src/server/payload.ts +89 -0
  150. package/src/server/prerender.ts +60 -0
  151. package/src/server/preview.tsx +67 -0
  152. package/src/server/render.ts +167 -0
  153. package/src/server/request.ts +98 -0
  154. package/src/server/resolver.ts +228 -0
  155. package/src/server/routes.ts +150 -0
  156. package/src/server/services.ts +69 -0
  157. package/src/server/text.ts +29 -0
  158. package/src/server/types.ts +160 -0
  159. package/src/server.tsx +82 -971
  160. package/src/shape.mjs +30 -6
  161. package/src/share/display.ts +50 -0
  162. package/src/share/network.ts +119 -0
  163. package/src/share/receive.ts +70 -0
  164. package/src/share/state.ts +30 -0
  165. package/src/share/tab-queue.ts +28 -0
  166. package/src/share-server.ts +128 -43
  167. package/src/share.ts +103 -209
  168. package/src/sheets.mjs +30 -18
  169. package/src/sidecar/build.mjs +170 -0
  170. package/src/sidecar/cargo.mjs +164 -0
  171. package/src/sidecar/common.mjs +17 -0
  172. package/src/sidecar/dev-build.mjs +91 -0
  173. package/src/sidecar/dev-watch.mjs +88 -0
  174. package/src/sidecar/manifest.mjs +19 -0
  175. package/src/sidecar/process.mjs +58 -0
  176. package/src/sidecar/refresh.mjs +19 -0
  177. package/src/sidecar.mjs +75 -416
  178. package/src/socket/check.ts +50 -0
  179. package/src/socket/deliver.ts +16 -0
  180. package/src/socket/publish.ts +56 -0
  181. package/src/socket/rooms.ts +41 -0
  182. package/src/socket/route.ts +79 -0
  183. package/src/socket/shared.ts +26 -0
  184. package/src/socket/types.ts +48 -0
  185. package/src/socket.ts +205 -194
  186. package/src/stream.ts +10 -1
  187. package/src/verify/app.ts +39 -0
  188. package/src/verify/cleanup.ts +27 -0
  189. package/src/verify/database.ts +11 -0
  190. package/src/verify/live.ts +63 -0
  191. package/src/verify/notify.ts +35 -0
  192. package/src/verify/skip.ts +19 -0
  193. package/src/verify/support.ts +149 -0
  194. package/src/verify/timing.ts +15 -0
  195. package/src/verify/trigger.ts +14 -0
  196. package/src/verify.ts +77 -176
  197. package/src/view.tsx +96 -11
  198. package/src/vite/asset-bytes.mjs +95 -0
  199. package/src/vite/assets.mjs +117 -0
  200. package/src/vite/config-hooks.mjs +166 -0
  201. package/src/vite/dev-hooks.mjs +103 -0
  202. package/src/vite/dev.mjs +102 -0
  203. package/src/vite/entries.mjs +145 -0
  204. package/src/vite/module-hooks.mjs +288 -0
  205. package/src/vite/route-modules.mjs +289 -0
  206. package/src/vite/server-functions.mjs +222 -0
  207. package/src/vite/shape-entry-css.mjs +187 -0
  208. package/src/vite/shape-override.mjs +226 -0
  209. package/src/vite/shared.mjs +51 -0
  210. package/src/vite/sheets.mjs +92 -0
  211. package/src/vite/state.mjs +89 -0
  212. package/src/vite/syntax.mjs +139 -0
  213. package/src/vite.mjs +152 -853
  214. 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
+ }