@weft-js/start 0.0.0-stage → 0.1.1

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 (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +41 -2
  3. package/package.json +107 -4
  4. package/src/app.mjs +93 -0
  5. package/src/auth/better-auth.ts +58 -0
  6. package/src/auth/drizzle.ts +17 -0
  7. package/src/auth.ts +47 -0
  8. package/src/boot.ts +44 -0
  9. package/src/builtin.tsx +19 -0
  10. package/src/bus/postgres.ts +91 -0
  11. package/src/bus.ts +48 -0
  12. package/src/capture.d.mts +1 -0
  13. package/src/capture.mjs +15 -0
  14. package/src/changes-sql.d.mts +14 -0
  15. package/src/changes-sql.mjs +99 -0
  16. package/src/changes.ts +253 -0
  17. package/src/channel-fake.ts +55 -0
  18. package/src/channel.ts +622 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +312 -0
  21. package/src/data.ts +929 -0
  22. package/src/db/bun-sqlite.d.ts +10 -0
  23. package/src/db/changes-mysql.ts +86 -0
  24. package/src/db/changes-postgres.ts +159 -0
  25. package/src/db/changes-sqlite.ts +116 -0
  26. package/src/db/core.ts +63 -0
  27. package/src/db/index.ts +27 -0
  28. package/src/db/mysql.ts +67 -0
  29. package/src/db/postgres.ts +65 -0
  30. package/src/db/sqlite.ts +139 -0
  31. package/src/derive.d.mts +16 -0
  32. package/src/derive.mjs +140 -0
  33. package/src/dev-runtime.mjs +42 -0
  34. package/src/exec.ts +519 -0
  35. package/src/extension.ts +38 -0
  36. package/src/first-paint.ts +57 -0
  37. package/src/form.tsx +134 -0
  38. package/src/gate.css +6 -0
  39. package/src/hub.ts +822 -0
  40. package/src/index.ts +106 -0
  41. package/src/inline.mjs +2 -0
  42. package/src/inspect.mjs +280 -0
  43. package/src/link.ts +461 -0
  44. package/src/list-drizzle.ts +50 -0
  45. package/src/list.ts +224 -0
  46. package/src/listen.ts +28 -0
  47. package/src/live-fake.ts +42 -0
  48. package/src/live-target.mjs +147 -0
  49. package/src/live.ts +499 -0
  50. package/src/media.ts +43 -0
  51. package/src/migrations.mjs +19 -0
  52. package/src/mux-session.ts +6 -0
  53. package/src/mux.ts +135 -0
  54. package/src/og/DefaultCard.tsx +60 -0
  55. package/src/og/fonts.mjs +64 -0
  56. package/src/og/images.mjs +54 -0
  57. package/src/og/index.mjs +146 -0
  58. package/src/og/native.mjs +29 -0
  59. package/src/range.mjs +19 -0
  60. package/src/router.ts +149 -0
  61. package/src/routes.mjs +142 -0
  62. package/src/run.tsx +202 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1021 -0
  65. package/src/shape.d.mts +3 -0
  66. package/src/shape.mjs +73 -0
  67. package/src/share-server.ts +208 -0
  68. package/src/share.ts +503 -0
  69. package/src/sheets.mjs +75 -0
  70. package/src/sidecar.mjs +503 -0
  71. package/src/socket.ts +389 -0
  72. package/src/stream.ts +13 -0
  73. package/src/verify.ts +251 -0
  74. package/src/view.tsx +47 -0
  75. package/src/vite.mjs +899 -0
package/src/mux.ts ADDED
@@ -0,0 +1,135 @@
1
+ // The server's half of `link.ts`: one WebSocket, many lines, each a connection of its own. A `c` line is an `attach`, a
2
+ // `r` line is a hub connection, and neither knows it isn't alone, so `id`, `exceptSelf`, `left`, buckets and session
3
+ // re-checks stay per tab. `limits.connections` counts the socket, which is what it was protecting.
4
+
5
+ import { StartError } from './data'
6
+ import type { Scope } from './exec'
7
+ import { sessionOf } from './mux-session'
8
+ import { hub, validBody, type Conn, type Hub, type Sink } from './hub'
9
+ import { attach, type Attached, type Peer } from './socket'
10
+
11
+ const LINES = 64
12
+ const HIGH = 64 * 1024
13
+
14
+ interface Physical { send(text: string): void; close(code: number, reason?: string): void; backlog(): number }
15
+ type Open = { kind: 'c'; a: Promise<Attached> } | { kind: 'r'; c: Promise<Conn | undefined> }
16
+
17
+ const parsed = (text: string): unknown => {
18
+ try { return JSON.parse(text) } catch { return undefined }
19
+ }
20
+
21
+ const counts = new WeakMap<Hub, Map<string, number>>()
22
+ const byUser = (h: Hub) => {
23
+ let m = counts.get(h)
24
+ if (!m) counts.set(h, (m = new Map()))
25
+ return m
26
+ }
27
+
28
+ export interface Muxed { message(text: string): void; close(): void }
29
+
30
+ export async function muxed(peer: Physical, request: Request, scope: Scope, h: Hub = hub()): Promise<Muxed> {
31
+ const ctx = await scope.ctx()
32
+ const user = (ctx as { user?: { id?: string } }).user?.id
33
+ const users = byUser(h)
34
+ if (user !== undefined) {
35
+ if ((users.get(user) ?? 0) >= h.limits.connections) {
36
+ peer.close(1008, 'too many sockets')
37
+ scope.options.onDiagnostic?.({ code: 'R009', severity: 'error', message: `R009: user \`${user}\` already has ${h.limits.connections} sockets` })
38
+ return { message() {}, close() {} }
39
+ }
40
+ users.set(user, (users.get(user) ?? 0) + 1)
41
+ }
42
+ const lines = new Map<string, Open>()
43
+ const bucket = { tokens: h.limits.burst * 8, at: Date.now() }
44
+ let closed = false
45
+
46
+ /** Every tab has the per-socket bucket; this is what stops them adding up without end. */
47
+ const allowed = () => {
48
+ const now = Date.now(), rate = h.limits.perSecond * 8
49
+ bucket.tokens = Math.min(h.limits.burst * 8, bucket.tokens + ((now - bucket.at) * rate) / 1000)
50
+ bucket.at = now
51
+ return bucket.tokens-- >= 1
52
+ }
53
+ const end = (id: string, code: number, reason = '') => {
54
+ if (!lines.delete(id) || closed) return
55
+ peer.send(`x:${id}:${code}:${reason}`)
56
+ }
57
+
58
+ function openC(id: string) {
59
+ const p: Peer = { send: t => { if (lines.has(id)) peer.send(`f:${id}:${t}`) }, close: (code, reason) => end(id, code, reason), backlog: () => peer.backlog() }
60
+ lines.set(id, { kind: 'c', a: attach(p, request, scope, h, true).catch(() => {
61
+ end(id, 1011)
62
+ return { message() {}, close() {} } as unknown as Attached
63
+ }) })
64
+ }
65
+
66
+ function openR(id: string, hello: string) {
67
+ const body = parsed(hello)
68
+ if (!validBody(body) || body.k) return peer.send(`x:${id}:4000:${JSON.stringify({ code: 'Q002', message: 'a realtime line opened with a body of the wrong shape' })}`)
69
+ const sink: Sink = {
70
+ write: (event, data) => { if (lines.has(id)) peer.send(`r:${id}:${event}:${data}`) },
71
+ ping: () => { if (lines.has(id)) peer.send(`r:${id}:ping:`) },
72
+ backlog: () => peer.backlog(),
73
+ close: () => end(id, 1000),
74
+ }
75
+ const c = h.connect(request, scope, body, sink, true).catch(e => {
76
+ const out = e instanceof StartError ? { code: e.code, message: e.message } : { message: 'Realtime failed.' }
77
+ end(id, 4000, JSON.stringify(out))
78
+ return undefined
79
+ })
80
+ lines.set(id, { kind: 'r', c })
81
+ }
82
+
83
+ function shut(id: string, line: Open) {
84
+ lines.delete(id)
85
+ if (line.kind === 'c') void line.a.then(a => a.close())
86
+ else void line.c.then(c => c && h.close(c))
87
+ }
88
+
89
+ const drain = setInterval(() => {
90
+ if (peer.backlog() >= HIGH) return
91
+ for (const l of lines.values()) if (l.kind === 'r') void l.c.then(c => c && h.drain(c))
92
+ }, 100)
93
+ ;(drain as { unref?(): void }).unref?.()
94
+
95
+ peer.send(`h:${await sessionOf(ctx)}`)
96
+ return {
97
+ message(text) {
98
+ if (text === 'p') return peer.send('p')
99
+ if (text.length > h.limits.body + 64) return peer.close(1009, 'too big')
100
+ if (!allowed()) {
101
+ scope.options.onDiagnostic?.({ code: 'R008', severity: 'error', message: `R008: one browser sent over ${h.limits.perSecond * 8} messages a second` })
102
+ return peer.close(1008, 'rate limit')
103
+ }
104
+ const at = text.indexOf(':', 2)
105
+ const id = text.slice(2, at), rest = text.slice(at + 1)
106
+ const line = lines.get(id)
107
+ if (text[0] === 'f') {
108
+ if (line?.kind === 'c') void line.a.then(a => a.message(rest))
109
+ else if (line) {
110
+ let body: Body | undefined
111
+ try { body = JSON.parse(rest) } catch {}
112
+ if (validBody(body)) void line.c.then(c => c && h.change(c, request, scope.options, body)).catch(() => end(id, 1011))
113
+ }
114
+ } else if (text[0] === 'x') {
115
+ if (line) shut(id, line)
116
+ } else if (text[0] === 'o') {
117
+ if (line) return
118
+ if (lines.size >= LINES) return peer.send(`x:${id}:1008:too many lines`)
119
+ if (rest[0] === 'c') openC(id)
120
+ else if (rest[0] === 'r') openR(id, rest.slice(2))
121
+ }
122
+ },
123
+ close() {
124
+ if (closed) return
125
+ closed = true
126
+ clearInterval(drain)
127
+ for (const [id, line] of Array.from(lines)) shut(id, line)
128
+ if (user !== undefined) {
129
+ const n = (users.get(user) ?? 1) - 1
130
+ if (n) users.set(user, n)
131
+ else users.delete(user)
132
+ }
133
+ },
134
+ }
135
+ }
@@ -0,0 +1,60 @@
1
+ // The `image: 'auto'` default card (research 20 §4.1, §10): the page's title and description, the app's name and its
2
+ // address, on the app's look. Colours and faces come from `site.card`, which warp and the text plugin fill in; one
3
+ // image goes to every feed, so a card has one scheme, and each is checked against both kinds of feed.
4
+ import { untrack } from '@weft-js/core'
5
+ import type { Site } from '@weft-js/core/ssr'
6
+ import { cardTitleSize } from '../derive.mjs'
7
+
8
+ type CardColors = NonNullable<NonNullable<Site['card']>['light']>
9
+
10
+ export interface DefaultProps {
11
+ title: string
12
+ description: string
13
+ site: Site
14
+ path?: string
15
+ }
16
+
17
+ const SCHEMES: Record<'dark' | 'light', Required<CardColors>> = {
18
+ dark: { background: '#100f0b', text: '#ebe8e0', subtle: '#a8a59d', accent: '#5fcf97' },
19
+ light: { background: '#f7f4ec', text: '#1f1d18', subtle: '#615e56', accent: '#1f7a50' },
20
+ }
21
+
22
+ const family = (name: string | undefined) => (name ? `'${name}',Inter` : 'Inter')
23
+
24
+ /** The title without the template the site wraps around it: the card shows the site's name on its own. */
25
+ function heading(title: string, template: string | undefined) {
26
+ const [before = '', after = ''] = template?.split('%s') ?? []
27
+ return title.startsWith(before) && title.endsWith(after) && title.length > before.length + after.length ? title.slice(before.length, title.length - after.length) : title
28
+ }
29
+
30
+ /** Everything the card draws, worked out once: a card renders one time, to a string. */
31
+ function look({ title, description, site, path }: DefaultProps) {
32
+ const card = site.card ?? {}
33
+ const scheme = card.scheme ?? 'dark'
34
+ const c = { ...SCHEMES[scheme], ...card[scheme] }
35
+ for (const k of ['background', 'text', 'subtle', 'accent'] as const) if (card[k]) c[k] = card[k]!
36
+ const host = site.url?.replace(/^https?:\/\//, '').replace(/\/+$/, '')
37
+ const shown = heading(title, site.title)
38
+ return {
39
+ c, description, title: shown, name: site.name ?? '', size: cardTitleSize(shown),
40
+ display: family(card.fonts?.display?.family), body: family(card.fonts?.body?.family),
41
+ address: host && host + (path && path !== '/' ? path.replace(/[?#].*/, '') : ''),
42
+ }
43
+ }
44
+
45
+ export function DefaultCard(props: DefaultProps) {
46
+ const { c, title, description, name, size, display, body, address } = untrack(() => look(props))
47
+ return (
48
+ <div style={`display:flex;flex-direction:column;width:100%;height:100%;padding:68px 76px 60px;background-color:${c.background};color:${c.text};font-family:${body}`}>
49
+ <div style={`display:flex;align-items:center;font-family:${display};font-size:32px;font-weight:600;letter-spacing:-0.3px`}>
50
+ <div style={`width:22px;height:22px;border-radius:5px;background-color:${c.accent};margin-right:16px`} />
51
+ <span>{name}</span>
52
+ </div>
53
+ <div style="display:flex;flex-direction:column;justify-content:center;flex:1;min-height:0;padding:24px 0">
54
+ <div style={`display:block;font-family:${display};font-size:${size}px;font-weight:700;line-height:1.08;letter-spacing:-1.5px;overflow-wrap:anywhere;line-clamp:3`}>{title}</div>
55
+ {description ? <div style={`display:block;margin-top:26px;font-size:30px;line-height:1.38;color:${c.subtle};line-clamp:3`}>{description}</div> : null}
56
+ </div>
57
+ {address ? <div style={`display:flex;align-items:center;font-size:24px;color:${c.subtle};border-top:1px solid ${c.subtle}44;padding-top:22px`}>{address}</div> : null}
58
+ </div>
59
+ )
60
+ }
@@ -0,0 +1,64 @@
1
+ // Fonts in any format the web uses, for either Takumi build. Takumi native reads TTF, OTF, WOFF and WOFF2; the wasm
2
+ // build doesn't read WOFF (1.0), and Satori never read WOFF2. So WOFF is unpacked to its SFNT here (zlib per table,
3
+ // with the platform's DecompressionStream, which Node, Bun and workerd all have), and every build gets every format.
4
+
5
+ const tag = (b, o) => String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3])
6
+
7
+ /** 'woff' | 'woff2' | 'ttf' | 'otf' | null, from the file's magic number. */
8
+ export function fontFormat(bytes) {
9
+ const t = tag(bytes, 0)
10
+ if (t === 'wOFF') return 'woff'
11
+ if (t === 'wOF2') return 'woff2'
12
+ if (t === 'OTTO') return 'otf'
13
+ if (t === '\0\x01\0\0' || t === 'true') return 'ttf'
14
+ return null
15
+ }
16
+
17
+ async function inflate(bytes) {
18
+ const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream('deflate'))
19
+ return new Uint8Array(await new Response(stream).arrayBuffer())
20
+ }
21
+
22
+ /** WOFF 1.0 → the SFNT (TTF/OTF) it wraps (W3C WOFF 1.0 §4–5). */
23
+ export async function woffToSfnt(woff) {
24
+ const v = new DataView(woff.buffer, woff.byteOffset, woff.byteLength)
25
+ const numTables = v.getUint16(12)
26
+ const tables = []
27
+ for (let i = 0; i < numTables; i++) {
28
+ const at = 44 + i * 20
29
+ const offset = v.getUint32(at + 4), compLength = v.getUint32(at + 8), origLength = v.getUint32(at + 12)
30
+ const raw = woff.subarray(offset, offset + compLength)
31
+ tables.push({ tag: v.getUint32(at), checksum: v.getUint32(at + 16), data: compLength < origLength ? await inflate(raw) : raw })
32
+ }
33
+ let size = 12 + 16 * numTables
34
+ for (const t of tables) size += (t.data.length + 3) & ~3
35
+ const out = new Uint8Array(size)
36
+ const w = new DataView(out.buffer)
37
+ const log2 = Math.floor(Math.log2(numTables))
38
+ w.setUint32(0, v.getUint32(4)) // flavor
39
+ w.setUint16(4, numTables)
40
+ w.setUint16(6, 16 << log2)
41
+ w.setUint16(8, log2)
42
+ w.setUint16(10, numTables * 16 - (16 << log2))
43
+ let offset = 12 + 16 * numTables
44
+ tables.forEach((t, i) => {
45
+ const at = 12 + i * 16
46
+ w.setUint32(at, t.tag)
47
+ w.setUint32(at + 4, t.checksum)
48
+ w.setUint32(at + 8, offset)
49
+ w.setUint32(at + 12, t.data.length)
50
+ out.set(t.data, offset)
51
+ offset += (t.data.length + 3) & ~3
52
+ })
53
+ return out
54
+ }
55
+
56
+ /** `{ name, data, weight?, style? }[]` in any format → the same with bytes every Takumi build reads. */
57
+ export async function normalizeFonts(fonts) {
58
+ return Promise.all(fonts.map(async f => {
59
+ const data = f.data instanceof Uint8Array ? f.data : new Uint8Array(f.data)
60
+ const format = fontFormat(data)
61
+ if (!format) throw new Error(`weft/og: font "${f.name}" isn't TTF, OTF, WOFF or WOFF2`)
62
+ return { ...f, data: format === 'woff' ? await woffToSfnt(data) : data }
63
+ }))
64
+ }
@@ -0,0 +1,54 @@
1
+ // `<img src>` in a card: fetched at render with a timeout and a size cap (research 20 §4.1), cached by URL, and left
2
+ // out of the card (keeping its box, so the layout doesn't move) when the fetch fails, instead of failing the request.
3
+
4
+ /** An LRU of fetched image bytes by URL, capped by total bytes. */
5
+ export function imageCache(maxBytes = 32 << 20) {
6
+ const map = new Map()
7
+ let bytes = 0
8
+ return {
9
+ get(src) {
10
+ const hit = map.get(src)
11
+ if (hit) { map.delete(src); map.set(src, hit) }
12
+ return hit
13
+ },
14
+ set(src, data) {
15
+ if (data.length > maxBytes) return
16
+ map.set(src, data)
17
+ bytes += data.length
18
+ for (const [k, v] of map) {
19
+ if (bytes <= maxBytes) break
20
+ map.delete(k)
21
+ bytes -= v.length
22
+ }
23
+ },
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Fetches every remote `src` in the tree. `allow(url)` decides which hosts a card may load from (the on-demand route
29
+ * renders data from `load`, so this is the SSRF guard); the default allows any https URL.
30
+ * Returns the images Takumi needs, and the srcs that failed.
31
+ */
32
+ export async function loadImages(srcs, { cache, timeoutMs = 1500, maxBytes = 4 << 20, allow = u => u.protocol === 'https:', fetch = globalThis.fetch } = {}) {
33
+ const images = [], failed = []
34
+ await Promise.all([...new Set(srcs)].map(async src => {
35
+ if (src.startsWith('data:')) return // Takumi decodes data URIs itself
36
+ const hit = cache?.get(src)
37
+ if (hit) return void images.push({ src, data: hit })
38
+ try {
39
+ const url = new URL(src)
40
+ if (!allow(url)) throw new Error('not allowed')
41
+ const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' })
42
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
43
+ if (!res.headers.get('content-type')?.startsWith('image/')) throw new Error('not an image')
44
+ if (Number(res.headers.get('content-length') ?? 0) > maxBytes) throw new Error('too large')
45
+ const data = new Uint8Array(await res.arrayBuffer())
46
+ if (data.length > maxBytes) throw new Error('too large')
47
+ cache?.set(src, data)
48
+ images.push({ src, data })
49
+ } catch (e) {
50
+ failed.push({ src, reason: e?.name === 'TimeoutError' ? `timed out after ${timeoutMs} ms` : String(e?.message ?? e) })
51
+ }
52
+ }))
53
+ return { images, failed }
54
+ }
@@ -0,0 +1,146 @@
1
+ // renderCard for weft (research 20 §4, §9; promoted from spikes/og/card): weft SSR HTML → PNG/JPEG/WebP/SVG, on
2
+ // Takumi, with what Satori did better added around it:
3
+ // - strict styles: every declaration is checked once against the renderer (an invalid value would fail the whole
4
+ // card, an unknown property is silently dropped) and reported as H005; production drops the bad declaration and
5
+ // renders the rest instead of failing the request
6
+ // - any font format (WOFF too, which Takumi's wasm build can't read), resolved before the first render
7
+ // - SVG output and a ready `Response` with the §4.3 cache headers
8
+ // - a browser preview that matches the PNG: the same preflight (border-box everywhere) and the same font bytes
9
+ // and what neither did: SSR hole comments are removed (both HTML parsers drop text before a comment), and remote
10
+ // images are fetched with a timeout, size cap and host allowlist, cached, and left out (box kept) when they fail.
11
+ import { fromHtml } from '@takumi-rs/helpers/html'
12
+ import { normalizeFonts } from './fonts.mjs'
13
+ import { imageCache, loadImages } from './images.mjs'
14
+ import { native } from './native.mjs'
15
+
16
+ /** Applied to every card, in the PNG and in the preview. Takumi lays out border-box by default; browsers don't. */
17
+ export const PREFLIGHT = '*,*::before,*::after{box-sizing:border-box}'
18
+ /** The size of a generated card. */
19
+ export const SIZE = { width: 1200, height: 630 }
20
+ const TYPES = { png: 'image/png', jpeg: 'image/jpeg', webp: 'image/webp', svg: 'image/svg+xml' }
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())
24
+ // A value no CSS grammar accepts: a property the renderer knows rejects it, and one it doesn't know takes anything.
25
+ // (An identifier won't do: `text-overflow`, `grid-template-columns` and `font-family` accept custom idents.)
26
+ const PROBE = '}{'
27
+
28
+ const renderers = new Map() // fonts array → the renderer registered with them
29
+
30
+ /**
31
+ * The one seam the server uses (research 20 §4.2): HTML at `size`, drawn with `fonts`, to PNG bytes. Diagnostics (H005
32
+ * styles, H008 images) go to `onDiagnostic`; `strict` (dev, `weft build`) throws on an error instead. Native Takumi;
33
+ * the edge build (`@takumi-rs/wasm`) plugs in here when an edge preset needs it.
34
+ *
35
+ * @param {string} html
36
+ * @param {{ width?: number; height?: number }} [size]
37
+ * @param {{ name: string; data: Uint8Array; weight?: number; style?: 'normal' | 'italic' }[]} [fonts]
38
+ * @param {{ strict?: boolean; onDiagnostic?: (d: { code: string; severity: string; message: string }) => void; images?: unknown }} [options]
39
+ */
40
+ export async function renderCard(html, size = SIZE, fonts = [], { strict = false, onDiagnostic, images } = {}) {
41
+ 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 })
44
+ for (const d of out.diagnostics) onDiagnostic?.(d)
45
+ return out.body
46
+ }
47
+
48
+ /**
49
+ * `backend` is `await native()` (card/native.mjs) or `await wasm(module)` (card/wasm.mjs). `fonts` is `{ name, data, weight?, style? }[]` in TTF, OTF,
50
+ * WOFF or WOFF2. `images` configures `<img>` fetching (see images.mjs).
51
+ */
52
+ export async function createCardRenderer({ backend, fonts = [], images: imageOptions = {} }) {
53
+ const renderer = new backend.Renderer()
54
+ for (const font of await normalizeFonts(fonts)) await renderer.registerFont(font)
55
+ const cache = imageCache(imageOptions.cacheBytes)
56
+ const verdicts = new Map() // `${prop}\0${value}` → null (fine) | { severity, message }
57
+
58
+ const accepts = async style => {
59
+ try { await renderer.measure({ type: 'container', style }, { width: 1, height: 1 }); return true } catch { return false }
60
+ }
61
+ async function judge(prop, value) {
62
+ const key = `${prop}\0${value}`
63
+ if (verdicts.has(key)) return verdicts.get(key)
64
+ let v = null
65
+ 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.` }
67
+ } 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.` }
69
+ }
70
+ verdicts.set(key, v)
71
+ return v
72
+ }
73
+
74
+ /** Walks the tree: checks styles (dropping invalid declarations), and collects image nodes. */
75
+ async function prepare(html) {
76
+ const { node, css } = fromHtml(stripComments(html))
77
+ const diagnostics = [], imageNodes = []
78
+ const walk = async (n, path) => {
79
+ if (n.type === 'image') imageNodes.push(n)
80
+ for (const [prop, value] of Object.entries(n.style ?? {})) {
81
+ const v = await judge(prop, value)
82
+ if (!v) continue
83
+ diagnostics.push({ code: 'H005', severity: v.severity, message: v.message, element: path })
84
+ if (v.severity === 'error') delete n.style[prop]
85
+ }
86
+ for (const [i, c] of (n.children ?? []).entries()) await walk(c, `${path} > ${c.tagName ?? c.type}:nth-child(${i + 1})`)
87
+ }
88
+ await walk(node, node.tagName ?? node.type)
89
+ return { node, css, diagnostics, imageNodes }
90
+ }
91
+
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
+ }
119
+ }
120
+
121
+ /**
122
+ * The HTTP response for `/_weft/og/<route>.png?v=<hash>` (§4.3): immutable, since the URL is content-addressed, except
123
+ * in dev, where an edit must show on reload.
124
+ */
125
+ export function cardResponse({ body, type }, { dev = false } = {}) {
126
+ return new Response(body, {
127
+ headers: { 'content-type': type, 'cache-control': dev ? 'no-store' : 'public, max-age=31536000, immutable' },
128
+ })
129
+ }
130
+
131
+ /**
132
+ * The card as a page for `weft open --og`: the same HTML, preflight and font bytes as the PNG, at the card's size, so a
133
+ * browser shows what the renderer draws.
134
+ */
135
+ 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('')
137
+ 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
+ }
139
+
140
+ const fontMime = d => ({ wOFF: 'woff', wOF2: 'woff2', OTTO: 'otf' })[String.fromCharCode(...d.subarray(0, 4))] ?? 'ttf'
141
+
142
+ function base64(bytes) {
143
+ let s = ''
144
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
145
+ return btoa(s)
146
+ }
@@ -0,0 +1,29 @@
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).
3
+ export async function native() {
4
+ // Takumi's addon in the same process as a later Rolldown pass kills a Windows `weft build` (0xC0000005) before the
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)
7
+ const { Renderer } = await import('@takumi-rs/core').catch(linked)
8
+ return { name: 'native', Renderer }
9
+ }
10
+
11
+ /**
12
+ * A prerender runs from a bundle in the app's `node_modules/.nitro`. An app that links `@weft-js/start` from a workspace
13
+ * has no hoisted `@takumi-rs/core` there, so a build says where @weft-js/start is (app.mjs).
14
+ */
15
+ async function linked(error) {
16
+ const from = globalThis.process?.env.WEFT_START_DIR
17
+ if (!from) throw error
18
+ const { createRequire } = await import('node:module')
19
+ return createRequire(`${from}/package.json`)('@takumi-rs/core')
20
+ }
21
+
22
+ /** The wasm renderer, found from `start` (the @weft-js/start directory) because a prerender runs from a bundle. */
23
+ 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 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)
27
+ await init({ module_or_path: await readFile(join(root, 'pkg/takumi_wasm_bg.wasm')) })
28
+ return { name: 'wasm', Renderer }
29
+ }
package/src/range.mjs ADDED
@@ -0,0 +1,19 @@
1
+ // Byte ranges for built video and audio. Nitro's static handler has none and runs before any middleware, and Safari
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 => {
4
+ const app = nitro.fetch
5
+ nitro.fetch = async (request, ...rest) => {
6
+ const range = /^bytes=(\d+)-(\d*)$/.exec(request.headers.get('range') ?? '')
7
+ const id = range && new URL(request.url).pathname
8
+ if (!id?.startsWith('/assets/')) return app(request, ...rest)
9
+ // Lazily: the prerender step builds this file without Nitro's asset table.
10
+ const { getAsset, readAsset } = await import('#nitro/virtual/public-assets')
11
+ const asset = getAsset(id)
12
+ if (!/^(video|audio)\//.test(asset?.type ?? '')) return app(request, ...rest)
13
+ const bytes = Buffer.from(await readAsset(id))
14
+ const start = Number(range[1])
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' } })
18
+ }
19
+ }
package/src/router.ts ADDED
@@ -0,0 +1,149 @@
1
+ // The route state both sides render from (research 25 §3). A page is a chain of levels, outer to inner: the root
2
+ // layout, nested layouts, the page. Each level is three boxes: which route it shows, its params and its loader
3
+ // data. Navigating writes only what changed: a level that keeps its route keeps its component and gets new params
4
+ // or data in place (the bindings that read them re-run, nothing remounts); the first level whose route changes
5
+ // renders its new route, and everything inside it goes. One state per request on the server (passed down as a
6
+ // prop), one per tab on the client.
7
+
8
+ import { state, untrack, type State,type SsrData } from '@weft-js/core'
9
+ import type { Merged } from '@weft-js/core/ssr'
10
+ import type { Wire } from './data'
11
+
12
+ /**
13
+ * One level as the server sends it: `d` lists the data's deferred fields (promises on the server, research 25 §4),
14
+ * `t` the tags of the queries its `load` read (a mutation that invalidates one refreshes the route, research 27 §4).
15
+ */
16
+ export interface Match {
17
+ id: string
18
+ params: Record<string, string>
19
+ data?: unknown
20
+ d?: string[] | undefined
21
+ t?: string[] | undefined
22
+ /** A static level: no client code (PLAN §11). `h`: its HTML (a navigation), `x`: a hash of it, to skip an unchanged one. */
23
+ s?: 1
24
+ h?: string
25
+ x?: string
26
+ }
27
+
28
+ export interface Level {
29
+ id: State<string | undefined>
30
+ params: State<Record<string, string>>
31
+ data: State<unknown>
32
+ /** A static level's HTML: empty for the page's own; undefined for a level with client code. */
33
+ html: State<string | undefined>
34
+ x?: string | undefined
35
+ }
36
+
37
+ /** A route, layout or island: its props are its own, so nothing here can call one without a cast. */
38
+ export type Component = (props: never) => unknown
39
+
40
+ export interface RouteState {
41
+ levels: Level[]
42
+ /** The current path (pathname + search). */
43
+ path: State<string>
44
+ status: State<number>
45
+ /** Loaded route components by id. */
46
+ comps: Record<string, Component>
47
+ /** Client: shows a static level's HTML, with the next level inside it. */
48
+ mount?: (html: string, depth: number, next: () => unknown) => unknown
49
+ /** Around a level's component, at the same point on both sides: the server's markers, the client's key scope. */
50
+ around?: (depth: number, id: string, body: () => unknown) => unknown
51
+ }
52
+
53
+ /** What `/_weft/data/<path>` answers and what the page carries in `#weft-route`: the matches, the head, the status. */
54
+ export interface RoutePayload {
55
+ m: Match[]
56
+ p: string
57
+ s: number
58
+ h?: Merged
59
+ /** A redirect (the client navigates there instead). */
60
+ r?: string
61
+ /** The queries the loaders resolved, for the client cache (navigation only; a page carries `#weft-cache`). */
62
+ c?: Wire[]
63
+ /** The islands in the static levels that can't wait, whose code has to be there first. */
64
+ i?: string[]
65
+ /** A navigation's page data, for those islands. */
66
+ b?: SsrData | null
67
+ }
68
+
69
+ /** The route state of the render in progress: a server pass sets it as it starts (passes are synchronous). */
70
+ export const active: { route?: RouteState } = {}
71
+
72
+ export function createRouteState(payload: RoutePayload, comps: Record<string, Component>): RouteState {
73
+ const r: RouteState = { levels: [], path: state(payload.p), status: state(payload.s), comps }
74
+ payload.m.forEach((m, d) => {
75
+ const lv = level(r, d)
76
+ lv.id.value = m.id
77
+ lv.params.value = m.params
78
+ lv.data.value = m.data
79
+ lv.html.value = m.s ? m.h ?? '' : undefined
80
+ lv.x = m.x
81
+ })
82
+ return r
83
+ }
84
+
85
+ /** Level `d`, made on first use (an empty level renders nothing). */
86
+ 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) })
88
+ }
89
+
90
+ const same = (a: unknown, b: unknown) => a === b || JSON.stringify(a) === JSON.stringify(b)
91
+
92
+ /**
93
+ * Show `matches`: levels that keep their route get their new params and data (only when they differ, so a shared
94
+ * layout whose data didn't change sees no write at all); the first level whose route changes, and every level after
95
+ * it, switch route.
96
+ */
97
+ export function applyMatches(r: RouteState, matches: Match[], path: string, status: number) {
98
+ untrack(() => {
99
+ const depth = Math.max(matches.length, r.levels.length)
100
+ let fresh = false
101
+ for (let d = 0; d < depth; d++) {
102
+ const m = matches[d]
103
+ const lv = level(r, d)
104
+ if (m) {
105
+ if (!same(lv.params.value, m.params)) lv.params.value = m.params
106
+ if (!same(lv.data.value, m.data)) lv.data.value = m.data
107
+ if (!m.s) lv.html.value = undefined
108
+ else if (fresh || lv.x !== m.x || lv.id.value !== m.id) {
109
+ lv.html.value = m.h
110
+ fresh = true // its markers for the levels below are gone with it, so those can't adopt the page
111
+ }
112
+ lv.x = m.x
113
+ }
114
+ if (lv.id.value !== m?.id) lv.id.value = m?.id
115
+ }
116
+ if (r.path.value !== path) r.path.value = path
117
+ if (r.status.value !== status) r.status.value = status
118
+ })
119
+ }
120
+
121
+ /**
122
+ * The tab's route state and navigation, set by `start()` (client.tsx). Server renders never touch them. The query
123
+ * cache (data.ts, loaded only by apps that use a query) hooks in here, so `start()` doesn't ship it: `seed` takes
124
+ * the entries a navigation brought (`seeds` holds them until it loads), `invalidated` refreshes the route data.
125
+ */
126
+ export const client: {
127
+ route?: RouteState
128
+ navigate?: (href: string, options?: NavigateOptions & { replace?: boolean | undefined }) => Promise<void>
129
+ seed?: (list: Wire[]) => void
130
+ seeds?: Wire[] | undefined
131
+ invalidated?: (tags: string[]) => void
132
+ /** Dev: server code changed, so whatever a query read may have too. */
133
+ refreshed?: () => void
134
+ } = {}
135
+
136
+ /** `navigate()`: replace the history entry, and keep the scroll. */
137
+ export interface NavigateOptions {
138
+ /** Replace the history entry instead of pushing one. */
139
+ replace?: boolean | undefined
140
+ /** Keep the scroll position (default: top, or the URL's #hash). */
141
+ scroll?: false
142
+ }
143
+
144
+ /** Go to `href` without a page load (a full load before `start()` ran, or for another origin). */
145
+ export function navigate(href: string, options?: NavigateOptions): Promise<void> {
146
+ if (client.navigate) return client.navigate(href, options)
147
+ location.assign(href)
148
+ return Promise.resolve()
149
+ }