@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.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +51 -10
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +97 -168
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +113 -35
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -174
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -964
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
@@ -0,0 +1,95 @@
1
+ // Render first when no remembered head can be sent yet.
2
+ import { LAYER_ORDER, pageStyles, type keptSheets } from '../inline.mjs'
3
+ import { capture, speculate } from '../capture'
4
+ import { whole } from './compression'
5
+ import { withHeaders } from './request'
6
+ import { esc } from './text'
7
+ import { finish, pageShell, render, rest, shaped, table, top } from './page-stream'
8
+ import { HOLD, type Head, type PageState } from './page-types'
9
+
10
+ type Styles = ReturnType<typeof pageStyles>
11
+ type Shape = ReturnType<typeof shaped>
12
+ type Table = ReturnType<typeof table>
13
+
14
+ function coldHead(
15
+ p: PageState,
16
+ shape: Shape,
17
+ t: Table,
18
+ styles: Styles | undefined,
19
+ capturing: boolean,
20
+ ) {
21
+ return (
22
+ top(p) +
23
+ shape.preloads +
24
+ // A streamed page's entry is at the end of what the shell sends, so its script tag is found late.
25
+ (p.assets.entry && shape.streaming
26
+ ? `<link rel="modulepreload" href="${esc(p.assets.entry)}">\n`
27
+ : '') +
28
+ (t
29
+ ? `<style>${LAYER_ORDER}${p.assets.pieces!.layers}${t.rules(p.stream.shell)}</style>\n`
30
+ : styles!.head) +
31
+ (capturing ? `<script>${capture(shape.now)}</script>\n` + speculate(shape) : '') +
32
+ p.dev +
33
+ (p.assets.entry && !shape.streaming
34
+ ? `<script type="module" src="${esc(p.assets.entry)}"></script>\n`
35
+ : '') +
36
+ '</head>\n<body>\n' +
37
+ pageShell(p, shape, t ? t.links : '')
38
+ )
39
+ }
40
+
41
+ /** Render first and send the head and shell together. */
42
+ export async function coldPage(
43
+ p: PageState,
44
+ known: Head | null | undefined,
45
+ cold: string,
46
+ kept: ReturnType<typeof keptSheets>,
47
+ ) {
48
+ const t0 = performance.now()
49
+ p.stream = render(p)
50
+ await p.stream.settle() // queries read outside a Boundary have nowhere to stream to
51
+ await p.stream.hold(p.options.hold ?? HOLD)
52
+ const ms = performance.now() - t0
53
+ p.complete(p.r, p.stream.shell)
54
+ const shape = shaped(p)
55
+ const t = table(p, shape)
56
+ const styles = t
57
+ ? undefined
58
+ : pageStyles(p.assets, shape.sheets, p.stream.shell, {
59
+ head: p.site.head,
60
+ have: kept,
61
+ keep: p.request.headers.has('cookie'),
62
+ })
63
+ const capturing = !!p.options.lazy || shape.streaming
64
+ if (known !== null && !p.options.dev)
65
+ p.remember(styles?.links ? cold + ' w' : cold, {
66
+ ...shape,
67
+ ms,
68
+ split: styles?.split ?? false,
69
+ found: styles?.found() ?? new Set(),
70
+ capture: capturing,
71
+ fold: !!shape.parts.fold.length,
72
+ })
73
+ const head = coldHead(p, shape, t, styles, capturing)
74
+ const init = {
75
+ status: p.status,
76
+ headers: {
77
+ 'content-type': 'text/html; charset=utf-8',
78
+ 'cache-control': 'no-cache',
79
+ ...(shape.parts.fold.length && { vary: 'Save-Data, Sec-CH-Prefers-Reduced-Data' }),
80
+ },
81
+ }
82
+ // No Boundary is waiting, so there's nothing to stream: the page goes out in one piece.
83
+ if (!shape.streaming) {
84
+ const res = new Response(null, init)
85
+ whole.set(res, head + (await finish(p, false)))
86
+ return withHeaders(res, p.scope)
87
+ }
88
+ const body = new ReadableStream<Uint8Array>({
89
+ async start(controller) {
90
+ controller.enqueue(p.enc.encode(head))
91
+ await rest(p, { controller, styles: t || styles!, streaming: true })
92
+ },
93
+ })
94
+ return withHeaders(new Response(body, init), p.scope)
95
+ }
@@ -0,0 +1,181 @@
1
+ // The render's shell, incremental data, and remaining streamed chunks.
2
+ import { metaTags, renderToStream } from '@weft-js/core/ssr'
3
+ import { keyOf } from '../data'
4
+ import * as exec from '../exec'
5
+ import { hub } from '../hub'
6
+ import { App } from '../view'
7
+ import type { pageStyles } from '../inline.mjs'
8
+ import { cookieJar } from './request'
9
+ import { dataTags, pageData, payloadOf, scrubbed, wire } from './payload'
10
+ import {
11
+ islandsOf,
12
+ modulepreloads,
13
+ pageChunks,
14
+ pageSheets,
15
+ saveData,
16
+ tableStyles,
17
+ withLevels,
18
+ } from './render'
19
+ import { esc, json } from './text'
20
+ import type { PageState } from './page-types'
21
+
22
+ /** Cache entries the client hasn't got. */
23
+ function news(p: PageState) {
24
+ const fresh = wire(p.scope).filter((w) => !p.sent.has(keyOf(w[0], w[1])))
25
+ for (const w of fresh) p.sent.add(keyOf(w[0], w[1]))
26
+ return fresh
27
+ }
28
+
29
+ /** What the client hasn't been told of the render. */
30
+ function since(p: PageState) {
31
+ const d = p.stream.data()
32
+ const changed = <T,>(from: Record<string, T>, seen: Record<string, unknown>) => {
33
+ const out = Object.fromEntries(Object.entries(from).filter(([k, v]) => seen[k] !== v))
34
+ Object.assign(seen, out)
35
+ return out
36
+ }
37
+ const state = JSON.stringify(d?.s)
38
+ const fresh = state !== p.told.s
39
+ p.told.s = state
40
+ return {
41
+ r: scrubbed(changed(d?.r ?? {}, p.told.r), p.scope),
42
+ b: changed(d?.b ?? {}, p.told.b),
43
+ ...(fresh && d?.s && { s: d.s }),
44
+ }
45
+ }
46
+
47
+ /** Start the page render with its request-local stream state. */
48
+ export function render(p: PageState) {
49
+ return renderToStream(() => <App route={p.route} />, {
50
+ ...p.timing,
51
+ passes: p.passes,
52
+ shown: (e) => exec.shown(e, p.scope),
53
+ stalled: () =>
54
+ p.options.onDiagnostic?.({
55
+ code: 'E_STREAM',
56
+ severity: 'warning',
57
+ message: `${p.path}: a render stopped reading anything new, so what's still out is left to the client. Is a query's input different on every render, a \`Date.now()\` say?`,
58
+ }),
59
+ app: { cookies: cookieJar(p.request) },
60
+ landing: () => `(self.$wc=self.$wc||[]).push(${json({ c: news(p), ...since(p) })});`,
61
+ })
62
+ }
63
+
64
+ /** The head down to the fonts, after metadata is complete. */
65
+ /**
66
+ * A full load between pages that opt into cross-document view transitions (warp does) brings the browser's own
67
+ * transition. When the browser skips one ("invalid state"), its three promises reject, and the page reveals before the
68
+ * runtime's module runs: so this listens from the head, on every page that has code. Chrome can also reject one the
69
+ * page never sees (a skipped outgoing transition), so a rejection that says only that a transition was aborted or
70
+ * skipped is marked handled too; nothing else is touched.
71
+ */
72
+ const REVEAL = `<script>(f=>{["pagereveal","pageswap"].forEach(n=>addEventListener(n,e=>{var t=e.viewTransition;t&&[t.ready,t.finished,t.updateCallbackDone].forEach(q=>q.catch(f))}));addEventListener("unhandledrejection",e=>{/^Transition was (aborted|skipped)/.test(e.reason&&e.reason.message)&&e.preventDefault()})})(()=>{})</script>\n`
73
+
74
+ export function top(p: PageState) {
75
+ return (
76
+ `<!doctype html>\n<html${p.lang}>\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n` +
77
+ (p.assets.entry ? REVEAL : '') +
78
+ metaTags(p.r.head, p.url.pathname) +
79
+ '\n' +
80
+ (p.site.head ? p.site.head + '\n' : '') +
81
+ (p.assets.fonts ?? [])
82
+ .map(
83
+ (f) =>
84
+ `<link rel="preload" as="font" type="${esc(f.type)}" href="${esc(f.href)}" crossorigin>\n`,
85
+ )
86
+ .join('')
87
+ )
88
+ }
89
+
90
+ /** What this render's head needs, from its HTML. */
91
+ export function shaped(p: PageState) {
92
+ const payload = withLevels(payloadOf(p.r, false, p.options), p.stream.shell, p.statics, false)
93
+ const parts = islandsOf(p.stream.shell)
94
+ // A Boundary is waiting: the client starts now, not at the last byte.
95
+ const streaming = Object.values(p.stream.data()?.b ?? {}).includes('p')
96
+ const now = !parts.lazy.length || payload.m.some((m) => !m.s) || !!payload.i?.length
97
+ // The entry's own script tag already fetches it; preloads go above the styles.
98
+ const preloads = modulepreloads(
99
+ [
100
+ ...(p.assets.js ?? []),
101
+ ...pageChunks(payload, p.options.chunks, saveData(p.request) ? [] : parts.fold, now),
102
+ ],
103
+ p.assets,
104
+ )
105
+ return {
106
+ payload,
107
+ parts,
108
+ streaming,
109
+ now,
110
+ preloads,
111
+ sheets: pageSheets(p.assets.css, parts, p.options.chunks),
112
+ }
113
+ }
114
+
115
+ /** First-paint rules for this render's sheets. */
116
+ export function table(p: PageState, shape: ReturnType<typeof shaped>) {
117
+ return p.assets.pieces && tableStyles(p.assets.pieces, shape.sheets, p.site.head, p.lang)
118
+ }
119
+
120
+ /** From the app element to what remains to stream. */
121
+ export function pageShell(p: PageState, shape: ReturnType<typeof shaped>, after = '') {
122
+ return (
123
+ `<div id="app">${p.stream.shell}</div>\n` +
124
+ after +
125
+ `<script type="application/json" id="weft-route">${json(shape.payload)}</script>\n` +
126
+ (p.scope.form
127
+ ? `<script type="application/json" id="weft-form">${json(p.scope.form)}</script>\n`
128
+ : '') +
129
+ // Async, and after all it reads, so it can run while the stream goes on.
130
+ (shape.streaming
131
+ ? (since(p), dataTags({ ...pageData(p.stream.data(), p.scope)!, w: 1 }, news(p)))
132
+ : '') +
133
+ (p.assets.entry && shape.streaming
134
+ ? `<script type="module" async src="${esc(p.assets.entry)}"></script>\n`
135
+ : '')
136
+ )
137
+ }
138
+
139
+ /** Finish the page with the resolved cache and live cursor. */
140
+ export async function finish(p: PageState, streaming: boolean) {
141
+ const cached = wire(p.scope)
142
+ // Read after the queries ran: the first live connect replays what is published from here.
143
+ if (p.scope.live) await hub().ready()
144
+ const liveScript = p.scope.live
145
+ ? `<script type="application/json" id="weft-rt">${json({ c: hub().cursor(), b: p.options.build })}</script>\n`
146
+ : ''
147
+ // A client that starts after this finds the whole page, as if it had never streamed.
148
+ const stale = streaming
149
+ ? '<script>["weft-data","weft-cache"].forEach(function(i){(i=document.getElementById(i))&&i.remove()})</script>\n'
150
+ : ''
151
+ return (
152
+ stale + dataTags(pageData(p.stream.data(), p.scope), cached) + liveScript + '</body>\n</html>\n'
153
+ )
154
+ }
155
+
156
+ /** Flush each remaining chunk with its styles. */
157
+ export async function rest(
158
+ p: PageState,
159
+ {
160
+ controller,
161
+ styles,
162
+ streaming,
163
+ }: {
164
+ controller: ReadableStreamDefaultController<Uint8Array>
165
+ styles: Pick<ReturnType<typeof pageStyles>, 'late'>
166
+ streaming: boolean
167
+ },
168
+ ) {
169
+ try {
170
+ for await (const chunk of p.stream.chunks)
171
+ controller.enqueue(
172
+ p.enc.encode(
173
+ styles.late(pageSheets([], islandsOf(chunk), p.options.chunks), chunk) + chunk + '\n',
174
+ ),
175
+ )
176
+ } catch (e) {
177
+ p.options.onDiagnostic?.({ code: 'E_STREAM', severity: 'error', message: String(e) })
178
+ }
179
+ controller.enqueue(p.enc.encode(await finish(p, streaming)))
180
+ controller.close()
181
+ }
@@ -0,0 +1,51 @@
1
+ import type { Merged, Stream } from '@weft-js/core/ssr'
2
+ import type { createRouteState, RoutePayload } from '../router'
3
+ import type { Scope } from '../exec'
4
+ import type { HandlerOptions, Resolved } from './types'
5
+
6
+ /** A render this long (ms) sends its head first. */
7
+ export const EARLY_MS = 4
8
+ /** Boundary data that arrives within this hold goes in the page. */
9
+ export const HOLD = 25
10
+
11
+ /** A remembered head for a route shape. */
12
+ export interface Head {
13
+ ms: number
14
+ sheets: { href: string }[]
15
+ preloads: string
16
+ now: boolean
17
+ capture: boolean
18
+ fold: boolean
19
+ split: boolean
20
+ found: Set<string>
21
+ }
22
+
23
+ export interface PageDeps {
24
+ options: HandlerOptions
25
+ site: NonNullable<HandlerOptions['site']>
26
+ statics: Set<string>
27
+ timing: { timeout?: number }
28
+ passes: number
29
+ resolve: (path: string, request: Request) => Promise<Resolved>
30
+ stateOf: (r: Resolved, payload: RoutePayload) => ReturnType<typeof createRouteState>
31
+ complete: (r: Resolved, html?: string) => Merged
32
+ heads: Map<string, Head | null>
33
+ remember: (shape: string, head: Head) => void
34
+ }
35
+
36
+ export interface PageState extends PageDeps {
37
+ url: URL
38
+ request: Request
39
+ scope: Scope
40
+ path: string
41
+ r: Resolved
42
+ route: ReturnType<typeof createRouteState>
43
+ sent: Set<string>
44
+ told: { r: Record<string, unknown>; b: Record<string, unknown>; s?: string }
45
+ stream: Stream
46
+ assets: NonNullable<HandlerOptions['assets']>
47
+ lang: string
48
+ dev: string
49
+ status: number
50
+ enc: TextEncoder
51
+ }
@@ -0,0 +1,130 @@
1
+ // A known head can go out before the new render finishes.
2
+ import { LAYER_ORDER, pageStyles, type keptSheets } from '../inline.mjs'
3
+ import { capture, speculate } from '../capture'
4
+ import { withHeaders } from './request'
5
+ import { esc } from './text'
6
+ import { pageShell, render, rest, shaped, table, top } from './page-stream'
7
+ import { HOLD, type Head, type PageState } from './page-types'
8
+
9
+ type Styles = ReturnType<typeof pageStyles>
10
+
11
+ /** Render the shell behind a head that has already gone out. */
12
+ async function warmContent(
13
+ p: PageState,
14
+ known: Head,
15
+ sent: {
16
+ cold: string
17
+ set: number
18
+ styles: Styles | undefined
19
+ controller: ReadableStreamDefaultController<Uint8Array>
20
+ },
21
+ ) {
22
+ const { cold, set, styles, controller } = sent
23
+ const t0 = performance.now()
24
+ p.stream = render(p)
25
+ await p.stream.settle() // queries read outside a Boundary have nowhere to stream to
26
+ await p.stream.hold(p.options.hold ?? HOLD)
27
+ const ms = performance.now() - t0
28
+ const shape = shaped(p)
29
+ const t = table(p, shape)
30
+ const capturing = !!p.options.lazy || shape.streaming
31
+ // What this page has beyond the head: sheets, rules, and chunks that render didn't reach.
32
+ const have = new Set(known.preloads.split(/(?=<link )/))
33
+ const more = shape.preloads
34
+ .split(/(?=<link )/)
35
+ .filter((l) => l && !have.has(l))
36
+ .join('')
37
+ const rules = t && t.rules(p.stream.shell)
38
+ const lead =
39
+ (t ? (rules ? `<style>${rules}</style>\n` : '') : styles!.late(shape.sheets, p.stream.shell)) +
40
+ more +
41
+ (capturing && !known.capture
42
+ ? `<script>${capture(shape.now)}</script>\n` + speculate(shape)
43
+ : '')
44
+ controller.enqueue(
45
+ p.enc.encode(
46
+ lead +
47
+ pageShell(p, shape, t ? t.links : '') +
48
+ (p.assets.entry && !shape.streaming
49
+ ? `<script type="module" src="${esc(p.assets.entry)}"></script>\n`
50
+ : ''),
51
+ ),
52
+ )
53
+ // A render that set a header after they went can't use this: the route goes back to rendering first.
54
+ if ([...p.scope.headers].length !== set) {
55
+ p.heads.set(cold, null).set(cold + ' w', null)
56
+ p.options.onDiagnostic?.({
57
+ code: 'E_HEAD',
58
+ severity: 'warning',
59
+ message: `${p.path}: a render set a response header after the head was sent, so this route now renders before sending anything.`,
60
+ })
61
+ } else
62
+ p.remember(styles?.links ? cold + ' w' : cold, {
63
+ ...shape,
64
+ ms,
65
+ split: styles?.split ?? false,
66
+ found: styles?.found() ?? new Set(),
67
+ capture: capturing,
68
+ fold: !!shape.parts.fold.length,
69
+ preloads: [...have, ...more.split(/(?=<link )/)].filter(Boolean).join(''),
70
+ })
71
+ await rest(p, { controller, styles: t || styles!, streaming: shape.streaming })
72
+ }
73
+
74
+ /** Send the remembered head, then flush the render behind it. */
75
+ export async function warmPage(
76
+ p: PageState,
77
+ known: Head,
78
+ cold: string,
79
+ kept: ReturnType<typeof keptSheets>,
80
+ ) {
81
+ p.complete(p.r)
82
+ // A session read can set cookies, which have to be in the headers: read it before they go.
83
+ if (p.request.headers.has('cookie')) await p.scope.ctx().catch(() => {})
84
+ const set = [...p.scope.headers].length
85
+ const styles = p.assets.pieces
86
+ ? undefined
87
+ : pageStyles(p.assets, known.sheets, '', {
88
+ head: p.site.head,
89
+ seen: known,
90
+ have: kept,
91
+ keep: p.request.headers.has('cookie'),
92
+ })
93
+ // With a table, the rules come with the shell; here there is only the layer order.
94
+ const head =
95
+ top(p) +
96
+ known.preloads +
97
+ (p.assets.entry ? `<link rel="modulepreload" href="${esc(p.assets.entry)}">\n` : '') +
98
+ (styles ? styles.head : `<style>${LAYER_ORDER}${p.assets.pieces!.layers}</style>\n`) +
99
+ (known.capture ? `<script>${capture(known.now)}</script>\n` + speculate(known) : '') +
100
+ p.dev +
101
+ '</head>\n<body>\n'
102
+ const init = {
103
+ status: p.status,
104
+ headers: {
105
+ 'content-type': 'text/html; charset=utf-8',
106
+ 'cache-control': 'no-cache',
107
+ ...(known.fold && { vary: 'Save-Data, Sec-CH-Prefers-Reduced-Data' }),
108
+ },
109
+ }
110
+ const body = new ReadableStream<Uint8Array>({
111
+ async start(controller) {
112
+ controller.enqueue(p.enc.encode(head))
113
+ try {
114
+ await warmContent(p, known, { cold, set, styles, controller })
115
+ } catch (e) {
116
+ p.heads.set(cold, null).set(cold + ' w', null)
117
+ p.options.onDiagnostic?.({
118
+ code: 'E_HEAD',
119
+ severity: 'error',
120
+ message: `${p.path}: ${String(e)}`,
121
+ })
122
+ controller.enqueue(
123
+ p.enc.encode('<p>Something went wrong. <a href="">Reload</a></p>\n</body>\n</html>\n'),
124
+ )
125
+ controller.close()
126
+ }
127
+ },
128
+ })
129
+ return withHeaders(new Response(body, init), p.scope)
130
+ }
@@ -0,0 +1,69 @@
1
+ // Select a remembered head or render before sending the page.
2
+ import { keptSheets, sheetId } from '../inline.mjs'
3
+ import type { Scope } from '../exec'
4
+ import { payloadOf } from './payload'
5
+ import { saveData } from './render'
6
+ import { redirectBody } from './request'
7
+ import { esc, moved } from './text'
8
+ import { coldPage } from './page-cold'
9
+ import { leftToPage } from './head'
10
+ import { warmPage } from './page-warm'
11
+ import { EARLY_MS, type PageDeps, type PageState } from './page-types'
12
+
13
+ /** The page renderer for one handler and its remembered heads. */
14
+ export function makePage(deps: PageDeps) {
15
+ return async function page(url: URL, request: Request, scope: Scope): Promise<Response> {
16
+ const path = url.pathname + url.search
17
+ const r = await deps.resolve(path, request)
18
+ if (r.redirect) {
19
+ const headers = new Headers(r.redirectHeaders)
20
+ headers.set('location', r.redirect.href)
21
+ if (!headers.has('cache-control')) headers.set('cache-control', 'no-cache')
22
+ return redirectBody(
23
+ new Response(moved(r.redirect.href), { status: r.redirect.status, headers }),
24
+ scope,
25
+ 'text/html; charset=utf-8',
26
+ r.redirect.href,
27
+ )
28
+ }
29
+ scope.started = true // from here a redirect can't change the status (Q007)
30
+ // The render reads raw deferred data; the page carries the settled part.
31
+ const route = deps.stateOf(r, payloadOf(r, false))
32
+ const sent = new Set<string>()
33
+ const told: PageState['told'] = { r: {}, b: {} }
34
+ const assets = deps.options.assets ?? {}
35
+ const p: PageState = {
36
+ ...deps,
37
+ url,
38
+ request,
39
+ scope,
40
+ path,
41
+ r,
42
+ route,
43
+ sent,
44
+ told,
45
+ stream: undefined!,
46
+ assets,
47
+ lang: deps.site.locale ? ` lang="${esc(deps.site.locale.replace('_', '-'))}"` : '',
48
+ dev: deps.options.dev
49
+ ? '<script type="module" src="/@vite/client"></script>\n<script type="module" src="/@weft-agent.js"></script>\n'
50
+ : '',
51
+ status: r.status === 200 && scope.form ? scope.form.status : r.status,
52
+ enc: new TextEncoder(),
53
+ }
54
+ // A browser with the page's sheets gets the remembered link-only head. A table has no separate head.
55
+ const cold = `${p.status} ${saveData(request) ? 1 : 0} ${r.levels.map((l) => l.id).join()}`
56
+ const kept = assets.pieces ? undefined : keptSheets(request.headers.get('cookie'))
57
+ const warm = kept && p.heads.get(cold + ' w')
58
+ const shape = warm && warm.sheets.every((c) => kept.has(sheetId(c.href))) ? cold + ' w' : cold
59
+ const known = p.heads.get(shape)
60
+ // A level left words to the page, which are only known after it renders.
61
+ const worded =
62
+ !!r.metas &&
63
+ r.status === 200 &&
64
+ (leftToPage(r.source!.title) || leftToPage(r.source!.description))
65
+ // Only a render slow enough to wait on sends its remembered head first.
66
+ if (known && known.ms >= EARLY_MS && !worded) return warmPage(p, known, cold, kept)
67
+ return coldPage(p, known, cold, kept)
68
+ }
69
+ }
@@ -0,0 +1,89 @@
1
+ import type { Stream } from '@weft-js/core/ssr'
2
+ import type { RoutePayload } from '../router'
3
+ import type { Wire } from '../data'
4
+ import * as exec from '../exec'
5
+ import type { Scope } from '../exec'
6
+ import { selectRouteAssets } from '../asset-receipt.mjs'
7
+ import { json, thenable } from './text'
8
+ import type { HandlerOptions, Resolved } from './types'
9
+
10
+ export function fill(pattern: string, params: Record<string, string>, splat?: string) {
11
+ return pattern.replace(/\$([\w$]*)/g, (_, n) =>
12
+ n ? encodeURIComponent(params[n]!) : (params[splat!] ?? ''),
13
+ )
14
+ }
15
+
16
+ /** The data without its deferred (promise) fields, and their names. */
17
+ export function split(raw: unknown): { data: unknown; d?: string[] } {
18
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return { data: raw }
19
+ const fields = raw as Record<string, unknown>
20
+ const d = Object.keys(raw).filter((k) => thenable(fields[k]))
21
+ if (!d.length) return { data: raw }
22
+ for (const k of d) Promise.resolve(fields[k]).catch(() => {}) // a deferred failure surfaces in its Boundary
23
+ return { data: Object.fromEntries(Object.entries(raw).filter(([k]) => !d.includes(k))), d }
24
+ }
25
+
26
+ /** What `meta` and cards see: the settled data. Reading a deferred field is H007 (it's still streaming). */
27
+ export function settledView(
28
+ raw: unknown,
29
+ deferred: string[] | undefined,
30
+ id: string,
31
+ options: HandlerOptions,
32
+ ) {
33
+ if (!deferred?.length) return raw
34
+ return new Proxy(raw as object, {
35
+ get(t, k, r) {
36
+ if (typeof k === 'string' && deferred.includes(k)) {
37
+ const message = `H007 \`meta\` (or a card) of route ${id} reads \`data.${k}\`, a deferred field that is still streaming. Await it in \`load\`, or leave it out of \`meta\`.`
38
+ options.onDiagnostic?.({ code: 'H007', severity: 'error', message, route: id })
39
+ throw Object.assign(new Error(message), { code: 'H007' })
40
+ }
41
+ return Reflect.get(t, k, r)
42
+ },
43
+ })
44
+ }
45
+
46
+ export function payloadOf(r: Resolved, withHead: boolean, options?: HandlerOptions): RoutePayload {
47
+ const { jsonLd: _, ...head } = r.head // only a first response carries it (`metaTags`)
48
+ const assetBytes = selectRouteAssets(
49
+ options?.routeAssetBytes,
50
+ options?.build,
51
+ r.levels.map((level) => level.id),
52
+ )
53
+ return {
54
+ m: r.levels.map(({ id, params, data, d, t }) => ({
55
+ id,
56
+ params,
57
+ data,
58
+ ...(d && { d }),
59
+ ...(t && { t }),
60
+ })),
61
+ p: r.path,
62
+ s: r.status,
63
+ ...(assetBytes && { a: assetBytes }),
64
+ ...(withHead && { h: head }),
65
+ }
66
+ }
67
+
68
+ export const dataTags = (data: unknown, cached: Wire[]) =>
69
+ `<script type="application/json" id="weft-data">${json(data)}</script>\n` +
70
+ (cached.length
71
+ ? `<script type="application/json" id="weft-cache">${json(cached)}</script>\n`
72
+ : '')
73
+
74
+ /** A render's data as a page may carry it: what failed says only what `exec.shown` let through. */
75
+ export function pageData(data: ReturnType<Stream['data']>, scope: Scope) {
76
+ return data && { ...data, r: scrubbed(data.r, scope) }
77
+ }
78
+
79
+ export const scrubbed = (r: Record<string, { v?: unknown; e?: string }>, scope: Scope) =>
80
+ Object.fromEntries(
81
+ Object.entries(r).map(([k, v]) => [k, v.e === undefined ? v : { e: exec.said(v.e, scope) }]),
82
+ )
83
+
84
+ /** The request's resolved queries, as the client cache seeds them (failures are left for the client to retry). */
85
+ export function wire(scope: Scope): Wire[] {
86
+ return [...scope.entries.values()]
87
+ .filter((e) => e.done && e.e === undefined)
88
+ .map((e) => [e.id, e.input, e.v, e.tags])
89
+ }
@@ -0,0 +1,60 @@
1
+ import type { Merged } from '@weft-js/core/ssr'
2
+ import { pathPages } from '../derive.mjs'
3
+ import { esc } from './text'
4
+ import { fill } from './payload'
5
+ import type { Manifest, Resolved, RouteModule } from './types'
6
+
7
+ export function makePrerender(
8
+ manifest: Manifest,
9
+ mod: (file?: string) => RouteModule | undefined,
10
+ resolve: (path: string, request: Request) => Promise<Resolved>,
11
+ finished: (r: Resolved, request: Request) => Promise<Merged>,
12
+ ) {
13
+ /** Every path `weft build` prerenders: pages from `prerender`, and the card of each page that has a generated one. */
14
+ async function prerenderPaths(request: Request): Promise<string[]> {
15
+ const paths: string[] = []
16
+ for (const route of manifest.routes) {
17
+ if (route.kind !== 'page') continue
18
+ const p = mod(route.file)?.prerender
19
+ const given = typeof p === 'function' ? await p() : p
20
+ const list: Record<string, string>[] =
21
+ given === true ? [{}] : (given ?? (route.params.length ? [] : [{}]))
22
+ for (const params of list) {
23
+ const path = fill(route.pattern, params, route.splat)
24
+ const r = await resolve(path, request)
25
+ if (given && r.status === 200) paths.push(path)
26
+ const image = (await finished(r, request)).og.image
27
+ if (
28
+ r.status === 200 &&
29
+ typeof image === 'object' &&
30
+ image &&
31
+ image.url.startsWith('/_weft/og/')
32
+ )
33
+ paths.push(image.url.replace(/\?.*/, ''))
34
+ }
35
+ }
36
+ return paths
37
+ }
38
+
39
+ /**
40
+ * Build mode (research 20 §4.3): the card URLs `weft build` prerenders, queued through Nitro's `x-nitro-prerender`.
41
+ * It's a header, so a big site's list comes a page at a time: too long a header is an error that Nitro's client
42
+ * logs and carries on from, and the build ends with no cards.
43
+ */
44
+ let listing: Promise<string[][]> | undefined
45
+ async function prerenderList(request: Request, url: URL): Promise<Response> {
46
+ const pages = await (listing ??= prerenderPaths(request).then((p) => pathPages(p)))
47
+ const n = Number(url.searchParams.get('page') ?? 0)
48
+ const paths = [
49
+ ...(pages[n] ?? []),
50
+ ...(n + 1 < pages.length ? [`/_weft/og-routes?page=${n + 1}`] : []),
51
+ ]
52
+ // HTML, because Nitro's prerenderer only follows links (and this header) from HTML responses.
53
+ const list = paths.map((p) => `<a href="${esc(p)}">${esc(p)}</a>`).join('\n')
54
+ return new Response(`<!doctype html>\n<meta name="robots" content="noindex">\n${list}\n`, {
55
+ headers: { 'content-type': 'text/html; charset=utf-8', 'x-nitro-prerender': paths.join(',') },
56
+ })
57
+ }
58
+
59
+ return prerenderList
60
+ }