@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.
- package/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +93 -0
- package/src/auth/better-auth.ts +58 -0
- package/src/auth/drizzle.ts +17 -0
- package/src/auth.ts +47 -0
- package/src/boot.ts +44 -0
- package/src/builtin.tsx +19 -0
- package/src/bus/postgres.ts +91 -0
- package/src/bus.ts +48 -0
- package/src/capture.d.mts +1 -0
- package/src/capture.mjs +15 -0
- package/src/changes-sql.d.mts +14 -0
- package/src/changes-sql.mjs +99 -0
- package/src/changes.ts +253 -0
- package/src/channel-fake.ts +55 -0
- package/src/channel.ts +622 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +312 -0
- package/src/data.ts +929 -0
- package/src/db/bun-sqlite.d.ts +10 -0
- package/src/db/changes-mysql.ts +86 -0
- package/src/db/changes-postgres.ts +159 -0
- package/src/db/changes-sqlite.ts +116 -0
- package/src/db/core.ts +63 -0
- package/src/db/index.ts +27 -0
- package/src/db/mysql.ts +67 -0
- package/src/db/postgres.ts +65 -0
- package/src/db/sqlite.ts +139 -0
- package/src/derive.d.mts +16 -0
- package/src/derive.mjs +140 -0
- package/src/dev-runtime.mjs +42 -0
- package/src/exec.ts +519 -0
- package/src/extension.ts +38 -0
- package/src/first-paint.ts +57 -0
- package/src/form.tsx +134 -0
- package/src/gate.css +6 -0
- package/src/hub.ts +822 -0
- package/src/index.ts +106 -0
- package/src/inline.mjs +2 -0
- package/src/inspect.mjs +280 -0
- package/src/link.ts +461 -0
- package/src/list-drizzle.ts +50 -0
- package/src/list.ts +224 -0
- package/src/listen.ts +28 -0
- package/src/live-fake.ts +42 -0
- package/src/live-target.mjs +147 -0
- package/src/live.ts +499 -0
- package/src/media.ts +43 -0
- package/src/migrations.mjs +19 -0
- package/src/mux-session.ts +6 -0
- package/src/mux.ts +135 -0
- package/src/og/DefaultCard.tsx +60 -0
- package/src/og/fonts.mjs +64 -0
- package/src/og/images.mjs +54 -0
- package/src/og/index.mjs +146 -0
- package/src/og/native.mjs +29 -0
- package/src/range.mjs +19 -0
- package/src/router.ts +149 -0
- package/src/routes.mjs +142 -0
- package/src/run.tsx +202 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1021 -0
- package/src/shape.d.mts +3 -0
- package/src/shape.mjs +73 -0
- package/src/share-server.ts +208 -0
- package/src/share.ts +503 -0
- package/src/sheets.mjs +75 -0
- package/src/sidecar.mjs +503 -0
- package/src/socket.ts +389 -0
- package/src/stream.ts +13 -0
- package/src/verify.ts +251 -0
- package/src/view.tsx +47 -0
- 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
|
+
}
|
package/src/og/fonts.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/og/index.mjs
ADDED
|
@@ -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
|
+
}
|