@weft-js/start 0.0.0-stage → 0.1.0

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 +82 -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 +616 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +308 -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 +456 -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 +144 -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 +198 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1014 -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/routes.mjs ADDED
@@ -0,0 +1,142 @@
1
+ // File routes (research 25 §2): `src/routes` → a route manifest, plain data that the server, the client table, the
2
+ // Vite plugin and `weft routes` all read. Conventions:
3
+ //
4
+ // index.tsx /
5
+ // about.tsx /about
6
+ // users/[id].tsx /users/:id (router-core path `/users/$id`)
7
+ // docs/[...rest].tsx /docs/* (router-core splat `$`, the param is `rest`)
8
+ // _layout.tsx wraps every route in its directory and below (the root one wraps the whole app)
9
+ // 404.tsx the not-found page (root only), rendered inside the root layout with status 404
10
+ // _error.tsx the error page (root only), rendered inside the root layout with status 500
11
+ // users/[id].og.tsx the share card of `users/[id]` (research 20 §4); `_default.og.tsx` replaces the built-in card
12
+ //
13
+ // Any other file or directory starting with `_` is ignored, and so are tests. A route module may export `default`
14
+ // (the component), `load`, `meta` and `prerender` (research 20 §3, §4.3).
15
+
16
+ import { existsSync, readdirSync, statSync } from 'node:fs'
17
+ import { join, relative } from 'node:path'
18
+
19
+ const EXT = /\.(tsx|jsx|ts|js)$/
20
+ const posix = p => p.split('\\').join('/')
21
+
22
+ /**
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
24
+ * @typedef {{ id: string, file: string, dir: string, parent?: string }} LayoutEntry
25
+ * @typedef {{ root: string, dir: string, routes: RouteEntry[], layouts: LayoutEntry[], defaultCard?: string, problems: { file: string, message: string }[] }} Manifest
26
+ */
27
+
28
+ /** Scan `<root>/src/routes` (or `dir`). Paths in the manifest are relative to `root`, POSIX-style. */
29
+ export function scanRoutes(root, dir = join(root, 'src', 'routes')) {
30
+ /** @type {Manifest} */
31
+ const out = { root, dir: posix(relative(root, dir)), routes: [], layouts: [], problems: [] }
32
+ if (!existsSync(dir)) return out
33
+ const files = []
34
+ const walk = d => {
35
+ for (const name of readdirSync(d).sort()) {
36
+ const abs = join(d, name)
37
+ if (statSync(abs).isDirectory()) {
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)))
40
+ }
41
+ }
42
+ walk(dir)
43
+
44
+ const rel = f => posix(relative(root, join(dir, f)))
45
+ const layoutFile = new Map() // route dir ('' = root) → its _layout file
46
+ for (const f of files) {
47
+ const m = /^(?:(.*)\/)?_layout\.[jt]sx?$/.exec(f)
48
+ if (m) layoutFile.set(m[1] ?? '', f)
49
+ }
50
+ const layoutOf = new Map([...layoutFile.keys()].map(d => [d, `layout:${d}`]))
51
+ 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
+ }
54
+
55
+ const cards = new Map()
56
+ for (const f of files) {
57
+ const m = /^(.*)\.og\.[jt]sx?$/.exec(f)
58
+ if (!m) continue
59
+ if (m[1] === '_default') out.defaultCard = rel(f)
60
+ else cards.set(m[1], rel(f))
61
+ }
62
+
63
+ for (const f of files) {
64
+ const base = f.replace(EXT, '')
65
+ const name = base.split('/').pop()
66
+ if (base.endsWith('.og') || name === '_layout' || name === '_default') continue
67
+ const d = base.includes('/') ? base.slice(0, base.lastIndexOf('/')) : ''
68
+ 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' })
71
+ continue
72
+ }
73
+ if (name.startsWith('_') || d.split('/').some(s => s.startsWith('_'))) continue
74
+ const segs = base === 'index' ? [] : base.split('/')
75
+ if (segs.at(-1) === 'index') segs.pop()
76
+ const params = []
77
+ let splat
78
+ const parts = []
79
+ let bad = false
80
+ for (const s of segs) {
81
+ const p = /^\[(\.\.\.)?([A-Za-z_$][\w$]*)\]$/.exec(s)
82
+ if (p) {
83
+ if (p[1]) splat = p[2]
84
+ params.push(p[2])
85
+ parts.push(p[1] ? '$' : `$${p[2]}`)
86
+ } else if (/[[\]]/.test(s)) bad = true
87
+ else parts.push(s)
88
+ }
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 }
91
+ const pattern = '/' + parts.join('/')
92
+ out.routes.push({
93
+ id: pattern, file: rel(f), layouts: chain(d, layoutOf), path: '/' + segs.join('/'), pattern, params, splat,
94
+ // 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',
97
+ })
98
+ }
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
+ }
103
+
104
+ /** Layout ids from the root down to `d`. */
105
+ function chain(d, layoutOf) {
106
+ const out = []
107
+ const parts = d ? d.split('/') : []
108
+ for (let i = 0; i <= parts.length; i++) {
109
+ const id = layoutOf.get(parts.slice(0, i).join('/'))
110
+ if (id) out.push(id)
111
+ }
112
+ return out
113
+ }
114
+
115
+ function cmpRank(a, b) {
116
+ 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
119
+ if (a[i] !== b[i]) return b[i] - a[i]
120
+ }
121
+ return 0
122
+ }
123
+
124
+ /** `/users/$id` with `{ id: '42' }` → `/users/42`; a splat fills `$`. */
125
+ export function fillPath(pattern, params) {
126
+ return pattern.replace(/\$([\w$]*)/g, (_, name) => {
127
+ const v = params[name || '_splat'] ?? params[name]
128
+ return name ? encodeURIComponent(String(v)) : String(v ?? '')
129
+ })
130
+ }
131
+
132
+ /** A route's file path as authors write it in types: `src/routes/users/[id].tsx` → `/users/[id]`, `index.tsx` → `/`. */
133
+ export function filePath(manifest, route) {
134
+ return '/' + route.file.slice(manifest.dir.length + 1).replace(EXT, '').replace(/(^|\/)index$/, '')
135
+ }
136
+
137
+ /** `.weft/routes.d.ts`: every route path, so `RouteProps<…, '/users/[id]'>` and `Meta<…, path>` only take real ones. */
138
+ export function routeTypes(manifest) {
139
+ const pages = manifest.routes.filter(r => r.kind === 'page')
140
+ const lines = pages.map(r => ` '${filePath(manifest, r)}': true`)
141
+ 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
+ }
package/src/run.tsx ADDED
@@ -0,0 +1,198 @@
1
+ // The client (`client.tsx` starts it) and its router (research 25 §3): hydrate the server's page, then navigate without page loads. A navigation
2
+ // fetches `/_weft/data/<path>` (the server matches, runs the loaders and merges the head, so loaders never ship to
3
+ // the client), loads the route chunks it needs (started in parallel from a guess against the route table), and
4
+ // writes the levels: a level that keeps its route keeps its component and gets new params/data in place.
5
+ // `setHead` writes the server's merged tags for the tab title and history; crawlers only read the server's HTML.
6
+ //
7
+ // Server functions (research 27): the query cache seeds itself from the page's `#weft-cache` as it loads, and gets
8
+ // each navigation's `c` through `client.seed`, so hydration fetches nothing (and an app without queries doesn't ship
9
+ // the cache). Redirects from loaders and server functions navigate (at most 5 in a row); a
10
+ // mutation that invalidates a tag the current route's loaders read refetches the route data in place.
11
+
12
+ import { drain, enter, hydrate, level as staticLevel, wake } from '@weft-js/core/dom'
13
+ import { flush } from '@weft-js/core'
14
+ import { setHead } from '@weft-js/core/head'
15
+ import type { Held } from './boot'
16
+ import { applyMatches, client, createRouteState, type Component, type Match, type NavigateOptions, type RoutePayload } from './router'
17
+ import { App } from './view'
18
+ import { ErrorPage, NotFoundPage } from './builtin'
19
+
20
+ type Hot = ImportMeta & { hot?: { on(event: string, on: (data: { queries: boolean }) => unknown): void } }
21
+
22
+ /** The client half of `virtual:weft/routes`: the page routes in match order, and a chunk loader per route id. */
23
+ export interface ClientRoutes {
24
+ table: { id: string; pattern: string; layouts: string[] }[]
25
+ load: Record<string, () => Promise<{ default?: Component }>>
26
+ /** Each island's code, by id: all a static level loads (PLAN §11). */
27
+ islands?: Record<string, () => Promise<{ default: Component }>>
28
+ }
29
+
30
+ /** Hydrate the page in `#app` and take over its links. Resolves once hydrated. */
31
+ const SLOW_FADE = 100 // ms from asking for a fade to it starting
32
+
33
+ export async function start(routes: ClientRoutes, options: { container?: Element } = {}) {
34
+ const container = options.container ?? document.getElementById('app')!
35
+ const page: RoutePayload = JSON.parse(document.getElementById('weft-route')!.textContent)
36
+ const data = JSON.parse(document.getElementById('weft-data')?.textContent ?? 'null')
37
+ let tags = page.m.map(m => m.t ?? [])
38
+ const comps: Record<string, Component> = {}
39
+ // The built-in 404 and error pages stand in only when the app has no route file for them.
40
+ const builtin: Record<string, Component> = { 404: NotFoundPage, error: ErrorPage }
41
+ const chunk = (id: string) =>
42
+ comps[id] ? Promise.resolve()
43
+ : routes.load[id] ? routes.load[id]().then(m => { if (m.default) comps[id] = m.default })
44
+ : Promise.resolve(builtin[id] && (comps[id] = builtin[id]))
45
+ const islands: Record<string, Component> = {}
46
+ const island = (id: string) => islands[id] ? undefined : routes.islands![id]!().then(m => { islands[id] = m.default })
47
+ await Promise.all([...page.m.filter(m => !m.s).map(m => chunk(m.id)), ...(page.i ?? []).map(island)])
48
+ const route = createRouteState(page, comps)
49
+ let bag: RoutePayload['b']
50
+ route.mount = (html, depth, next) => staticLevel(html, depth, next, id => islands[id]! as never, bag ?? undefined, id => Promise.resolve(island(id)))
51
+ route.around = (_d, _id, body) => enter(body)
52
+ hydrate(() => <App route={route} />, container, { data })
53
+ client.route = route
54
+ const q = (window as Window & { __wq?: Held }).__wq
55
+ if (q) {
56
+ q.w = wake
57
+ if (q.l) wake(q.l)
58
+ drain()
59
+ }
60
+
61
+ let current: AbortController | undefined
62
+ let warm: { path: string; at: number; res: Promise<Response> } | undefined
63
+ // A fade pictures the whole page twice, and the picture holds up every frame. Where that's slow, the page just changes.
64
+ let fades = !(navigator.hardwareConcurrency <= 4)
65
+ async function go(href: string, opts: NavigateOptions & { pop?: boolean; hops?: number } = {}): Promise<void> {
66
+ const url = new URL(href, location.href)
67
+ if (url.origin !== location.origin) return location.assign(url.href)
68
+ current?.abort()
69
+ const ctrl = (current = new AbortController())
70
+ const path = url.pathname + url.search
71
+ for (const id of guess(routes, url.pathname)) void chunk(id) // chunk loads start with the data fetch, not after it
72
+ let next: RoutePayload
73
+ try {
74
+ const held = warm?.path === path && performance.now() - warm.at < 10_000 ? warm.res : undefined
75
+ warm = undefined
76
+ next = await (held ?? ask(url, ctrl.signal)).then(r => r.clone().json())
77
+ } catch (e) {
78
+ if (ctrl.signal.aborted) return
79
+ return location.assign(url.href) // the data request failed: let the server render the page
80
+ }
81
+ if (next.r) {
82
+ const hops = (opts.hops ?? 0) + 1
83
+ return hops > 5 ? location.assign(next.r) : go(next.r, { replace: opts.pop || opts.replace, hops }) // a back/forward to a moved URL replaces its entry
84
+ }
85
+ await Promise.all([...next.m.map(m => chunk(m.id)), ...(next.i ?? []).map(island)])
86
+ if (ctrl.signal.aborted) return
87
+ if (!opts.pop) history[opts.replace ? 'replaceState' : 'pushState'](null, '', url.href)
88
+ at = path
89
+ if (next.c) client.seed ? client.seed(next.c) : (client.seeds ??= []).push(...next.c)
90
+ tags = next.m.map(m => m.t ?? [])
91
+ bag = next.b
92
+ const swap = () => {
93
+ applyMatches(route, next.m.map(revive), path, next.s)
94
+ if (next.m.some(m => m.s)) current_(url.pathname)
95
+ if (next.h) setHead(next.h, url.pathname)
96
+ flush()
97
+ if (opts.scroll !== false && !opts.pop) {
98
+ const target = url.hash && document.getElementById(decodeURIComponent(url.hash.slice(1)))
99
+ target ? target.scrollIntoView() : window.scrollTo(0, 0)
100
+ }
101
+ }
102
+ // The old page stays on screen, as a picture, until the new one is ready to fade in. `hidden`: nothing would show it.
103
+ if (fades && document.startViewTransition && !document.hidden && !matchMedia('(prefers-reduced-motion: reduce)').matches) {
104
+ const started = performance.now()
105
+ const fade = document.startViewTransition(swap)
106
+ void fade.ready.then(() => { if (performance.now() - started > SLOW_FADE) fades = false }, () => {})
107
+ // a transition the browser aborts (a second navigation, a hidden tab) rejects `finished`: the page already swapped
108
+ void fade.finished.catch(() => {})
109
+ await fade.updateCallbackDone
110
+ }
111
+ else swap()
112
+ }
113
+ client.navigate = go
114
+ // Dev: server code changed under the page (vite.mjs). The route's data comes again, in place.
115
+ const refresh = async ({ queries }: { queries: boolean }) => {
116
+ const url = new URL(location.href)
117
+ const res = await ask(url)
118
+ // It doesn't load yet, and the error page would replace what's here: the next save asks again.
119
+ if (!res.ok || (await res.clone().json()).s >= 500) return
120
+ if (queries) client.refreshed?.()
121
+ warm = { path: url.pathname + url.search, at: performance.now(), res: Promise.resolve(res) }
122
+ await go(url.href, { pop: true })
123
+ }
124
+ if ((import.meta as Hot).hot) (import.meta as Hot).hot!.on('weft:refresh', changed => void refresh(changed))
125
+ // A mutation invalidated tags: when the route's loaders read one, refetch the route data (no history entry, no scroll).
126
+ client.invalidated = t => {
127
+ warm = undefined
128
+ if (tags.some(l => l.some(x => t.includes(x)))) void go(location.href, { pop: true })
129
+ }
130
+
131
+ document.addEventListener('click', e => {
132
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
133
+ const url = ours(e.target)
134
+ if (!url) return
135
+ e.preventDefault()
136
+ void go(url.href)
137
+ })
138
+ // A link the pointer rests on, or the keyboard lands on, has its chunks and data by the time it is pressed.
139
+ // `data-preload="off"` on a link opts out, and so does Save-Data.
140
+ let pending: ReturnType<typeof setTimeout> | undefined
141
+ const intent = (e: Event) => {
142
+ clearTimeout(pending)
143
+ const url = ours(e.target)
144
+ const path = url && url.pathname + url.search
145
+ if (!url || warm?.path === path || (e.target as Element).closest('[data-preload="off"]') || (navigator as Navigator & { connection?: { saveData?: boolean } }).connection?.saveData) return
146
+ pending = setTimeout(() => {
147
+ for (const id of guess(routes, url.pathname)) void chunk(id)
148
+ warm = { path: path!, at: performance.now(), res: ask(url) }
149
+ warm.res.catch(() => { warm = undefined })
150
+ }, e.type === 'focusin' ? 0 : 80)
151
+ }
152
+ document.addEventListener('pointerover', intent)
153
+ document.addEventListener('focusin', intent)
154
+ // An in-page anchor fires this too, and the page hasn't changed: re-rendering it would lose what's been scrolled to.
155
+ let at = location.pathname + location.search
156
+ addEventListener('popstate', () => {
157
+ if (location.pathname + location.search !== at) void go(location.href, { pop: true })
158
+ })
159
+ return route
160
+ }
161
+
162
+ const ask = (url: URL, signal?: AbortSignal) => fetch(`/_weft/data${url.pathname === '/' ? '' : url.pathname}${url.search}`, { signal: signal ?? null, headers: { accept: 'application/json' } })
163
+
164
+ /** The URL a client navigation would take from this event's link, or nothing for a link the browser should follow. */
165
+ function ours(target: EventTarget | null) {
166
+ const a = (target as Element | null)?.closest?.('a')
167
+ if (!a || (a.target && a.target !== '_self') || a.hasAttribute('download') || a.getAttribute('rel')?.includes('external')) return
168
+ const url = new URL(a.href, location.href)
169
+ if (url.origin !== location.origin || url.pathname.startsWith('/_weft/')) return
170
+ return url.pathname === location.pathname && url.search === location.search && url.hash ? undefined : url // not an in-page anchor
171
+ }
172
+
173
+ /** A kept static level has no binding for its links' current-page state: the same rule as `Link`, after the swap. */
174
+ function current_(path: string) {
175
+ flush()
176
+ for (const a of document.querySelectorAll('a[aria-current="page"]')) if (a.getAttribute('href') !== path) a.removeAttribute('aria-current')
177
+ for (const a of document.querySelectorAll(`a[href="${CSS.escape(path)}"]:not([aria-current])`)) a.setAttribute('aria-current', 'page')
178
+ }
179
+
180
+ /** Deferred fields arrive resolved on navigation: they become settled promises again, so resources read them alike. */
181
+ function revive(m: Match): Match {
182
+ if (!m.d?.length) return m
183
+ const data = { ...(m.data as Record<string, unknown>) }
184
+ for (const k of m.d) data[k] = Promise.resolve(data[k])
185
+ return { ...m, data }
186
+ }
187
+
188
+ /** The route ids `pathname` probably needs (its layouts and page), from the table; the server's answer decides. */
189
+ function guess(routes: ClientRoutes, pathname: string): string[] {
190
+ const parts = pathname.split('/').filter(Boolean)
191
+ for (const r of routes.table) {
192
+ const segs = r.pattern.split('/').filter(Boolean)
193
+ const splat = segs.at(-1) === '$'
194
+ if (splat ? parts.length < segs.length - 1 : parts.length !== segs.length) continue
195
+ if (segs.every((s, i) => s === '$' || s.startsWith('$') || s === decodeURIComponent(parts[i] ?? ''))) return [...r.layouts, r.id]
196
+ }
197
+ return []
198
+ }