@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
@@ -0,0 +1,139 @@
1
+ // SQLite for `@weft-js/start` (research 28 §1): Node's and Deno's `node:sqlite` or Bun's `bun:sqlite` behind Drizzle's
2
+ // proxy driver, so no native binary and the same async API as Postgres. One connection, opened on the first statement
3
+ // (`weft build` loads this module and must not touch a file). A transaction's body is async, so it holds a gate:
4
+ // statements outside it wait, or a rollback would take another request's writes with it.
5
+
6
+ // oxlint-disable-next-line typescript/triple-slash-reference -- an installed app's tsc doesn't include this file by itself, and it declares a module, so nothing can import it
7
+ /// <reference path="./bun-sqlite.d.ts" />
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
9
+ import { mkdirSync } from 'node:fs'
10
+ import { dirname } from 'node:path'
11
+ import type { SQLInputValue } from 'node:sqlite'
12
+ import { drizzle, type SqliteRemoteDatabase } from 'drizzle-orm/sqlite-proxy'
13
+ import { StartError } from '../data'
14
+ import { gate, register, statements, touch, type DbHandle, type Migration } from './core'
15
+
16
+ /** How Drizzle's proxy driver asks for rows. */
17
+ export type Method = 'run' | 'all' | 'values' | 'get'
18
+
19
+ interface Conn {
20
+ /** Array rows, as the proxy driver wants them. */
21
+ query(sql: string, params: unknown[], method: Method): unknown
22
+ exec(sql: string): void
23
+ close?(): void
24
+ }
25
+
26
+ /** The file open on this runtime's driver: Bun's, or `node:sqlite`. */
27
+ export async function open(path: string): Promise<Conn> {
28
+ if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
29
+ const pick = (rows: unknown[][], method: Method) => (method === 'get' ? rows[0] : rows)
30
+ let conn: Conn
31
+ if ('Bun' in globalThis) {
32
+ const { Database } = await import('bun:sqlite')
33
+ const db = new Database(path, { create: true })
34
+ conn = {
35
+ query: (sql, params, method) => (method === 'run' ? void db.prepare(sql).run(...params) : pick(db.prepare(sql).values(...params), method)),
36
+ exec: sql => void db.run(sql),
37
+ close: () => db.close(),
38
+ }
39
+ } else {
40
+ const { DatabaseSync } = await import('node:sqlite')
41
+ const db = new DatabaseSync(path)
42
+ conn = {
43
+ query: (sql, params, method) => {
44
+ const s = db.prepare(sql)
45
+ if (method === 'run') return void s.run(...(params as SQLInputValue[]))
46
+ s.setReturnArrays(true)
47
+ return pick(s.all(...(params as SQLInputValue[])) as unknown as unknown[][], method)
48
+ },
49
+ exec: sql => db.exec(sql),
50
+ close: () => db.close(),
51
+ }
52
+ }
53
+ if (path !== ':memory:') conn.exec('pragma journal_mode = wal')
54
+ conn.exec('pragma foreign_keys = on; pragma busy_timeout = 5000')
55
+ return conn
56
+ }
57
+
58
+ /** `createDb` on SQLite: the schema, the file, and whether to migrate on first use. */
59
+ export interface SqliteOptions<S extends Record<string, unknown>> {
60
+ schema: S
61
+ /** `file:.data/app.db` (the default), a path, or `:memory:`. */
62
+ url?: string
63
+ /** Apply `drizzle/` before the first statement a request runs (default true). */
64
+ migrate?: boolean
65
+ }
66
+
67
+ /** Drizzle over a local file, with `$weft` for the framework's own needs. */
68
+ export type SqliteDb<S extends Record<string, unknown>> = SqliteRemoteDatabase<S> & { $weft: DbHandle }
69
+
70
+ /** A Drizzle client on a SQLite file, with this app's migrations and write tracking. */
71
+ export function createDb<S extends Record<string, unknown>>({ schema, url = 'file:.data/app.db', migrate = true }: SqliteOptions<S>): SqliteDb<S> {
72
+ if (/^[a-z][a-z0-9+.-]+:\/\//i.test(url) && !url.startsWith('file:'))
73
+ throw new StartError('Q013', `\`@weft-js/start/db/sqlite\` can't open \`${url.replace(/\/\/.*@/, '//…@')}\``, 'use `file:<path>`, or `createDb` from `@weft-js/start/db/postgres` for a Postgres URL')
74
+ const path = url.replace(/^file:/, '')
75
+ let conn: Promise<Conn> | undefined
76
+ const connect = () => (conn ??= open(path).catch(e => { conn = undefined; throw e }))
77
+ const inside = new AsyncLocalStorage<boolean>()
78
+ let held: Promise<unknown> = Promise.resolve()
79
+ const handle: DbHandle = {
80
+ dialect: 'sqlite',
81
+ exempt: new Set(),
82
+ async migrate(list: Migration[]) {
83
+ const c = await connect()
84
+ c.exec('create table if not exists __weft_migrations (tag text primary key, at integer not null)')
85
+ const done = new Set((c.query('select tag from __weft_migrations', [], 'values') as string[][]).map(r => r[0]))
86
+ for (const m of list) {
87
+ if (done.has(m.tag)) continue
88
+ c.exec('begin')
89
+ try {
90
+ for (const s of statements(m.sql)) c.exec(s)
91
+ c.query('insert into __weft_migrations values (?, ?)', [m.tag, Date.now()], 'run')
92
+ c.exec('commit')
93
+ } catch (e) {
94
+ c.exec('rollback')
95
+ throw e
96
+ }
97
+ }
98
+ },
99
+ changes: (tables, o) => import('./changes-sqlite').then(m => m.sqliteChanges({ ...o, path, open, tables })),
100
+ }
101
+ const ready = gate(handle, migrate)
102
+ const run = async (sql: string, params: unknown[], method: Method) => {
103
+ touch(handle, sql)
104
+ await ready()
105
+ const c = await connect()
106
+ if (!inside.getStore()) await held
107
+ return { rows: c.query(sql, params, method) as unknown[] }
108
+ }
109
+ const batch = async (list: { sql: string; params: unknown[]; method: Method }[]) => {
110
+ await ready()
111
+ const c = await connect()
112
+ await held
113
+ c.exec('begin')
114
+ try {
115
+ const out = list.map(q => (touch(handle, q.sql), { rows: c.query(q.sql, q.params, q.method) as unknown[] }))
116
+ c.exec('commit')
117
+ return out
118
+ } catch (e) {
119
+ c.exec('rollback')
120
+ throw e
121
+ }
122
+ }
123
+ const db = drizzle(run, batch, { schema })
124
+ const transaction = db.transaction.bind(db)
125
+ db.transaction = (async (...args: Parameters<typeof transaction>) => {
126
+ if (inside.getStore()) return transaction(...args)
127
+ await ready()
128
+ const before = held
129
+ let release!: () => void
130
+ held = new Promise<void>(ok => { release = ok })
131
+ await before
132
+ try {
133
+ return await inside.run(true, () => transaction(...args))
134
+ } finally {
135
+ release()
136
+ }
137
+ }) as typeof db.transaction
138
+ return register(db, handle)
139
+ }
@@ -0,0 +1,16 @@
1
+ /** The text with HTML entities decoded. */
2
+ export function decode(text: string): string
3
+ /** One line of plain text: whitespace collapsed, no list marker in front. */
4
+ export function cleanText(text: string): string
5
+ /** At most `max` characters, cut at a sentence or a word. */
6
+ export function truncate(text: string, max?: number): string
7
+ /** `undefined`, or why this text isn't a description a person would read. */
8
+ export function descriptionProblem(text: string): string | undefined
9
+ /** A page's title and description, as a person would pick them out of its HTML. */
10
+ export function pageText(html: string): { title?: string; description?: string }
11
+ /** The site's URL, from its config or from what the host says about this deploy. */
12
+ export function deployUrl(site: { url?: string | undefined } | undefined, env?: Record<string, string | undefined>): string | undefined
13
+ /** The card title's font size, smaller for a longer title. */
14
+ export function cardTitleSize(title: string): number
15
+ /** `paths` in pages that each fit in a response header. */
16
+ export function pathPages(paths: string[], max?: number): string[][]
package/src/derive.mjs ADDED
@@ -0,0 +1,140 @@
1
+ // What a page's HTML says about itself (research 20 §10): the automatic layer under route `meta`. A route that sets no
2
+ // title gets its `h1`, one that sets no description gets its first real paragraph, cleaned and cut at a sentence. Pure
3
+ // functions on a string, server-side only: `weft check` runs the same `descriptionProblem` on what a route wrote.
4
+
5
+ const SKIP = new Set(['script', 'style', 'template', 'noscript', 'svg', 'math', 'nav', 'aside', 'footer', 'pre', 'figure', 'table', 'ul', 'ol', 'dl', 'details', 'button', 'select', 'textarea', 'form', 'dialog', 'iframe', 'canvas', 'object'])
6
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr'])
7
+ const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', middot: '·', copy: '©', reg: '®', trade: '™', times: '×', rarr: '→', larr: '←' }
8
+
9
+ /** `&amp;`, `&#39;`, `&#x27;` → their characters. An unknown name stays as written. */
10
+ export const decode = text => text.replace(/&(#\d+|#x[\da-f]+|[a-z]+);/gi, (all, e) => {
11
+ if (e[0] !== '#') return ENTITIES[e] ?? all
12
+ const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : Number(e.slice(1))
13
+ return n > 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ''
14
+ })
15
+
16
+ /** One line of plain text: whitespace collapsed, no list marker in front, no space before punctuation. */
17
+ export const cleanText = text => text
18
+ .replace(/\s+/g, ' ')
19
+ .replace(/\s+([.,;:!?)])(?=\s|$)/g, '$1')
20
+ .trim()
21
+ .replace(/^(?:[-*•‣–—]\s+|\d{1,3}[.)]\s+)+/, '')
22
+
23
+ /** Not the end of a sentence, though it ends in a full stop. */
24
+ const ABBREVIATION = /(?:^|[\s(])(?:e\.g|i\.e|etc|vs|cf|approx|no|fig|[A-Z])\.$/
25
+
26
+ /**
27
+ * At most `max` characters: whole sentences when the first ones fit (past a third of `max`, so one short sentence
28
+ * isn't all that's left), else whole words and an ellipsis. Never cuts inside a word.
29
+ */
30
+ export function truncate(text, max = 155) {
31
+ if (text.length <= max) return text
32
+ const head = text.slice(0, max + 1)
33
+ let end = -1
34
+ for (const m of head.matchAll(/[.!?…]["'”’)]?(?=\s)/g)) {
35
+ const at = m.index + m[0].length
36
+ if (at >= max / 3 && at <= max && !ABBREVIATION.test(head.slice(0, at))) end = at
37
+ }
38
+ if (end > 0) return head.slice(0, end)
39
+ const cut = head.slice(0, max).replace(/\s+\S*$/, '')
40
+ return cut.replace(/[\s,;:(–—-]+$/, '') + '…'
41
+ }
42
+
43
+ /** `undefined`, or why this text isn't a description a person would read: markup, code, a list item or a fragment. */
44
+ export function descriptionProblem(text) {
45
+ const s = text.trim()
46
+ if (/^(?:[-*•+]\s|\d{1,3}[.)]\s|#{1,6}\s|>\s)/.test(s)) return 'it starts with a markdown list marker or heading'
47
+ if (/<\/?[a-z][^>]*>?|\/>|=>|\w\(\)|[{}]|`|\]\(|\bx = </i.test(s)) return 'it holds code or markup'
48
+ if ((s.match(/[<>=;$[\]{}()|\\]/g)?.length ?? 0) / Math.max(s.length, 1) > 0.05) return 'it is mostly symbols, which reads as code'
49
+ if (/\s[.,;:!?)](?=\s|$)/.test(s)) return 'it has a space before punctuation, the mark of inline code that was stripped out'
50
+ if (s.length && !/[.!?…"'”’)]$/.test(s) && s.length > 100) return 'it stops mid-sentence'
51
+ if (/^[a-z]/.test(s) && !/^[a-z][\w.-]*[A-Z@/:]|^[a-z]+:/.test(s)) return 'it starts in lower case, like a fragment of a longer sentence'
52
+ return undefined
53
+ }
54
+
55
+ /** A paragraph worth quoting: prose of some length, with no code in it. */
56
+ const prose = text => text.length >= 40 && text.includes(' ') && !descriptionProblem(truncate(text))
57
+
58
+ function tokens(html) {
59
+ const out = []
60
+ const re = /<!--[\s\S]*?-->|<(\/?)([a-zA-Z][\w:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/g
61
+ let at = 0
62
+ for (let m; (m = re.exec(html)); at = re.lastIndex) {
63
+ if (m.index > at) out.push({ text: html.slice(at, m.index) })
64
+ if (m[2]) out.push({ close: !!m[1], tag: m[2].toLowerCase(), attrs: m[3] })
65
+ }
66
+ if (at < html.length) out.push({ text: html.slice(at) })
67
+ return out
68
+ }
69
+
70
+ const hidden = attrs => /\s(?:hidden|aria-hidden=(?:"true"|'true'|true))(?:[\s/]|$)/.test(attrs)
71
+
72
+ /**
73
+ * The page's own words: `{ title, description }`, either missing when the page doesn't have one. Reads `<main>` when
74
+ * there is one, so a header or a footer in a layout can't speak for the page. The title is the `h1`, or with none the
75
+ * first `h2` or `h3`; the description is the first paragraph after the `h1` that reads as prose.
76
+ */
77
+ export function pageText(html) {
78
+ const open = /<main\b/i.exec(html)
79
+ const end = html.toLowerCase().lastIndexOf('</main>')
80
+ const region = open ? html.slice(open.index, end > open.index ? end : undefined) : html
81
+ let title, heading, description, skip, grab, buffer = ''
82
+ const done = t => {
83
+ const text = cleanText(decode(buffer))
84
+ grab = undefined
85
+ if (t === 'p') { if (prose(text)) description ??= truncate(text) }
86
+ else if (t !== 'h1') heading ??= text || undefined
87
+ else if (text && !title) { title = text; description = undefined } // the description is what follows the title
88
+ }
89
+ for (const t of tokens(region)) {
90
+ if (skip) {
91
+ if (t.tag === skip.tag && !VOID.has(t.tag) && !/\/\s*$/.test(t.attrs ?? '')) skip.depth += t.close ? -1 : 1
92
+ if (!skip.depth) skip = undefined
93
+ continue
94
+ }
95
+ if (t.text !== undefined) { if (grab) buffer += t.text; continue }
96
+ if (!t.close && (SKIP.has(t.tag) || hidden(t.attrs)) && !VOID.has(t.tag) && !/\/\s*$/.test(t.attrs)) {
97
+ skip = { tag: t.tag, depth: 1 }
98
+ continue
99
+ }
100
+ if (grab) {
101
+ if (t.close && t.tag === grab) done(t.tag)
102
+ else if (t.tag === 'br') buffer += ' '
103
+ continue
104
+ }
105
+ if (!t.close && /^(?:h1|h2|h3|p)$/.test(t.tag)) { grab = t.tag; buffer = '' }
106
+ if (title && description) break
107
+ }
108
+ return { title: title ?? heading, description }
109
+ }
110
+
111
+ /**
112
+ * The site's public origin, when something says what it is: `site.url`, `WEFT_SITE_URL`, or what the host sets on a
113
+ * deploy. No request is involved, so the answer can be trusted and written into a page a CDN keeps.
114
+ */
115
+ export function deployUrl(site, env = {}) {
116
+ if (site?.url) return site.url
117
+ const https = host => host && `https://${host}`
118
+ return env.WEFT_SITE_URL
119
+ || https(env.VERCEL_PROJECT_PRODUCTION_URL) || https(env.VERCEL_URL)
120
+ || env.URL // Netlify
121
+ || env.CF_PAGES_URL || env.RENDER_EXTERNAL_URL
122
+ || https(env.RAILWAY_PUBLIC_DOMAIN)
123
+ || (env.FLY_APP_NAME && `https://${env.FLY_APP_NAME}.fly.dev`)
124
+ || undefined
125
+ }
126
+
127
+ /** The built-in card's title size: bigger type for a shorter title. */
128
+ export const cardTitleSize = title => (title.length <= 24 ? 92 : title.length <= 44 ? 76 : title.length <= 70 ? 62 : 52)
129
+
130
+ /** `paths` in pages that each fit in a response header, with room for the link to the next page. */
131
+ export function pathPages(paths, max = 6000) {
132
+ const pages = [[]]
133
+ let size = 0
134
+ for (const p of paths) {
135
+ if (size + p.length + 1 > max) { pages.push([]); size = 0 }
136
+ pages.at(-1).push(p)
137
+ size += p.length + 1
138
+ }
139
+ return pages
140
+ }
@@ -0,0 +1,42 @@
1
+ // `weft dev` for an app with sockets on Bun and Deno. Nitro's dev server assumes a Node worker for websockets: its
2
+ // entry takes crossws's Node adapter, which refuses to load there, and a worker on either runtime can't take the
3
+ // upgrade. So the app runs in Vite's own process (the `self` runner), where Vite's HTTP server hands the upgrade over
4
+ // and the adapter works. Node, and any app without a channel or sharedState, gets none of this.
5
+
6
+ const alternate = Boolean(process.versions.bun || process.versions.deno)
7
+
8
+ export const devRunner = sockets => (alternate && sockets ? 'self' : undefined)
9
+
10
+ const GUARD = /if \("Deno" in globalThis \|\| "Bun" in globalThis\) throw new Error\([^)]*\);?/
11
+
12
+ export const devSockets = () => ({
13
+ name: 'weft:dev-sockets',
14
+ apply: 'serve',
15
+ enforce: 'post',
16
+ configResolved(config) {
17
+ for (const [name, environment] of Object.entries(config.environments)) {
18
+ const create = environment.dev?.createEnvironment
19
+ if (!create || environment.consumer !== 'server') continue
20
+ const release = hold(name)
21
+ environment.dev.createEnvironment = async (...args) => release(await create(...args))
22
+ }
23
+ },
24
+ transform: {
25
+ filter: { id: /crossws/, code: 'incompatible environment' },
26
+ handler: code => code.replace(GUARD, ''),
27
+ },
28
+ })
29
+
30
+ // Nitro can name an environment to the runner before Vite has made it. Across a process that takes long enough, but
31
+ // `self` answers at once: the first request goes to nothing and the environment hangs for good. So each one is held
32
+ // until it exists, and `init` names it again. (The worker keeps these on globalThis, and `self` is this process.)
33
+ const HELD = { hasEvaluated: () => false, reload: async () => {}, fetch: () => Promise.resolve(new Response('starting', { status: 503 })) }
34
+
35
+ function hold(name) {
36
+ const environments = (globalThis.__nitro_vite_envs__ ??= {})
37
+ environments[name] = HELD
38
+ return environment => {
39
+ environments[name] = undefined
40
+ return environment
41
+ }
42
+ }