@weft-js/start 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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 +38 -8
  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 +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  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 +90 -166
  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 +112 -37
  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 -178
  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 -971
  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
package/src/og/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- // renderCard for weft (research 20 §4, §9; promoted from spikes/og/card): weft SSR HTML → PNG/JPEG/WebP/SVG, on
1
+ // renderCard for weft (research 20 §4, §9; promoted from archive/evals-2026-11:spikes/og/card): weft SSR HTML → PNG/JPEG/WebP/SVG, on
2
2
  // Takumi, with what Satori did better added around it:
3
3
  // - strict styles: every declaration is checked once against the renderer (an invalid value would fail the whole
4
4
  // card, an unknown property is silently dropped) and reported as H005; production drops the bad declaration and
@@ -19,8 +19,9 @@ export const PREFLIGHT = '*,*::before,*::after{box-sizing:border-box}'
19
19
  export const SIZE = { width: 1200, height: 630 }
20
20
  const TYPES = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', svg: 'image/svg+xml' }
21
21
 
22
- const stripComments = html => html.replace(/<!--[\s\S]*?-->/g, '')
23
- const kebab = k => k.replace(/^Webkit/, '-webkit-').replace(/[A-Z]/g, c => '-' + c.toLowerCase())
22
+ const stripComments = (html) => html.replace(/<!--[\s\S]*?-->/g, '')
23
+ const kebab = (k) =>
24
+ k.replace(/^Webkit/, '-webkit-').replace(/[A-Z]/g, (c) => '-' + c.toLowerCase())
24
25
  // A value no CSS grammar accepts: a property the renderer knows rejects it, and one it doesn't know takes anything.
25
26
  // (An identifier won't do: `text-overflow`, `grid-template-columns` and `font-family` accept custom idents.)
26
27
  const PROBE = '}{'
@@ -37,10 +38,21 @@ const renderers = new Map() // fonts array → the renderer registered with them
37
38
  * @param {{ name: string; data: Uint8Array; weight?: number; style?: 'normal' | 'italic' }[]} [fonts]
38
39
  * @param {{ strict?: boolean; onDiagnostic?: (d: { code: string; severity: string; message: string }) => void; images?: unknown }} [options]
39
40
  */
40
- export async function renderCard(html, size = SIZE, fonts = [], { strict = false, onDiagnostic, images } = {}) {
41
+ export async function renderCard(
42
+ html,
43
+ size = SIZE,
44
+ fonts = [],
45
+ { strict = false, onDiagnostic, images } = {},
46
+ ) {
41
47
  let r = renderers.get(fonts)
42
- if (!r) renderers.set(fonts, (r = native().then(backend => createCardRenderer({ backend, fonts, images }))))
43
- const out = await (await r).render(html, { width: size.width ?? SIZE.width, height: size.height ?? SIZE.height, strict })
48
+ if (!r)
49
+ renderers.set(
50
+ fonts,
51
+ (r = native().then((backend) => createCardRenderer({ backend, fonts, images }))),
52
+ )
53
+ const out = await (
54
+ await r
55
+ ).render(html, { width: size.width ?? SIZE.width, height: size.height ?? SIZE.height, strict })
44
56
  for (const d of out.diagnostics) onDiagnostic?.(d)
45
57
  return out.body
46
58
  }
@@ -53,19 +65,97 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
53
65
  const renderer = new backend.Renderer()
54
66
  for (const font of await normalizeFonts(fonts)) await renderer.registerFont(font)
55
67
  const cache = imageCache(imageOptions.cacheBytes)
68
+ const prepare = createPreparation(renderer)
69
+
70
+ return {
71
+ /** Diagnostics only (`weft check`, dev): nothing is fetched or rasterized. */
72
+ async check(html) {
73
+ return (await prepare(html)).diagnostics
74
+ },
75
+
76
+ /**
77
+ * Renders a card. Never throws on a card's styles or images: those come back as diagnostics, and the card renders
78
+ * without them. `strict` (dev, `weft build`) throws on any error-severity diagnostic instead.
79
+ */
80
+ async render(
81
+ html,
82
+ { width = SIZE.width, height = SIZE.height, format = 'png', quality, strict = false } = {},
83
+ ) {
84
+ const { node, css, diagnostics, imageNodes } = await prepare(html)
85
+ const { images, failed } = await loadImages(
86
+ imageNodes.map((n) => n.src),
87
+ { cache, ...imageOptions },
88
+ )
89
+ for (const f of failed) {
90
+ diagnostics.push({
91
+ code: 'H008',
92
+ severity: 'warning',
93
+ message: `Card image ${f.src} didn't load (${f.reason}); the card renders without it.`,
94
+ })
95
+ for (const n of imageNodes)
96
+ if (n.src === f.src)
97
+ Object.assign(n, {
98
+ type: 'container',
99
+ src: undefined,
100
+ style: {
101
+ width: n.width && `${n.width}px`,
102
+ height: n.height && `${n.height}px`,
103
+ ...n.style,
104
+ },
105
+ })
106
+ }
107
+ if (strict && diagnostics.some((d) => d.severity === 'error')) {
108
+ throw Object.assign(
109
+ new Error(
110
+ diagnostics
111
+ .filter((d) => d.severity === 'error')
112
+ .map((d) => `${d.code} ${d.message} (${d.element})`)
113
+ .join('\n'),
114
+ ),
115
+ { diagnostics },
116
+ )
117
+ }
118
+ const options = { width, height, images, css: [PREFLIGHT, ...css] }
119
+ const body =
120
+ format === 'svg'
121
+ ? await renderer.renderSvg(node, options)
122
+ : new Uint8Array(
123
+ await renderer.render(node, {
124
+ ...options,
125
+ format,
126
+ ...(quality != null && { quality }),
127
+ }),
128
+ )
129
+ return { body, type: TYPES[format], diagnostics }
130
+ },
131
+ }
132
+ }
133
+
134
+ function createPreparation(renderer) {
56
135
  const verdicts = new Map() // `${prop}\0${value}` → null (fine) | { severity, message }
57
136
 
58
- const accepts = async style => {
59
- try { await renderer.measure({ type: 'container', style }, { width: 1, height: 1 }); return true } catch { return false }
137
+ const accepts = async (style) => {
138
+ try {
139
+ await renderer.measure({ type: 'container', style }, { width: 1, height: 1 })
140
+ return true
141
+ } catch {
142
+ return false
143
+ }
60
144
  }
61
145
  async function judge(prop, value) {
62
146
  const key = `${prop}\0${value}`
63
147
  if (verdicts.has(key)) return verdicts.get(key)
64
148
  let v = null
65
149
  if (!(await accepts({ [prop]: value }))) {
66
- v = { severity: 'error', message: `\`${kebab(prop)}: ${value}\` isn't a value the card renderer accepts; the declaration is dropped.` }
150
+ v = {
151
+ severity: 'error',
152
+ message: `\`${kebab(prop)}: ${value}\` isn't a value the card renderer accepts; the declaration is dropped.`,
153
+ }
67
154
  } else if (await accepts({ [prop]: PROBE })) {
68
- v = { severity: 'warning', message: `\`${kebab(prop)}\` isn't a property the card renderer supports; it has no effect.` }
155
+ v = {
156
+ severity: 'warning',
157
+ message: `\`${kebab(prop)}\` isn't a property the card renderer supports; it has no effect.`,
158
+ }
69
159
  }
70
160
  verdicts.set(key, v)
71
161
  return v
@@ -74,7 +164,8 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
74
164
  /** Walks the tree: checks styles (dropping invalid declarations), and collects image nodes. */
75
165
  async function prepare(html) {
76
166
  const { node, css } = fromHtml(stripComments(html))
77
- const diagnostics = [], imageNodes = []
167
+ const diagnostics = [],
168
+ imageNodes = []
78
169
  const walk = async (n, path) => {
79
170
  if (n.type === 'image') imageNodes.push(n)
80
171
  for (const [prop, value] of Object.entries(n.style ?? {})) {
@@ -83,39 +174,14 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
83
174
  diagnostics.push({ code: 'H005', severity: v.severity, message: v.message, element: path })
84
175
  if (v.severity === 'error') delete n.style[prop]
85
176
  }
86
- for (const [i, c] of (n.children ?? []).entries()) await walk(c, `${path} > ${c.tagName ?? c.type}:nth-child(${i + 1})`)
177
+ for (const [i, c] of (n.children ?? []).entries())
178
+ await walk(c, `${path} > ${c.tagName ?? c.type}:nth-child(${i + 1})`)
87
179
  }
88
180
  await walk(node, node.tagName ?? node.type)
89
181
  return { node, css, diagnostics, imageNodes }
90
182
  }
91
183
 
92
- return {
93
- /** Diagnostics only (`weft check`, dev): nothing is fetched or rasterized. */
94
- async check(html) {
95
- return (await prepare(html)).diagnostics
96
- },
97
-
98
- /**
99
- * Renders a card. Never throws on a card's styles or images: those come back as diagnostics, and the card renders
100
- * without them. `strict` (dev, `weft build`) throws on any error-severity diagnostic instead.
101
- */
102
- async render(html, { width = SIZE.width, height = SIZE.height, format = 'png', quality, strict = false } = {}) {
103
- const { node, css, diagnostics, imageNodes } = await prepare(html)
104
- const { images, failed } = await loadImages(imageNodes.map(n => n.src), { cache, ...imageOptions })
105
- for (const f of failed) {
106
- diagnostics.push({ code: 'H008', severity: 'warning', message: `Card image ${f.src} didn't load (${f.reason}); the card renders without it.` })
107
- for (const n of imageNodes) if (n.src === f.src) Object.assign(n, { type: 'container', src: undefined, style: { width: n.width && `${n.width}px`, height: n.height && `${n.height}px`, ...n.style } })
108
- }
109
- if (strict && diagnostics.some(d => d.severity === 'error')) {
110
- throw Object.assign(new Error(diagnostics.filter(d => d.severity === 'error').map(d => `${d.code} ${d.message} (${d.element})`).join('\n')), { diagnostics })
111
- }
112
- const options = { width, height, images, css: [PREFLIGHT, ...css] }
113
- const body = format === 'svg'
114
- ? await renderer.renderSvg(node, options)
115
- : new Uint8Array(await renderer.render(node, { ...options, format, ...(quality != null && { quality }) }))
116
- return { body, type: TYPES[format], diagnostics }
117
- },
118
- }
184
+ return prepare
119
185
  }
120
186
 
121
187
  /**
@@ -124,7 +190,10 @@ export async function createCardRenderer({ backend, fonts = [], images: imageOpt
124
190
  */
125
191
  export function cardResponse({ body, type }, { dev = false } = {}) {
126
192
  return new Response(body, {
127
- headers: { 'content-type': type, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' },
193
+ headers: {
194
+ 'content-type': type,
195
+ 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable',
196
+ },
128
197
  })
129
198
  }
130
199
 
@@ -133,14 +202,21 @@ export function cardResponse({ body, type }, { dev = false } = {}) {
133
202
  * browser shows what the renderer draws.
134
203
  */
135
204
  export function previewHtml(html, { fonts = [], width = SIZE.width, height = SIZE.height } = {}) {
136
- const faces = fonts.map(f => `@font-face{font-family:"${f.name}";font-weight:${f.weight ?? 400};font-style:${f.style ?? 'normal'};src:url(data:font/${fontMime(f.data)};base64,${base64(f.data)})}`).join('')
205
+ const faces = fonts
206
+ .map(
207
+ (f) =>
208
+ `@font-face{font-family:"${f.name}";font-weight:${f.weight ?? 400};font-style:${f.style ?? 'normal'};src:url(data:font/${fontMime(f.data)};base64,${base64(f.data)})}`,
209
+ )
210
+ .join('')
137
211
  return `<!doctype html><meta charset="utf-8"><style>${faces}${PREFLIGHT}html,body{margin:0}#card{width:${width}px;height:${height}px;overflow:hidden;display:flex}</style><div id="card">${stripComments(html)}</div>`
138
212
  }
139
213
 
140
- const fontMime = d => ({ wOFF: 'woff', wOF2: 'woff2', OTTO: 'otf' })[String.fromCharCode(...d.subarray(0, 4))] ?? 'ttf'
214
+ const fontMime = (d) =>
215
+ ({ wOFF: 'woff', wOF2: 'woff2', OTTO: 'otf' })[String.fromCharCode(...d.subarray(0, 4))] ?? 'ttf'
141
216
 
142
217
  function base64(bytes) {
143
218
  let s = ''
144
- for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
219
+ for (let i = 0; i < bytes.length; i += 0x8000)
220
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
145
221
  return btoa(s)
146
222
  }
package/src/og/native.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  // `@takumi-rs/core`: Node, Bun and Deno servers, `weft build`, Vercel and Netlify Node functions. A file of its own so
2
- // an edge bundle (`@takumi-rs/wasm`, spikes/og/card/wasm.mjs) never pulls in the native addon. Both builds draw byte-identical cards (§9.4).
2
+ // an edge bundle (`@takumi-rs/wasm`, archive/evals-2026-11:spikes/og/card/wasm.mjs) never pulls in the native addon. Both builds draw byte-identical cards (§9.4).
3
3
  export async function native() {
4
4
  // Takumi's addon in the same process as a later Rolldown pass kills a Windows `weft build` (0xC0000005) before the
5
5
  // server is written. WEFT_START_DIR means a build, and the cards come out the same.
6
- if (globalThis.process?.platform === 'win32' && globalThis.process.env.WEFT_START_DIR) return wasmBackend(globalThis.process.env.WEFT_START_DIR)
6
+ if (globalThis.process?.platform === 'win32' && globalThis.process.env.WEFT_START_DIR)
7
+ return wasmBackend(globalThis.process.env.WEFT_START_DIR)
7
8
  const { Renderer } = await import('@takumi-rs/core').catch(linked)
8
9
  return { name: 'native', Renderer }
9
10
  }
@@ -21,9 +22,15 @@ async function linked(error) {
21
22
 
22
23
  /** The wasm renderer, found from `start` (the @weft-js/start directory) because a prerender runs from a bundle. */
23
24
  export async function wasmBackend(start) {
24
- const [{ createRequire }, { readFile }, { dirname, join }, { pathToFileURL }] = await Promise.all(['node:module', 'node:fs/promises', 'node:path', 'node:url'].map(m => import(/* @vite-ignore */ m)))
25
+ const [{ createRequire }, { readFile }, { dirname, join }, { pathToFileURL }] = await Promise.all(
26
+ ['node:module', 'node:fs/promises', 'node:path', 'node:url'].map(
27
+ (m) => import(/* @vite-ignore */ m),
28
+ ),
29
+ )
25
30
  const root = dirname(dirname(createRequire(`${start}/package.json`).resolve('@takumi-rs/wasm')))
26
- const { default: init, Renderer } = await import(/* @vite-ignore */ pathToFileURL(join(root, 'dist/export.mjs')).href)
31
+ const { default: init, Renderer } = await import(
32
+ /* @vite-ignore */ pathToFileURL(join(root, 'dist/export.mjs')).href
33
+ )
27
34
  await init({ module_or_path: await readFile(join(root, 'pkg/takumi_wasm_bg.wasm')) })
28
35
  return { name: 'wasm', Renderer }
29
36
  }
package/src/range.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // Byte ranges for built video and audio. Nitro's static handler has none and runs before any middleware, and Safari
2
2
  // won't play a video without them, so this Nitro plugin answers ranged requests for those files in front of the app.
3
- export default nitro => {
3
+ export default (nitro) => {
4
4
  const app = nitro.fetch
5
5
  nitro.fetch = async (request, ...rest) => {
6
6
  const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.get('range') ?? '')
@@ -13,7 +13,19 @@ export default nitro => {
13
13
  const bytes = Buffer.from(await readAsset(id))
14
14
  const start = Number(range[1])
15
15
  const end = Math.min(range[2] ? Number(range[2]) : Infinity, bytes.length - 1)
16
- if (start > end) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${bytes.length}` } })
17
- return new Response(bytes.subarray(start, end + 1), { status: 206, headers: { 'content-type': asset.type, 'accept-ranges': 'bytes', 'content-range': `bytes ${start}-${end}/${bytes.length}`, 'cache-control': 'public, max-age=31536000, immutable' } })
16
+ if (start > end)
17
+ return new Response(null, {
18
+ status: 416,
19
+ headers: { 'content-range': `bytes */${bytes.length}` },
20
+ })
21
+ return new Response(bytes.subarray(start, end + 1), {
22
+ status: 206,
23
+ headers: {
24
+ 'content-type': asset.type,
25
+ 'accept-ranges': 'bytes',
26
+ 'content-range': `bytes ${start}-${end}/${bytes.length}`,
27
+ 'cache-control': 'public, max-age=31536000, immutable',
28
+ },
29
+ })
18
30
  }
19
31
  }
package/src/router.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  // renders its new route, and everything inside it goes. One state per request on the server (passed down as a
6
6
  // prop), one per tab on the client.
7
7
 
8
- import { state, untrack, type State,type SsrData } from '@weft-js/core'
8
+ import { state, untrack, type State, type SsrData } from '@weft-js/core'
9
9
  import type { Merged } from '@weft-js/core/ssr'
10
10
  import type { Wire } from './data'
11
+ import type { PreparedRouteShape } from './run/shape-preparation'
11
12
 
12
13
  /**
13
14
  * One level as the server sends it: `d` lists the data's deferred fields (promises on the server, research 25 §4),
@@ -31,12 +32,33 @@ export interface Level {
31
32
  data: State<unknown>
32
33
  /** A static level's HTML: empty for the page's own; undefined for a level with client code. */
33
34
  html: State<string | undefined>
35
+ /** A temporary incoming shape for this outlet, while its route is pending. */
36
+ shape: State<PreparedRouteShape[] | undefined>
34
37
  x?: string | undefined
35
38
  }
36
39
 
37
40
  /** A route, layout or island: its props are its own, so nothing here can call one without a cast. */
38
41
  export type Component = (props: never) => unknown
39
42
 
43
+ /** Sizes are emitted, uncompressed bytes in Vite's recorded static closure. */
44
+ export interface RouteAssetCosts {
45
+ build: string
46
+ scope: 'vite-static-closure'
47
+ levels: { id: string; bytes: number | null }[]
48
+ routeBytes: number | null
49
+ eagerBytes: number | null
50
+ combinedBytes: number | null
51
+ }
52
+
53
+ export interface AssetReceipt {
54
+ build: string | null
55
+ version: 1
56
+ scope: 'vite-static-closure'
57
+ files: Record<string, { bytes: number | null; kind: string }>
58
+ routes: Record<string, { files: string[]; bytes: number | null } | null>
59
+ eager: { files: string[]; bytes: number | null } | null
60
+ }
61
+
40
62
  export interface RouteState {
41
63
  levels: Level[]
42
64
  /** The current path (pathname + search). */
@@ -44,6 +66,10 @@ export interface RouteState {
44
66
  status: State<number>
45
67
  /** Loaded route components by id. */
46
68
  comps: Record<string, Component>
69
+ /** Cost of the current match, when the client and receipt are from the same build. */
70
+ assetBytes?: RouteAssetCosts | null
71
+ /** Prepare a route's provided shape CSS until the caller releases it. */
72
+ prepareShape?: (id: string) => PreparedRouteShape | null
47
73
  /** Client: shows a static level's HTML, with the next level inside it. */
48
74
  mount?: (html: string, depth: number, next: () => unknown) => unknown
49
75
  /** Around a level's component, at the same point on both sides: the server's markers, the client's key scope. */
@@ -64,19 +90,24 @@ export interface RoutePayload {
64
90
  i?: string[]
65
91
  /** A navigation's page data, for those islands. */
66
92
  b?: SsrData | null
93
+ /** Emitted asset cost for these levels, when a matching build receipt exists. */
94
+ a?: RouteAssetCosts
67
95
  }
68
96
 
69
97
  /** The route state of the render in progress: a server pass sets it as it starts (passes are synchronous). */
70
98
  export const active: { route?: RouteState } = {}
71
99
 
72
- export function createRouteState(payload: RoutePayload, comps: Record<string, Component>): RouteState {
100
+ export function createRouteState(
101
+ payload: RoutePayload,
102
+ comps: Record<string, Component>,
103
+ ): RouteState {
73
104
  const r: RouteState = { levels: [], path: state(payload.p), status: state(payload.s), comps }
74
105
  payload.m.forEach((m, d) => {
75
106
  const lv = level(r, d)
76
107
  lv.id.value = m.id
77
108
  lv.params.value = m.params
78
109
  lv.data.value = m.data
79
- lv.html.value = m.s ? m.h ?? '' : undefined
110
+ lv.html.value = m.s ? (m.h ?? '') : undefined
80
111
  lv.x = m.x
81
112
  })
82
113
  return r
@@ -84,7 +115,13 @@ export function createRouteState(payload: RoutePayload, comps: Record<string, Co
84
115
 
85
116
  /** Level `d`, made on first use (an empty level renders nothing). */
86
117
  export function level(r: RouteState, d: number): Level {
87
- return (r.levels[d] ??= { id: state<string | undefined>(undefined), params: state({}), data: state<unknown>(undefined), html: state<string | undefined>(undefined) })
118
+ return (r.levels[d] ??= {
119
+ id: state<string | undefined>(undefined),
120
+ params: state({}),
121
+ data: state<unknown>(undefined),
122
+ html: state<string | undefined>(undefined),
123
+ shape: state<PreparedRouteShape[] | undefined>(undefined),
124
+ })
88
125
  }
89
126
 
90
127
  const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
@@ -125,10 +162,17 @@ export function applyMatches(r: RouteState, matches: Match[], path: string, stat
125
162
  */
126
163
  export const client: {
127
164
  route?: RouteState
128
- navigate?: (href: string, options?: NavigateOptions & { replace?: boolean | undefined }) => Promise<void>
129
- seed?: (list: Wire[]) => void
165
+ navigate?: (
166
+ href: string,
167
+ options?: NavigateOptions & { replace?: boolean | undefined },
168
+ ) => Promise<void>
169
+ /** `since`: what `now` said when the navigation left, so entries a write has passed don't land. */
170
+ seed?: (list: Wire[], since?: number) => void
130
171
  seeds?: Wire[] | undefined
172
+ now?: () => number
131
173
  invalidated?: (tags: string[]) => void
174
+ /** Clear navigation state before a session-changing reload. */
175
+ sessionChanged?: () => void
132
176
  /** Dev: server code changed, so whatever a query read may have too. */
133
177
  refreshed?: () => void
134
178
  } = {}
@@ -141,6 +185,12 @@ export interface NavigateOptions {
141
185
  scroll?: false
142
186
  }
143
187
 
188
+ /** This tab's line to the app's other tabs: a list of tags a write invalidated, or `0` when the session changed. */
189
+ export const peers =
190
+ typeof document !== 'undefined' && typeof BroadcastChannel !== 'undefined'
191
+ ? new BroadcastChannel('weft:tags')
192
+ : undefined
193
+
144
194
  /** Go to `href` without a page load (a full load before `start()` ran, or for another origin). */
145
195
  export function navigate(href: string, options?: NavigateOptions): Promise<void> {
146
196
  if (client.navigate) return client.navigate(href, options)
package/src/routes.mjs CHANGED
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'
17
17
  import { join, relative } from 'node:path'
18
18
 
19
19
  const EXT = /\.(tsx|jsx|ts|js)$/
20
- const posix = p => p.split('\\').join('/')
20
+ const posix = (p) => p.split('\\').join('/')
21
21
 
22
22
  /**
23
23
  * @typedef {{ id: string, file: string, layouts: string[], path: string, pattern: string, params: string[], splat?: string, rank: number[], card?: string, kind: 'page' | '404' | 'error' }} RouteEntry
@@ -31,25 +31,31 @@ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
31
31
  const out = { root, dir: posix(relative(root, dir)), routes: [], layouts: [], problems: [] }
32
32
  if (!existsSync(dir)) return out
33
33
  const files = []
34
- const walk = d => {
34
+ const walk = (d) => {
35
35
  for (const name of readdirSync(d).sort()) {
36
36
  const abs = join(d, name)
37
37
  if (statSync(abs).isDirectory()) {
38
38
  if (!name.startsWith('_') && !name.startsWith('.')) walk(abs)
39
- } else if (EXT.test(name) && !/\.(test|spec)\.|\.d\.ts$/.test(name)) files.push(posix(relative(dir, abs)))
39
+ } else if (EXT.test(name) && !/\.(test|spec)\.|\.d\.ts$/.test(name))
40
+ files.push(posix(relative(dir, abs)))
40
41
  }
41
42
  }
42
43
  walk(dir)
43
44
 
44
- const rel = f => posix(relative(root, join(dir, f)))
45
+ const rel = (f) => posix(relative(root, join(dir, f)))
45
46
  const layoutFile = new Map() // route dir ('' = root) → its _layout file
46
47
  for (const f of files) {
47
48
  const m = /^(?:(.*)\/)?_layout\.[jt]sx?$/.exec(f)
48
49
  if (m) layoutFile.set(m[1] ?? '', f)
49
50
  }
50
- const layoutOf = new Map([...layoutFile.keys()].map(d => [d, `layout:${d}`]))
51
+ const layoutOf = new Map([...layoutFile.keys()].map((d) => [d, `layout:${d}`]))
51
52
  for (const [d, f] of [...layoutFile].sort((a, b) => a[0].length - b[0].length)) {
52
- out.layouts.push({ id: layoutOf.get(d), file: rel(f), dir: d, parent: chain(d, layoutOf).at(-2) })
53
+ out.layouts.push({
54
+ id: layoutOf.get(d),
55
+ file: rel(f),
56
+ dir: d,
57
+ parent: chain(d, layoutOf).at(-2),
58
+ })
53
59
  }
54
60
 
55
61
  const cards = new Map()
@@ -60,17 +66,41 @@ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
60
66
  else cards.set(m[1], rel(f))
61
67
  }
62
68
 
69
+ appendRoutes(files, out, rel, layoutOf, cards)
70
+ out.routes.sort((a, b) => cmpRank(a.rank, b.rank) || a.id.localeCompare(b.id))
71
+ for (const [b, file] of cards)
72
+ if (!out.routes.some((r) => r.card === file))
73
+ out.problems.push({ file, message: `no route \`${b}\` for this card` })
74
+ return out
75
+ }
76
+
77
+ function appendRoutes(files, out, rel, layoutOf, cards) {
63
78
  for (const f of files) {
64
79
  const base = f.replace(EXT, '')
65
80
  const name = base.split('/').pop()
66
81
  if (base.endsWith('.og') || name === '_layout' || name === '_default') continue
67
82
  const d = base.includes('/') ? base.slice(0, base.lastIndexOf('/')) : ''
68
83
  if (name === '404' || name === '_error') {
69
- if (d) { out.problems.push({ file: rel(f), message: `\`${name}\` is only read at the routes root for now; this one is ignored` }); continue }
70
- out.routes.push({ id: name === '404' ? '404' : 'error', file: rel(f), layouts: chain('', layoutOf), path: '', pattern: '', params: [], rank: [], kind: name === '404' ? '404' : 'error' })
84
+ if (d) {
85
+ out.problems.push({
86
+ file: rel(f),
87
+ message: `\`${name}\` is only read at the routes root for now; this one is ignored`,
88
+ })
89
+ continue
90
+ }
91
+ out.routes.push({
92
+ id: name === '404' ? '404' : 'error',
93
+ file: rel(f),
94
+ layouts: chain('', layoutOf),
95
+ path: '',
96
+ pattern: '',
97
+ params: [],
98
+ rank: [],
99
+ kind: name === '404' ? '404' : 'error',
100
+ })
71
101
  continue
72
102
  }
73
- if (name.startsWith('_') || d.split('/').some(s => s.startsWith('_'))) continue
103
+ if (name.startsWith('_') || d.split('/').some((s) => s.startsWith('_'))) continue
74
104
  const segs = base === 'index' ? [] : base.split('/')
75
105
  if (segs.at(-1) === 'index') segs.pop()
76
106
  const params = []
@@ -86,19 +116,32 @@ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
86
116
  } else if (/[[\]]/.test(s)) bad = true
87
117
  else parts.push(s)
88
118
  }
89
- if (bad) { out.problems.push({ file: rel(f), message: 'a segment mixes text and a `[param]`; use a whole segment `[name]`' }); continue }
90
- if (splat && parts.at(-1) !== '$') { out.problems.push({ file: rel(f), message: '`[...rest]` must be the last segment' }); continue }
119
+ if (bad) {
120
+ out.problems.push({
121
+ file: rel(f),
122
+ message: 'a segment mixes text and a `[param]`; use a whole segment `[name]`',
123
+ })
124
+ continue
125
+ }
126
+ if (splat && parts.at(-1) !== '$') {
127
+ out.problems.push({ file: rel(f), message: '`[...rest]` must be the last segment' })
128
+ continue
129
+ }
91
130
  const pattern = '/' + parts.join('/')
92
131
  out.routes.push({
93
- id: pattern, file: rel(f), layouts: chain(d, layoutOf), path: '/' + segs.join('/'), pattern, params, splat,
132
+ id: pattern,
133
+ file: rel(f),
134
+ layouts: chain(d, layoutOf),
135
+ path: '/' + segs.join('/'),
136
+ pattern,
137
+ params,
138
+ splat,
94
139
  // Static segments outrank params, params outrank a splat (router-core's order; the client table uses it).
95
- rank: parts.map(p => (p === '$' ? 0 : p.startsWith('$') ? 1 : 2)),
96
- card: cards.get(base), kind: 'page',
140
+ rank: parts.map((p) => (p === '$' ? 0 : p.startsWith('$') ? 1 : 2)),
141
+ card: cards.get(base),
142
+ kind: 'page',
97
143
  })
98
144
  }
99
- out.routes.sort((a, b) => cmpRank(a.rank, b.rank) || a.id.localeCompare(b.id))
100
- for (const [b, file] of cards) if (!out.routes.some(r => r.card === file)) out.problems.push({ file, message: `no route \`${b}\` for this card` })
101
- return out
102
145
  }
103
146
 
104
147
  /** Layout ids from the root down to `d`. */
@@ -114,8 +157,10 @@ function chain(d, layoutOf) {
114
157
 
115
158
  function cmpRank(a, b) {
116
159
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
117
- if (a[i] === undefined) return 1 // shorter after: `/users/new` is tried before `/users/$id/edit`… only on ties
118
- if (b[i] === undefined) return -1
160
+ // Shorter after: `/users/new` is tried before `/users/$id/edit`… only on ties. But a splat also matches nothing, so
161
+ // the route it would extend comes first: `/docs` before `/docs/$`, or the splat takes `/docs` itself.
162
+ if (a[i] === undefined) return b[i] === 0 && b.length === i + 1 ? -1 : 1
163
+ if (b[i] === undefined) return a[i] === 0 && a.length === i + 1 ? 1 : -1
119
164
  if (a[i] !== b[i]) return b[i] - a[i]
120
165
  }
121
166
  return 0
@@ -131,12 +176,18 @@ export function fillPath(pattern, params) {
131
176
 
132
177
  /** A route's file path as authors write it in types: `src/routes/users/[id].tsx` → `/users/[id]`, `index.tsx` → `/`. */
133
178
  export function filePath(manifest, route) {
134
- return '/' + route.file.slice(manifest.dir.length + 1).replace(EXT, '').replace(/(^|\/)index$/, '')
179
+ return (
180
+ '/' +
181
+ route.file
182
+ .slice(manifest.dir.length + 1)
183
+ .replace(EXT, '')
184
+ .replace(/(^|\/)index$/, '')
185
+ )
135
186
  }
136
187
 
137
188
  /** `.weft/routes.d.ts`: every route path, so `RouteProps<…, '/users/[id]'>` and `Meta<…, path>` only take real ones. */
138
189
  export function routeTypes(manifest) {
139
- const pages = manifest.routes.filter(r => r.kind === 'page')
140
- const lines = pages.map(r => ` '${filePath(manifest, r)}': true`)
190
+ const pages = manifest.routes.filter((r) => r.kind === 'page')
191
+ const lines = pages.map((r) => ` '${filePath(manifest, r)}': true`)
141
192
  return `// Generated by weft from ${manifest.dir}. Do not edit.\nimport '@weft-js/start'\ndeclare module '@weft-js/start' {\n interface RouteFiles {\n${lines.join('\n')}\n }\n}\n`
142
193
  }
@@ -0,0 +1,80 @@
1
+ import type { ClientRoutes } from '../run'
2
+ import { guess, ours } from './helpers'
3
+
4
+ /** Admit a link only after the app's own event handlers have run. */
5
+ export function admitLink(event: Event, routes: ClientRoutes) {
6
+ if (event.defaultPrevented) return
7
+ if (
8
+ event instanceof MouseEvent &&
9
+ (event.metaKey ||
10
+ event.ctrlKey ||
11
+ event.shiftKey ||
12
+ event.altKey ||
13
+ ((event.type === 'pointerdown' || event.type === 'click') && event.button !== 0))
14
+ )
15
+ return
16
+ if (event.type === 'pointerdown' && 'isPrimary' in event && !event.isPrimary) return
17
+ const path = event.composedPath()
18
+ const anchor = path.find((item) => item instanceof HTMLAnchorElement)
19
+ if (
20
+ !anchor ||
21
+ path.some(
22
+ (item) =>
23
+ item instanceof Element &&
24
+ item.matches('[data-navigation="off"], [contenteditable]:not([contenteditable="false"])'),
25
+ )
26
+ )
27
+ return
28
+ try {
29
+ const url = ours(anchor)
30
+ if (!url || !['http:', 'https:'].includes(url.protocol)) return
31
+ if (!guess(routes, url.pathname).length) return
32
+ return { anchor, url, noop: url.href === location.href }
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ /** Pointer admission and its click fallback; the returned disposer removes only these listeners. */
39
+ export function routeClicks(
40
+ doc: Document,
41
+ routes: ClientRoutes,
42
+ go: (href: string) => void | Promise<void>,
43
+ ) {
44
+ let accepted: { id: number; anchor: HTMLAnchorElement } | undefined
45
+ const down = (event: PointerEvent) => {
46
+ accepted = undefined
47
+ const target = admitLink(event, routes)
48
+ if (!target) return
49
+ accepted = { id: event.pointerId, anchor: target.anchor }
50
+ if (!target.noop) void Promise.resolve(go(target.url.href)).catch(() => {})
51
+ }
52
+ const cancel = (event: PointerEvent) => {
53
+ if (accepted?.id === event.pointerId) accepted = undefined
54
+ }
55
+ const click = (event: MouseEvent) => {
56
+ const record = accepted
57
+ accepted = undefined
58
+ const pointer = event as PointerEvent
59
+ if (
60
+ record &&
61
+ event.detail > 0 &&
62
+ (pointer.pointerId === record.id || event.composedPath().includes(record.anchor))
63
+ ) {
64
+ event.preventDefault()
65
+ return
66
+ }
67
+ const target = admitLink(event, routes)
68
+ if (!target) return
69
+ event.preventDefault()
70
+ if (!target.noop) void go(target.url.href)
71
+ }
72
+ doc.addEventListener('pointerdown', down)
73
+ doc.addEventListener('pointercancel', cancel)
74
+ doc.addEventListener('click', click)
75
+ return () => {
76
+ doc.removeEventListener('pointerdown', down)
77
+ doc.removeEventListener('pointercancel', cancel)
78
+ doc.removeEventListener('click', click)
79
+ }
80
+ }