@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.
- package/LICENSE +16 -0
- package/README.md +41 -2
- package/package.json +107 -4
- package/src/app.mjs +82 -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 +616 -0
- package/src/client.tsx +20 -0
- package/src/contract.mjs +308 -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 +456 -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 +144 -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 +198 -0
- package/src/rust.ts +333 -0
- package/src/server.tsx +1014 -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/list.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// The list contract: one way to ask for a page or a cursor, whatever holds
|
|
2
|
+
// the rows. `listInput` is a schema a `query` takes as its `input`, and a source is what the `@weft-js/db` subpaths build.
|
|
3
|
+
//
|
|
4
|
+
// export const tasks = query({
|
|
5
|
+
// input: listInput({ sort: ['rank', 'title'], filter: ['status'], search: ['title'], pageSize: { default: 25, max: 100 } }),
|
|
6
|
+
// run: ({ input }) => Tasks.list(input),
|
|
7
|
+
// })
|
|
8
|
+
//
|
|
9
|
+
// Every name a client sends is checked against an allowlist twice, here and against the source's `columns`, so no
|
|
10
|
+
// request reaches an identifier. A filter value is a scalar or a list of them: an object is refused, which
|
|
11
|
+
// is what stops `{ $ne: null }` reaching a document store. Only `columns` are selected, returned or written.
|
|
12
|
+
|
|
13
|
+
import { StartError, type StandardSchemaV1 } from './data'
|
|
14
|
+
|
|
15
|
+
/** One value a filter can hold. */
|
|
16
|
+
export type Scalar = string | number | boolean | null
|
|
17
|
+
/** Which way a sort runs. */
|
|
18
|
+
export type Dir = 'asc' | 'desc'
|
|
19
|
+
/** One field and a direction. */
|
|
20
|
+
export interface Sort { field: string; dir: Dir }
|
|
21
|
+
|
|
22
|
+
/** A checked request, as a source takes it. */
|
|
23
|
+
export interface ListParams {
|
|
24
|
+
mode: 'page' | 'cursor'
|
|
25
|
+
limit: number
|
|
26
|
+
/** Rows to skip, in `page` mode. */
|
|
27
|
+
offset: number
|
|
28
|
+
/** The position to continue after, in `cursor` mode: the last row's sort value and id. */
|
|
29
|
+
after: [value: unknown, id: unknown] | null
|
|
30
|
+
sort: Sort | null
|
|
31
|
+
search?: string
|
|
32
|
+
/** A list means any of them. `null` means the field is empty. */
|
|
33
|
+
filter: Record<string, Scalar | Scalar[]>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The envelope warp's `Table` reads. `next` and `prev` go back in as `cursor` or `page`; absent means no such page. */
|
|
37
|
+
export interface ListPage<T> { items: T[]; total?: number; next?: string | number; prev?: string | number }
|
|
38
|
+
|
|
39
|
+
/** What a list lets a client ask for. */
|
|
40
|
+
export interface ListSpec {
|
|
41
|
+
/** Required, even empty: nothing is sortable, filterable or searchable by default. */
|
|
42
|
+
sort: readonly string[]
|
|
43
|
+
filter: readonly string[]
|
|
44
|
+
search?: readonly string[]
|
|
45
|
+
defaultSort?: Sort
|
|
46
|
+
pageSize?: { default?: number; max?: number }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** What a client sends: every field is checked by `listInput`. */
|
|
50
|
+
export interface ListRequest {
|
|
51
|
+
mode?: 'page' | 'cursor'
|
|
52
|
+
page?: number
|
|
53
|
+
pageSize?: number
|
|
54
|
+
cursor?: string | null
|
|
55
|
+
sort?: Sort | null
|
|
56
|
+
search?: string
|
|
57
|
+
filter?: Record<string, Scalar | Scalar[]>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const SEARCH_MAX = 200, IN_MAX = 100, CURSOR_MAX = 1024
|
|
61
|
+
|
|
62
|
+
const scalar = (v: unknown): v is Scalar => v === null || typeof v === 'boolean' || typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v))
|
|
63
|
+
|
|
64
|
+
const pack = (v: unknown) => (v instanceof Date ? { $d: v.toISOString() } : v)
|
|
65
|
+
const stamped = (v: unknown): v is { $d: string } => typeof (v as { $d?: unknown } | null)?.$d === 'string'
|
|
66
|
+
const unpack = (v: unknown) => (stamped(v) ? new Date(v.$d) : v)
|
|
67
|
+
|
|
68
|
+
/** Opaque to a client, and read with the same care as any other input. */
|
|
69
|
+
export function encodeCursor(value: unknown, id: unknown): string {
|
|
70
|
+
const bytes = new TextEncoder().encode(JSON.stringify([pack(value), pack(id)]))
|
|
71
|
+
return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The other way from `encodeCursor`: null for anything that isn't a cursor of ours. */
|
|
75
|
+
export function decodeCursor(text: string): [unknown, unknown] | null {
|
|
76
|
+
if (text.length > CURSOR_MAX) return null
|
|
77
|
+
try {
|
|
78
|
+
const bytes = Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0))
|
|
79
|
+
const list: unknown = JSON.parse(new TextDecoder().decode(bytes))
|
|
80
|
+
const fits = (v: unknown) => scalar(v) || (stamped(v) && !Number.isNaN(Date.parse(v.$d)))
|
|
81
|
+
return Array.isArray(list) && list.length === 2 && list.every(fits) ? [unpack(list[0]), unpack(list[1])] : null
|
|
82
|
+
} catch {
|
|
83
|
+
return null
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A `query`'s `input`. Takes a `ListRequest` from the client, and hands `run` the `ListParams` a source takes. */
|
|
88
|
+
export function listInput(spec: ListSpec): StandardSchemaV1<ListRequest | undefined, ListParams> {
|
|
89
|
+
const fallback = spec.pageSize?.default ?? 25, max = spec.pageSize?.max ?? 100
|
|
90
|
+
const sorts = new Set(spec.sort), filters = new Set(spec.filter)
|
|
91
|
+
const validate = (input: unknown) => {
|
|
92
|
+
const issues: { message: string; path?: string[] }[] = []
|
|
93
|
+
const no = (message: string, ...path: string[]) => void issues.push({ message, path })
|
|
94
|
+
const r = (input ?? {}) as ListRequest
|
|
95
|
+
if (typeof r !== 'object' || Array.isArray(r)) return { issues: [{ message: 'a list request is an object' }] }
|
|
96
|
+
const mode = r.mode ?? (r.cursor !== undefined ? 'cursor' : 'page')
|
|
97
|
+
if (mode !== 'page' && mode !== 'cursor') no('`mode` is `page` or `cursor`', 'mode')
|
|
98
|
+
const size = r.pageSize ?? fallback
|
|
99
|
+
if (!Number.isInteger(size) || size < 1 || size > max) no(`\`pageSize\` is a whole number up to ${max}`, 'pageSize')
|
|
100
|
+
const page = r.page ?? 1
|
|
101
|
+
if (!Number.isInteger(page) || page < 1 || page > 1e6) no('`page` is a whole number from 1', 'page')
|
|
102
|
+
let after: ListParams['after'] = null
|
|
103
|
+
if (r.cursor != null) {
|
|
104
|
+
after = typeof r.cursor === 'string' ? decodeCursor(r.cursor) : null
|
|
105
|
+
if (!after) no('that cursor is not one of ours', 'cursor')
|
|
106
|
+
}
|
|
107
|
+
const sort = r.sort ?? spec.defaultSort ?? null
|
|
108
|
+
if (sort && !(typeof sort === 'object' && sorts.has(sort.field) && (sort.dir === 'asc' || sort.dir === 'desc'))) no('that sort is not allowed', 'sort')
|
|
109
|
+
if (r.search !== undefined && (typeof r.search !== 'string' || r.search.length > SEARCH_MAX)) no(`\`search\` is text up to ${SEARCH_MAX} characters`, 'search')
|
|
110
|
+
if (r.search && !spec.search?.length) no('this list has no search', 'search')
|
|
111
|
+
const filter: ListParams['filter'] = Object.create(null)
|
|
112
|
+
if (r.filter !== undefined && (typeof r.filter !== 'object' || r.filter === null || Array.isArray(r.filter))) no('`filter` is an object', 'filter')
|
|
113
|
+
else for (const key of Object.keys(r.filter ?? {})) {
|
|
114
|
+
const v = r.filter![key]!
|
|
115
|
+
if (!filters.has(key)) no(`\`${key}\` can't be filtered on`, 'filter', key)
|
|
116
|
+
else if (Array.isArray(v) ? !(v.length && v.length <= IN_MAX && v.every(scalar)) : !scalar(v)) no(`\`${key}\` takes a value or a list of up to ${IN_MAX}, not an object`, 'filter', key)
|
|
117
|
+
else filter[key] = v
|
|
118
|
+
}
|
|
119
|
+
if (issues.length) return { issues }
|
|
120
|
+
const params: ListParams = { mode, limit: size, offset: mode === 'page' ? (page - 1) * size : 0, after, sort, filter, ...(r.search ? { search: r.search } : {}) }
|
|
121
|
+
return { value: params }
|
|
122
|
+
}
|
|
123
|
+
return { '~standard': { version: 1, vendor: 'weft', validate } }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- sources --------------------------------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
/** The allowlist a source reads and writes through. */
|
|
129
|
+
export interface SourceSpec {
|
|
130
|
+
/** Public name → stored name. Required: only these are selected, returned or written. */
|
|
131
|
+
columns: Record<string, string>
|
|
132
|
+
/** The public name of the row's id. Default `id`. */
|
|
133
|
+
id?: string
|
|
134
|
+
/** Public names `search` scans. */
|
|
135
|
+
search?: readonly string[]
|
|
136
|
+
/** Public names a write may set. Default every column but the id. */
|
|
137
|
+
write?: readonly string[]
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** One table or collection, read and written the same way whatever holds it. */
|
|
141
|
+
export interface ListSource<Row = Record<string, unknown>> {
|
|
142
|
+
list(params: ListParams): Promise<ListPage<Row>>
|
|
143
|
+
get(id: unknown): Promise<Row | null>
|
|
144
|
+
create(data: Record<string, unknown>): Promise<Row>
|
|
145
|
+
/** Null when there's no such row. */
|
|
146
|
+
update(id: unknown, patch: Record<string, unknown>): Promise<Row | null>
|
|
147
|
+
remove(id: unknown): Promise<boolean>
|
|
148
|
+
/** What it can't do, so a page can leave the control out. */
|
|
149
|
+
readonly limits?: { page?: false; search?: 'prefix' | 'cased' | false; sort?: readonly string[] }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** `db.adapter@1`: what a provider exports. `target` is the driver's handle: a collection, a table, a query function. */
|
|
153
|
+
export interface DbAdapter<T = unknown> {
|
|
154
|
+
readonly name: string
|
|
155
|
+
source<Row = Record<string, unknown>>(target: T, spec: SourceSpec): ListSource<Row>
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const refuse = (what: string) => new StartError('Q002', `the list source refused ${what}`, 'add it to the source\'s `columns`, and to `listInput`', { status: 400 })
|
|
159
|
+
|
|
160
|
+
/** A request checked against the source and put in stored names. Throws on any name outside `columns`. */
|
|
161
|
+
export interface Plan {
|
|
162
|
+
sort: { column: string; dir: Dir; field: string } | null
|
|
163
|
+
/** The last thing to order by, so rows that tie keep one order across pages. */
|
|
164
|
+
id: { column: string; field: string }
|
|
165
|
+
where: { column: string; values: Scalar[] }[]
|
|
166
|
+
search?: { columns: string[]; text: string }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Stored name of the row's id. A spec without one can't address a row, so a write can't go nowhere in silence. */
|
|
170
|
+
export function storedId(spec: SourceSpec): string {
|
|
171
|
+
const id = spec.id ?? 'id'
|
|
172
|
+
if (!Object.hasOwn(spec.columns, id)) throw refuse(`\`${id}\` as the id`)
|
|
173
|
+
return spec.columns[id]!
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Check a request against `spec`, for a source to run. */
|
|
177
|
+
export function plan(spec: SourceSpec, p: ListParams): Plan {
|
|
178
|
+
const stored = (field: string) => (Object.hasOwn(spec.columns, field) ? spec.columns[field]! : (() => { throw refuse(`\`${field}\``) })())
|
|
179
|
+
const id = spec.id ?? 'id'
|
|
180
|
+
const where = Object.keys(p.filter).map(field => ({ column: stored(field), values: [p.filter[field]!].flat() }))
|
|
181
|
+
if (where.some(w => !w.values.every(scalar))) throw refuse('a filter value that is not a scalar')
|
|
182
|
+
const searching = p.search ? spec.search ?? [] : []
|
|
183
|
+
if (p.search && !searching.length) throw refuse('a search')
|
|
184
|
+
return {
|
|
185
|
+
sort: p.sort ? { column: stored(p.sort.field), dir: p.sort.dir, field: p.sort.field } : null,
|
|
186
|
+
id: { column: stored(id), field: id },
|
|
187
|
+
where,
|
|
188
|
+
...(p.search ? { search: { columns: searching.map(stored), text: p.search } } : {}),
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A stored row in public names, with only `columns` in it. */
|
|
193
|
+
export const rowOf = <Row = Record<string, unknown>>(spec: SourceSpec) => {
|
|
194
|
+
const pairs = Object.entries(spec.columns)
|
|
195
|
+
return (row: object) => Object.fromEntries(pairs.filter(([, c]) => c in row).map(([f, c]) => [f, (row as Record<string, unknown>)[c]])) as Row
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** A write in stored names. Refuses anything outside `write`, so a client can't set `role` or move an id. */
|
|
199
|
+
export function written(spec: SourceSpec, data: Record<string, unknown>, creating = false): Record<string, unknown> {
|
|
200
|
+
const id = spec.id ?? 'id'
|
|
201
|
+
const allowed = new Set(spec.write ?? Object.keys(spec.columns).filter(f => f !== id))
|
|
202
|
+
if (creating) allowed.add(id)
|
|
203
|
+
const out: Record<string, unknown> = {}
|
|
204
|
+
for (const field of Object.keys(data)) {
|
|
205
|
+
if (!allowed.has(field) || !Object.hasOwn(spec.columns, field)) throw refuse(`a write to \`${field}\``)
|
|
206
|
+
out[spec.columns[field]!] = data[field]
|
|
207
|
+
}
|
|
208
|
+
return out
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** `rows` are in public names, with one more than `limit` in cursor mode when there is a next page. */
|
|
212
|
+
export function pageOf<R>(rows: R[], p: ListParams, field: { sort?: string | undefined; id: string }, total?: number): ListPage<R> {
|
|
213
|
+
if (p.mode === 'page') {
|
|
214
|
+
const page = p.offset / p.limit + 1
|
|
215
|
+
return { items: rows, ...(total === undefined ? {} : { total }), ...(total !== undefined && p.offset + p.limit < total ? { next: page + 1 } : {}), ...(page > 1 ? { prev: page - 1 } : {}) }
|
|
216
|
+
}
|
|
217
|
+
const more = rows.length > p.limit
|
|
218
|
+
const items = more ? rows.slice(0, p.limit) : rows
|
|
219
|
+
const last = items.at(-1) as R & Record<string, unknown>
|
|
220
|
+
return { items, ...(total === undefined ? {} : { total }), ...(more ? { next: encodeCursor(field.sort ? last[field.sort] : last[field.id], last[field.id]) } : {}) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `LIKE` with `!` as the escape: the one character that means the same on SQLite, Postgres and MySQL. */
|
|
224
|
+
export const likeOf = (text: string) => `%${text.replace(/[!%_]/g, '!$&')}%`.toLowerCase()
|
package/src/listen.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `weft db listen`: the change feed as a process of its own, for an app on functions, which can't hold a connection. It
|
|
2
|
+
// publishes pokes to the app's bus, so it needs a bus every instance reads, and its heartbeat is how the app knows it's up.
|
|
3
|
+
|
|
4
|
+
import type { Bus } from './bus'
|
|
5
|
+
import { createFeed, type Source } from './changes'
|
|
6
|
+
import { StartError } from './data'
|
|
7
|
+
|
|
8
|
+
const BEAT = 10_000
|
|
9
|
+
|
|
10
|
+
/** Listens until the returned `stop`. Run one: two listeners poke twice, which costs a refetch and loses nothing. */
|
|
11
|
+
export async function listen({ changes, bus, say = console.info, beat = BEAT }: { changes: Source; bus: Bus; say?: (line: string) => void; beat?: number }) {
|
|
12
|
+
if (bus.name === 'memory') throw new StartError('R017', '`weft db listen` publishes to the bus, and the memory bus stops at this process', 'set `bus` in `src/realtime.server.ts`')
|
|
13
|
+
const how = await changes.probe()
|
|
14
|
+
say(`weft live: ${how.mode} (${how.via})${how.why ? `: ${how.why}` : ''}`)
|
|
15
|
+
if (how.mode !== 'feed') throw new StartError('R015', `this listener has no real feed${how.why ? `: ${how.why}` : ''}`, how.fix)
|
|
16
|
+
const feed = createFeed(changes, {
|
|
17
|
+
emit: p => void bus.publish('weft:poke', JSON.stringify(p)).catch(e => say(`weft live: publish failed: ${e?.message ?? e}`)),
|
|
18
|
+
status: down => say(down ? `weft live: down: ${down}` : 'weft live: back'),
|
|
19
|
+
})
|
|
20
|
+
await feed.start(how)
|
|
21
|
+
const beats = setInterval(() => void bus.publish('weft:poke', '{"v":1,"b":1}').catch(() => {}), beat)
|
|
22
|
+
await bus.publish('weft:poke', '{"v":1,"b":1}')
|
|
23
|
+
return async () => {
|
|
24
|
+
clearInterval(beats)
|
|
25
|
+
await feed.stop()
|
|
26
|
+
await bus.close?.()
|
|
27
|
+
}
|
|
28
|
+
}
|
package/src/live-fake.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @weft-js/core/test's realtime transport (research 30 §7): the real hub, a memory bus, frames through JSON.
|
|
2
|
+
|
|
3
|
+
import { fakeScope, FnError, net, runtime, type Fn } from './data'
|
|
4
|
+
import type { Body } from './hub'
|
|
5
|
+
|
|
6
|
+
const loadExec = async () => (runtime.exec ??= await import('./exec'))
|
|
7
|
+
|
|
8
|
+
/** A change to a connection's subscriptions, as the route would take it. */
|
|
9
|
+
export async function change(body: Body) {
|
|
10
|
+
const [{ hub }, exec] = await Promise.all([import('./hub'), loadExec()])
|
|
11
|
+
const c = hub().find(body.k!)
|
|
12
|
+
if (!c) throw new FnError({}, 404)
|
|
13
|
+
const request = new Request('http://localhost/_weft/rt', { method: 'POST' })
|
|
14
|
+
await hub().change(c, request, fakeScope(exec, net()!, request).options, body)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A connection to the real hub in this process. */
|
|
18
|
+
export async function open(body: Body, frame: (event: string, data: unknown) => void, signal: AbortSignal, up: () => void, subs: Map<string, { fn: Fn; input: unknown }>, closing: (close: () => unknown) => void) {
|
|
19
|
+
const n = net()!
|
|
20
|
+
const [{ hub, resetHub }, exec] = await Promise.all([import('./hub'), loadExec()])
|
|
21
|
+
if (!n.online()) throw Object.assign(new Error('offline'), { name: 'OfflineError' })
|
|
22
|
+
closing(resetHub)
|
|
23
|
+
const h = hub()
|
|
24
|
+
runtime.poke ??= (tags, origin) => hub().poke(tags, origin)
|
|
25
|
+
const request = new Request('http://localhost/_weft/rt', { method: 'POST' })
|
|
26
|
+
return new Promise<void>((done, fail) => {
|
|
27
|
+
let conn: Parameters<typeof h.close>[0] | undefined
|
|
28
|
+
const sink = { write: (e: string, d: string) => frame(e, JSON.parse(d)), backlog: () => 0, close: () => done() }
|
|
29
|
+
n.rt = {
|
|
30
|
+
push: (id, msg) => h.push(id, msg),
|
|
31
|
+
poke: tags => h.poke(tags),
|
|
32
|
+
drop: () => conn && h.close(conn),
|
|
33
|
+
subscriptions: () => [...subs.values()].map(s => ({ kind: s.fn.kind, id: s.fn.$id, input: s.input })),
|
|
34
|
+
}
|
|
35
|
+
signal.addEventListener('abort', () => (conn ? h.close(conn) : done()))
|
|
36
|
+
h.connect(request, fakeScope(exec, n, request), body, sink).then(c => {
|
|
37
|
+
conn = c
|
|
38
|
+
if (signal.aborted) h.close(c)
|
|
39
|
+
up()
|
|
40
|
+
}, fail)
|
|
41
|
+
})
|
|
42
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// What a deploy target can do for live data, from its preset and config alone: `weft check`, `weft build` and `weft
|
|
2
|
+
// doctor` read this. What only a running server can know (a pooler, a missing trigger) the server probes at startup.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const LISTENER = 'run `weft db listen` on any always-on host (a VPS, Fly, Railway), publishing into a shared bus, and set `dbChanges(db, tables, { listener: \'external\' })`'
|
|
8
|
+
const SERVER = 'deploy to a server preset (`node_server`, `bun`, `deno_server`, or a container on Fly, Railway or Render)'
|
|
9
|
+
const POLLED = 'or poll the query yourself by reloading: `.live()` still updates on mutations'
|
|
10
|
+
|
|
11
|
+
/** Presets that give each request its own short-lived process. */
|
|
12
|
+
const FUNCTIONS = [
|
|
13
|
+
[/^vercel/, 'Vercel Functions'], [/^netlify/, 'Netlify Functions'], [/^aws[-_]lambda/, 'AWS Lambda'], [/^azure/, 'Azure Functions'],
|
|
14
|
+
[/^firebase/, 'Firebase Functions'], [/^cloudflare/, 'Cloudflare Workers'], [/^deno[-_]deploy/, 'Deno Deploy'],
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string} [preset]
|
|
19
|
+
* @returns {{ preset: string, held: boolean, host: string }} `held`: one process outlives a request, so it can hold a connection and a timer
|
|
20
|
+
*/
|
|
21
|
+
export function liveTarget(preset = 'node') {
|
|
22
|
+
const hit = FUNCTIONS.find(([re]) => re.test(preset))
|
|
23
|
+
return hit ? { preset, held: false, host: hit[1] } : { preset, held: true, host: 'a long-running server' }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SKIP = new Set(['node_modules', '.weft', '.output', 'dist'])
|
|
27
|
+
|
|
28
|
+
/** `live: { required: true }` on a query, and `.live(input, { required: true })` in a component, with where. */
|
|
29
|
+
export function scanRequired(root) {
|
|
30
|
+
const out = []
|
|
31
|
+
const walk = dir => {
|
|
32
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
33
|
+
const f = join(dir, d.name)
|
|
34
|
+
if (d.isDirectory()) { if (!SKIP.has(d.name)) walk(f) } else if (/\.[cm]?[jt]sx?$/.test(d.name) && !/\.test\./.test(d.name)) read(f)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const read = file => {
|
|
38
|
+
const text = readFileSync(file, 'utf8')
|
|
39
|
+
if (!/required:\s*true/.test(text)) return
|
|
40
|
+
for (const m of text.matchAll(/\blive:\s*\{[^}]*required:\s*true/g)) {
|
|
41
|
+
const name = [...text.slice(0, m.index).matchAll(/\b(\w+)\s*=\s*query\s*[(<]/g)].at(-1)?.[1]
|
|
42
|
+
if (name) out.push({ name, file: file.slice(root.length + 1) })
|
|
43
|
+
}
|
|
44
|
+
for (const m of text.matchAll(/\b(\w+)\.live\(([^;\n]*?)\brequired:\s*true/g)) out.push({ name: m[1], file: file.slice(root.length + 1) })
|
|
45
|
+
}
|
|
46
|
+
const src = join(root, 'src')
|
|
47
|
+
if (existsSync(src)) walk(src)
|
|
48
|
+
return [...new Map(out.map(r => [r.name, r])).values()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What this deploy gets, for `weft doctor`, and the diagnostics `weft check` and `weft build` report.
|
|
53
|
+
* @param {{ preset?: string, source?: { name: string, external?: boolean, tables: string[] }, required?: { name: string, file: string }[] }} input
|
|
54
|
+
*/
|
|
55
|
+
export function livePlan({ preset, source, required = [] }) {
|
|
56
|
+
const target = liveTarget(preset)
|
|
57
|
+
const names = required.map(r => `\`${r.name}\``).join(', ')
|
|
58
|
+
const diagnostics = []
|
|
59
|
+
const options = target.held ? [] : [LISTENER, SERVER]
|
|
60
|
+
let mode, says
|
|
61
|
+
if (!source) {
|
|
62
|
+
mode = 'app'
|
|
63
|
+
says = 'Mutations update `.live()` queries. Writes from outside the app do not: nothing listens to the database.'
|
|
64
|
+
if (required.length) diagnostics.push({ code: 'R013', severity: 'error', why: `${names} require${required.length > 1 ? '' : 's'} a live feed, and \`src/realtime.server.ts\` sets no \`changes\`. Add \`changes: dbChanges(db, [tables])\`, or drop \`required\`.` })
|
|
65
|
+
} else if (source.external) {
|
|
66
|
+
mode = 'external'
|
|
67
|
+
says = `A separate listener reports writes to ${source.tables.join(', ')} through the bus. It is yours to keep running; \`.live()\` shows \`reconnecting\` when it goes quiet.`
|
|
68
|
+
} else if (target.held) {
|
|
69
|
+
mode = 'feed'
|
|
70
|
+
says = `This server listens to ${source.name} for writes to ${source.tables.join(', ')}. At startup it probes what the database allows, and logs what it settled on.`
|
|
71
|
+
} else {
|
|
72
|
+
mode = 'off'
|
|
73
|
+
says = `${target.host} ends a process with its request, so nothing can hold a connection to ${source.name}: mutations update \`.live()\`, and writes from outside the app do not. \`.live()\` shows \`degraded\`.`
|
|
74
|
+
if (required.length) diagnostics.push({ code: 'R013', severity: 'error', why: `${names} require${required.length > 1 ? '' : 's'} a live feed, and \`${target.preset}\` can't hold one: ${target.host} ends a process with its request. Either ${LISTENER}, or ${SERVER}, or drop \`required\`.` })
|
|
75
|
+
else diagnostics.push({ code: 'R014', severity: 'warning', why: `\`changes\` needs a process that stays up, and on \`${target.preset}\` none does: writes from outside the app won't reach \`.live()\`. Either ${LISTENER}, or ${SERVER}, ${POLLED}.` })
|
|
76
|
+
}
|
|
77
|
+
return { preset: target.preset, held: target.held, host: target.host, mode, says, required: required.map(r => r.name), options, diagnostics }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- `verify` ---------------------------------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const PRODUCTION = /(^|[.\-_])(prod|production|live|www|main|master)([.\-_]|$)/i
|
|
83
|
+
const NOT_PRODUCTION = /(^|[.\-_])(staging|stage|stg|dev|develop|development|test|testing|qa|sandbox|preview|uat|demo)([.\-_]|$)|^localhost$|^127\.|^\[?::1\]?$/i
|
|
84
|
+
|
|
85
|
+
const hostOf = url => { try { return new URL(url).hostname } catch { return url } }
|
|
86
|
+
const dbNameOf = url => { try { return new URL(url).pathname.slice(1) } catch { return '' } }
|
|
87
|
+
|
|
88
|
+
const HOWTO = 'Set `WEFT_STAGING_URL` to a running staging app, `STAGING_DATABASE_URL` to its database, or both for the whole chain.'
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where a `verify` points, from the query's declaration, `live.targets` in weft.config.ts and the environment.
|
|
92
|
+
* @returns {{ name: string, url?: string, db?: string, marked: boolean } | { missing: string }}
|
|
93
|
+
*/
|
|
94
|
+
export function resolveTarget(verify, { targets = {}, env = process.env } = {}) {
|
|
95
|
+
if (typeof verify === 'object' && verify.target !== undefined) return resolveTarget(verify.target, { targets, env })
|
|
96
|
+
if (verify === true) {
|
|
97
|
+
const url = env.WEFT_STAGING_URL, db = env.STAGING_DATABASE_URL
|
|
98
|
+
return url || db ? { name: 'staging', url, db, marked: false } : { missing: `\`verify: true\` found neither \`WEFT_STAGING_URL\` nor \`STAGING_DATABASE_URL\`. ${HOWTO}` }
|
|
99
|
+
}
|
|
100
|
+
if (typeof verify === 'string') {
|
|
101
|
+
const t = targets[verify]
|
|
102
|
+
if (!t) return { missing: `\`verify: '${verify}'\` names no target. Add it to weft.config.ts: \`live: { targets: { ${verify}: { url: 'https://…', db: 'STAGING_DATABASE_URL', production: false } } }\`` }
|
|
103
|
+
const db = t.db && (t.db.includes('://') ? t.db : env[t.db])
|
|
104
|
+
if (t.db && !db) return { missing: `target \`${verify}\` reads its database from \`${t.db}\`, which isn't set on this machine.` }
|
|
105
|
+
return { name: verify, url: t.url, db, marked: t.production === false }
|
|
106
|
+
}
|
|
107
|
+
if (!verify.url && !verify.db) return { missing: `\`verify\` got an empty target (is its env var set?). ${HOWTO}` }
|
|
108
|
+
return { name: 'inline', url: verify.url, db: verify.db, marked: false }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Why this target must not be touched: R022. Anything that says production is refused, whatever config says. */
|
|
112
|
+
export function refusal(target, { targets = {} } = {}) {
|
|
113
|
+
const hosts = [target.url && hostOf(target.url), target.db && hostOf(target.db)].filter(Boolean)
|
|
114
|
+
const names = [target.name, ...hosts, ...(target.db ? [dbNameOf(target.db)] : [])]
|
|
115
|
+
const said = names.find(n => PRODUCTION.test(n))
|
|
116
|
+
if (said) return `\`${said}\` looks like production: \`verify\` writes rows, and won't go near it`
|
|
117
|
+
if (target.name !== 'inline' && target.name !== 'staging' && targets[target.name]?.production !== false) return `target \`${target.name}\` isn't marked: add \`production: false\` to it in weft.config.ts`
|
|
118
|
+
if (target.marked) return
|
|
119
|
+
const unsure = hosts.find(h => !NOT_PRODUCTION.test(h))
|
|
120
|
+
if (unsure) return `nothing says \`${unsure}\` isn't production. Name it in weft.config.ts: \`live: { targets: { staging: { db: 'STAGING_DATABASE_URL', production: false } } }\`, and use \`verify: 'staging'\``
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const PASSWORD = /[a-z][a-z0-9+.-]*:\/\/[^\s'"`/:@]+:[^\s'"`/@]+@/i
|
|
124
|
+
|
|
125
|
+
/** R020: a connection string with a password, written into source. An env var's value never reaches this. */
|
|
126
|
+
export function literalSecrets(root) {
|
|
127
|
+
const out = []
|
|
128
|
+
const look = file => {
|
|
129
|
+
const text = readFileSync(file, 'utf8')
|
|
130
|
+
if (!/\b(verify|targets)\b/.test(text)) return
|
|
131
|
+
text.split('\n').forEach((line, i) => {
|
|
132
|
+
if (PASSWORD.test(line) && /\b(db|url|verify)\b/.test(line)) out.push({ file: file.slice(root.length + 1), line: i + 1 })
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
const walk = dir => {
|
|
136
|
+
for (const d of readdirSync(dir, { withFileTypes: true })) {
|
|
137
|
+
const f = join(dir, d.name)
|
|
138
|
+
if (d.isDirectory()) { if (!SKIP.has(d.name)) walk(f) } else if (/\.[cm]?[jt]sx?$/.test(d.name)) look(f)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
|
|
142
|
+
for (const f of ['weft.config.ts', 'weft.config.js', 'weft.config.mjs']) if (existsSync(join(root, f))) look(join(root, f))
|
|
143
|
+
return out
|
|
144
|
+
}
|