@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/contract.mjs
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// `.weft/contract.json` (PLAN §4.1, research 28 §4): every query and mutation as JSON Schema, with tags, cache and auth
|
|
2
|
+
// scope. `weft build` writes it; the OpenAPI export, the MCP server and Rust codegen read it. The server modules are
|
|
3
|
+
// loaded through Vite, never run: creating a database client connects to nothing.
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto'
|
|
6
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { join, resolve } from 'node:path'
|
|
8
|
+
import { createServer } from 'vite'
|
|
9
|
+
import weft from '@weft-js/core/vite'
|
|
10
|
+
import { literalSecrets, livePlan, refusal, resolveTarget, scanRequired } from './live-target.mjs'
|
|
11
|
+
import { readMigrations } from './migrations.mjs'
|
|
12
|
+
import { shapeTree, toJson } from './shape.mjs'
|
|
13
|
+
import weftStart, { startDir } from './vite.mjs'
|
|
14
|
+
|
|
15
|
+
const tagsOf = t => (typeof t === 'function' ? 'dynamic' : [...(t ?? [])])
|
|
16
|
+
|
|
17
|
+
/** The first string or array in a JSON Schema with no upper bound, as a path. */
|
|
18
|
+
function unbounded(s, path = 'input') {
|
|
19
|
+
if (!s || typeof s !== 'object') return
|
|
20
|
+
if (s.type === 'string' && s.maxLength === undefined && !s.enum && s.const === undefined) return path
|
|
21
|
+
if (s.type === 'array' && s.maxItems === undefined) return path
|
|
22
|
+
if (s.items) {
|
|
23
|
+
const hit = unbounded(s.items, `${path}[]`)
|
|
24
|
+
if (hit) return hit
|
|
25
|
+
}
|
|
26
|
+
for (const [k, v] of Object.entries(s.properties ?? {})) {
|
|
27
|
+
const hit = unbounded(v, `${path}.${k}`)
|
|
28
|
+
if (hit) return hit
|
|
29
|
+
}
|
|
30
|
+
for (const v of [...(s.anyOf ?? []), ...(s.oneOf ?? [])]) {
|
|
31
|
+
const hit = unbounded(v, path)
|
|
32
|
+
if (hit) return hit
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A one-way `.transform()` or `preprocess` in a Zod schema: parsed input it touched can't be sent on (`encode` throws). */
|
|
37
|
+
function oneWay(schema, seen = new Set()) {
|
|
38
|
+
if (!schema || typeof schema !== 'object' || seen.has(schema)) return false
|
|
39
|
+
seen.add(schema)
|
|
40
|
+
const def = schema._zod?.def
|
|
41
|
+
if (def?.type === 'transform') return true
|
|
42
|
+
return Object.values(def ?? schema).some(v => oneWay(v, seen)) // arrays and `shape` hold schemas too
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The first union in a JSON Schema with two object or array arms, as a path: typify has no faithful type for one. */
|
|
46
|
+
function plainUnion(s, path) {
|
|
47
|
+
if (!s || typeof s !== 'object') return
|
|
48
|
+
const arms = s.anyOf?.filter(x => x.type !== 'null')
|
|
49
|
+
if (arms?.length > 1 && arms.some(x => x.type === 'object' || x.type === 'array' || x.$ref)) return path
|
|
50
|
+
for (const [k, v] of Object.entries(s.properties ?? {})) {
|
|
51
|
+
const hit = plainUnion(v, `${path}.${k}`)
|
|
52
|
+
if (hit) return hit
|
|
53
|
+
}
|
|
54
|
+
for (const v of [s.items, ...(s.prefixItems ?? []), ...(s.anyOf ?? []), ...(s.oneOf ?? []), ...Object.values(s.$defs ?? {})]) {
|
|
55
|
+
const hit = plainUnion(v, path)
|
|
56
|
+
if (hit) return hit
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const CODEC = '`z.codec(z.iso.datetime(), z.date(), { decode, encode })` for a date, `z.codec(z.string(), z.bigint(), …)` for a bigint'
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A function with `impl`, as a sidecar sees it (research 36 §3). The wire is the *input* side of every schema: TS sends
|
|
64
|
+
* parsed input encoded back, and parses what comes back, so codecs and transforms on an output are TS's business.
|
|
65
|
+
*/
|
|
66
|
+
function implOf(f, report) {
|
|
67
|
+
const { impl, input, output, errors = {} } = f.def
|
|
68
|
+
const r101 = why => report({ code: 'R101', severity: 'error', why: `\`${f.$id}\`: ${why}` })
|
|
69
|
+
const wire = (schema, what) => {
|
|
70
|
+
const json = toJson(schema, 'input')
|
|
71
|
+
if (!json) r101(`${what} has a type JSON can't carry, so no Rust type can be generated. Use ${CODEC}.`)
|
|
72
|
+
return json ?? {}
|
|
73
|
+
}
|
|
74
|
+
if (input) wire(input, '`input`')
|
|
75
|
+
if (input && oneWay(input)) r101('`input` has a one-way `.transform()`, so its parsed value can\'t be sent. Use `z.codec(…)`, or do it in Rust.')
|
|
76
|
+
if (!output) r101('a function with `impl` needs `output`: it\'s the contract for what comes back, and `z.null()` says "nothing".')
|
|
77
|
+
return {
|
|
78
|
+
kind: impl.kind, target: impl.target, service: impl.service,
|
|
79
|
+
ctx: f.def.cache && f.def.cache.scope !== 'user' ? 'shared' : f.def.auth === 'user' ? 'user' : 'ctx',
|
|
80
|
+
output: output ? wire(output, '`output`') : {},
|
|
81
|
+
errors: Object.fromEntries(Object.entries(errors).map(([code, schema]) => [code, wire(schema, `error \`${code}\``)])),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** What a sidecar is built from, per service: a binary whose hash differs was built from another contract (R103). */
|
|
86
|
+
function services(functions) {
|
|
87
|
+
const out = {}
|
|
88
|
+
for (const f of functions) if (f.impl) (out[f.impl.service] ??= { kind: f.impl.kind, hash: createHash('sha256'), functions: [] }).functions.push(f.id)
|
|
89
|
+
for (const f of functions) if (f.impl) out[f.impl.service].hash.update(JSON.stringify([f.id, f.kind, f.input, f.impl]))
|
|
90
|
+
return Object.fromEntries(Object.entries(out).map(([k, v]) => [k, { kind: v.kind, hash: v.hash.digest('hex').slice(0, 16), functions: v.functions }]))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Presets with no file for an addon or a `.wasm` to be read from. */
|
|
94
|
+
const NO_FILES = /^(cloudflare|vercel[-_]edge|netlify[-_]edge|deno[-_]deploy|bunny|fastly)/
|
|
95
|
+
/** Presets that run where they were built, so the addon from this machine is the right one. */
|
|
96
|
+
const LOCAL = /^(node|bun|deno[-_]server)/
|
|
97
|
+
|
|
98
|
+
/** R104 for a build: `native()` on a preset that has no way to run it. */
|
|
99
|
+
export function nativeProblem({ service, preset, wasm }) {
|
|
100
|
+
if (preset && NO_FILES.test(preset)) return `\`native('${service}')\` can't load on the \`${preset}\` preset: it has no file system, so neither an addon nor a \`.wasm\` file reaches it. Use \`remote('rust://${service}')\`, which is the same crate behind \`weft::router\``
|
|
101
|
+
if (wasm === false && preset && !LOCAL.test(preset)) return `\`native('${service}')\` has no WASM, and an addon built here may not fit the \`${preset}\` preset's machines. Drop \`wasm: false\` from \`rust.services.${service}\`, or use \`remote()\``
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @param {Map<string, any>} fns @param {{ unions?: 'allow' }} [rust] the config's `rust` */
|
|
105
|
+
export function describe(fns, rust = {}) {
|
|
106
|
+
const diagnostics = []
|
|
107
|
+
const functions = [...fns.values()].sort((a, b) => a.$id.localeCompare(b.$id)).map(f => {
|
|
108
|
+
const schema = (key, side) => {
|
|
109
|
+
const s = f.def[key]
|
|
110
|
+
if (!s) return undefined
|
|
111
|
+
const json = toJson(s, side)
|
|
112
|
+
if (!json) diagnostics.push({ code: 'C001', severity: 'warning', why: `\`${f.$id}\`'s \`${key}\` schema can't become JSON Schema: the contract says \`{}\`.` })
|
|
113
|
+
return json ?? {}
|
|
114
|
+
}
|
|
115
|
+
const errors = [...(f.def.input ? ['Q002'] : []), ...(f.def.auth ? ['A002'] : []), ...(f.kind === 'mutation' ? ['Q010'] : []), ...Object.keys(f.def.errors ?? {})]
|
|
116
|
+
const impl = f.def.impl && implOf(f, d => diagnostics.push(d))
|
|
117
|
+
const input = impl ? (f.def.input ? toJson(f.def.input, 'input') ?? {} : null) : schema('input', 'input') ?? null
|
|
118
|
+
if (impl && rust.unions !== 'allow') {
|
|
119
|
+
const hit = [['input', input], ['output', impl.output], ...Object.entries(impl.errors).map(([k, v]) => [`error ${k}`, v])].map(([name, s]) => plainUnion(s, name)).find(Boolean)
|
|
120
|
+
if (hit) diagnostics.push({ code: 'R102', severity: 'error', why: `\`${f.$id}\`'s \`${hit}\` is a plain \`z.union\` of objects: it has no tag, so Rust can't tell its arms apart reliably. Use \`z.discriminatedUnion\`, or set \`rust: { unions: 'allow' }\` in weft.config.ts.` })
|
|
121
|
+
}
|
|
122
|
+
const bound = (json, path) => {
|
|
123
|
+
const hit = unbounded(json, path)
|
|
124
|
+
if (hit) diagnostics.push({ code: 'R005', severity: 'warning', why: `\`${f.$id}\`'s \`${hit}\` has no upper bound: a client decides how much the server holds. Add \`.max()\`.` })
|
|
125
|
+
}
|
|
126
|
+
if (f.kind === 'stream') bound(input)
|
|
127
|
+
if (f.kind === 'channel' || f.kind === 'shared') {
|
|
128
|
+
const part = key => (f.def[key] ? toJson(f.def[key], 'input') ?? {} : undefined)
|
|
129
|
+
const messages = list => Object.fromEntries(Object.entries(list ?? {}).map(([k, s]) => [k, toJson(s, 'input') ?? {}]))
|
|
130
|
+
const client = f.kind === 'shared' ? { value: part('value') } : messages(f.def.client)
|
|
131
|
+
bound(part(f.kind === 'shared' ? 'key' : 'params'), f.kind === 'shared' ? 'key' : 'params')
|
|
132
|
+
for (const [k, json] of Object.entries(client)) bound(json, `client.${k}`)
|
|
133
|
+
return { id: f.$id, name: f.$id.replace(/\..*$/, ''), kind: f.kind, input: part(f.kind === 'shared' ? 'key' : 'params') ?? null, output: null, client, server: messages(f.def.server), auth: 'authorize', errors: ['R011', 'Q002'] }
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
id: f.$id, name: f.$id.replace(/\..*$/, ''), kind: f.kind,
|
|
137
|
+
input, output: impl ? impl.output : schema(f.kind === 'stream' ? 'message' : 'output', 'output') ?? null,
|
|
138
|
+
...(impl && { impl }),
|
|
139
|
+
...(f.kind === 'query' ? { tags: tagsOf(f.def.tags), cache: f.def.cache ?? null } : f.kind === 'mutation' ? { invalidates: tagsOf(f.def.invalidates) } : {}),
|
|
140
|
+
auth: f.def.auth ?? 'public', errors,
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
const rusts = services(functions)
|
|
144
|
+
for (const f of functions) if (f.impl && f.impl.kind !== rusts[f.impl.service].kind) diagnostics.push({ code: 'R109', severity: 'error', why: `\`${f.impl.service}\` is named by both \`remote()\` and \`native()\`: a crate is one or the other. Rename one.` })
|
|
145
|
+
return { version: 1, ...(Object.keys(rusts).length && { services: rusts }), functions, diagnostics }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Presets that run many instances: a process-local bus reaches a fraction of the clients (R002). */
|
|
149
|
+
const MULTI = /^(vercel|netlify|aws[-_]|azure|cloudflare|firebase|deno[-_]deploy|digitalocean|render|koyeb|zeabur)/
|
|
150
|
+
|
|
151
|
+
const usesLive = root => {
|
|
152
|
+
const walk = dir => readdirSync(dir, { withFileTypes: true }).some(d => {
|
|
153
|
+
const f = join(dir, d.name)
|
|
154
|
+
return d.isDirectory() ? walk(f) : /\.[cm]?[jt]sx?$/.test(d.name) && /\.live\(/.test(readFileSync(f, 'utf8'))
|
|
155
|
+
})
|
|
156
|
+
return existsSync(join(root, 'src')) && walk(join(root, 'src'))
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* R002 for one build: `bus` is the configured bus's name.
|
|
161
|
+
* @returns {string | undefined} the message when this build would drop messages
|
|
162
|
+
*/
|
|
163
|
+
export const busProblem = ({ preset, bus, streams, root }) =>
|
|
164
|
+
bus === 'durable-object' && preset !== 'cloudflare_durable'
|
|
165
|
+
? `\`durableObjectBus\` holds a socket open, and on the \`${preset}\` preset that socket dies with the request that opened it: use the \`cloudflare_durable\` preset`
|
|
166
|
+
: bus === 'memory' && MULTI.test(preset ?? '') && (streams || usesLive(root))
|
|
167
|
+
? `the \`${preset}\` preset runs many instances, and the memory bus only reaches clients on its own: pokes and messages would be lost`
|
|
168
|
+
: undefined
|
|
169
|
+
|
|
170
|
+
/** What this deploy gets from live data, with the config loaded. R018 is a table `changes` lists and no migration covers. */
|
|
171
|
+
function planOf({ root, preset, realtime }) {
|
|
172
|
+
const source = realtime?.changes
|
|
173
|
+
const plan = livePlan({ preset, source: source && { name: source.name, external: source.external, tables: source.tables }, required: scanRequired(root) })
|
|
174
|
+
if (source?.dialect) {
|
|
175
|
+
const sql = readMigrations(root).map(m => m.sql).join('\n')
|
|
176
|
+
const missing = source.tables.filter(t => !new RegExp(`weft_changes_(${t.replace(/\W/g, '_')}_)?upd\\b[^;]*["\`\\s]${t}["\`\\s]`).test(sql))
|
|
177
|
+
if (missing.length) plan.diagnostics.push({ code: 'R018', severity: 'warning', why: `no migration adds change triggers to ${missing.map(t => `\`${t}\``).join(', ')}: run \`weft db live\`. Until then the server falls back to polling.` })
|
|
178
|
+
}
|
|
179
|
+
return plan
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const verifiedFile = root => join(root, '.weft', 'verify.json')
|
|
183
|
+
const readVerified = root => { try { return JSON.parse(readFileSync(verifiedFile(root), 'utf8')) } catch { return {} } }
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every query's `live.verify`, run. `strict` is a build: a target that isn't reachable from here fails it, where a
|
|
187
|
+
* check warns and goes on. A pass is kept by the query's contract entry and target, so an unchanged query runs once.
|
|
188
|
+
* @returns {Promise<{ diagnostics: any[], verified: any[] }>}
|
|
189
|
+
*/
|
|
190
|
+
async function verifyAll({ root, fns, realtime, server, live, strict }) {
|
|
191
|
+
const diagnostics = [], verified = []
|
|
192
|
+
for (const hit of literalSecrets(root)) diagnostics.push({ code: 'R020', severity: 'error', file: hit.file, why: `${hit.file}:${hit.line} has a connection string with a password in it: it would be committed. Read it from the environment: \`verify: { db: process.env.STAGING_DATABASE_URL }\`, or \`db: 'STAGING_DATABASE_URL'\` in a target.` })
|
|
193
|
+
const queries = [...fns.values()].filter(f => f.kind === 'query' && f.def.live?.verify)
|
|
194
|
+
if (!queries.length) return { diagnostics, verified }
|
|
195
|
+
const { roundTrip } = await server.ssrLoadModule(join(startDir, 'verify.ts'))
|
|
196
|
+
const cache = readVerified(root)
|
|
197
|
+
const contract = describe(fns).functions
|
|
198
|
+
const { env } = process
|
|
199
|
+
for (const f of queries) {
|
|
200
|
+
const name = f.$id.replace(/\..*$/, '')
|
|
201
|
+
const v = f.def.live.verify
|
|
202
|
+
const options = typeof v === 'object' ? v : {}
|
|
203
|
+
const target = resolveTarget(v, { targets: live?.targets, env })
|
|
204
|
+
if (target.missing) {
|
|
205
|
+
diagnostics.push({ code: 'R021', severity: strict ? 'error' : 'warning', why: `\`${name}\` can't be verified on this machine: ${target.missing}${strict ? ' A build needs it, because the query declares it.' : ' Skipped.'}` })
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
const stop = refusal(target, { targets: live?.targets })
|
|
209
|
+
if (stop) {
|
|
210
|
+
diagnostics.push({ code: 'R022', severity: 'error', why: `\`${name}\`: ${stop}.` })
|
|
211
|
+
continue
|
|
212
|
+
}
|
|
213
|
+
const key = createHash('sha256').update(JSON.stringify([contract.find(c => c.id === f.$id), realtime?.changes?.tables, target.name, target.url ?? '', !!target.db])).digest('hex').slice(0, 16)
|
|
214
|
+
if (cache[f.$id]?.key === key && !env.WEFT_VERIFY_FRESH) {
|
|
215
|
+
verified.push({ name, cached: true, ...cache[f.$id].result })
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
const result = await roundTrip({ fn: f, source: realtime?.changes, url: target.url, db: target.db, options, env })
|
|
219
|
+
verified.push({ name, ...result })
|
|
220
|
+
if (result.ok) cache[f.$id] = { key, result, at: new Date().toISOString() }
|
|
221
|
+
else {
|
|
222
|
+
delete cache[f.$id]
|
|
223
|
+
diagnostics.push({ code: 'R019', severity: 'error', why: `\`${name}\` failed its live round trip at \`${result.broke.link}\`: ${result.broke.why}.` })
|
|
224
|
+
}
|
|
225
|
+
if (result.ok && result.untested.length) diagnostics.push({ code: 'R023', severity: 'warning', why: `\`${name}\` passed, with ${result.untested.map(u => `\`${u.link}\``).join(', ')} untested: ${[...new Set(result.untested.map(u => u.why))].join(' ')}` })
|
|
226
|
+
}
|
|
227
|
+
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
228
|
+
writeFileSync(verifiedFile(root), JSON.stringify(cache, null, 2) + '\n')
|
|
229
|
+
return { diagnostics, verified }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The deploy's live plan on its own, for `weft check` and `weft doctor`: no contract is written. */
|
|
233
|
+
export const readLivePlan = ({ root, site, preset, live, verify = true }) => {
|
|
234
|
+
root = resolve(root)
|
|
235
|
+
return withFns({ root, site }, async (fns, server) => {
|
|
236
|
+
const realtime = (await server.ssrLoadModule('virtual:weft/realtime')).default
|
|
237
|
+
const plan = planOf({ root, preset, realtime })
|
|
238
|
+
if (!verify) return { ...plan, verified: [] }
|
|
239
|
+
const { diagnostics, verified } = await verifyAll({ root, fns, realtime, server, live, strict: false })
|
|
240
|
+
return { ...plan, diagnostics: [...plan.diagnostics, ...diagnostics], verified }
|
|
241
|
+
})
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** `src/realtime.server.ts` loaded, for `weft db live` and `weft db listen`. `fn` holds it open as long as it needs. */
|
|
245
|
+
export const withRealtime = ({ root, site }, fn) => withFns({ root: resolve(root), site }, async (_, server) => fn((await server.ssrLoadModule('virtual:weft/realtime')).default ?? {}, server))
|
|
246
|
+
|
|
247
|
+
/** Every query's shape tree (research 31 §3), by id. */
|
|
248
|
+
export function shapeTable(fns) {
|
|
249
|
+
const table = {}
|
|
250
|
+
for (const f of fns.values()) {
|
|
251
|
+
const tree = f.kind === 'query' ? shapeTree(f.def) : undefined
|
|
252
|
+
if (tree !== undefined) table[f.$id] = tree
|
|
253
|
+
}
|
|
254
|
+
return table
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* `fn(fns, server)` with every server module loaded through a private Vite server. A dev server sets `NODE_ENV` to
|
|
259
|
+
* development when nothing had: that's put back after, or `weft build`, which reads the live plan first, would build
|
|
260
|
+
* with `import.meta.env.DEV` on and ship every dev-only diagnostic.
|
|
261
|
+
*/
|
|
262
|
+
async function withFns({ root, site = {} }, fn) {
|
|
263
|
+
const env = process.env.NODE_ENV
|
|
264
|
+
let server
|
|
265
|
+
try {
|
|
266
|
+
server = await createServer({
|
|
267
|
+
root, configFile: false, logLevel: 'silent', appType: 'custom',
|
|
268
|
+
server: { middlewareMode: true, hmr: false, ws: false },
|
|
269
|
+
plugins: [weft({ hydratable: true }), weftStart({ root, site })],
|
|
270
|
+
})
|
|
271
|
+
await server.ssrLoadModule('virtual:weft/fns')
|
|
272
|
+
const { fns } = await server.ssrLoadModule(join(startDir, 'data.ts'))
|
|
273
|
+
return await fn(fns, server)
|
|
274
|
+
} finally {
|
|
275
|
+
await server?.close()
|
|
276
|
+
if (env === undefined) delete process.env.NODE_ENV
|
|
277
|
+
else process.env.NODE_ENV = env
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The shape table at build: nothing is running yet, so the server modules load the way the contract's do. */
|
|
282
|
+
export const readShapes = options => withFns({ ...options, root: resolve(options.root) }, shapeTable)
|
|
283
|
+
|
|
284
|
+
/** Load every server module and write `.weft/contract.json`. */
|
|
285
|
+
export function writeContract({ root, site = {}, preset, rust, live, verify }) {
|
|
286
|
+
root = resolve(root)
|
|
287
|
+
return withFns({ root, site }, async (fns, server) => {
|
|
288
|
+
const { diagnostics, ...contract } = describe(fns, rust)
|
|
289
|
+
const mod = await server.ssrLoadModule('virtual:weft/realtime')
|
|
290
|
+
const bus = busProblem({ preset, bus: mod.default?.bus?.name ?? 'memory', streams: [...fns.values()].some(f => f.kind === 'stream' || f.kind === 'channel' || f.kind === 'shared'), root })
|
|
291
|
+
if (bus) diagnostics.push({ code: 'R002', severity: 'error', why: `${bus}. Add \`@weft-js/realtime-redis\` or \`@weft-js/realtime-nats\` to \`plugins\`, or set \`postgresBus\` in \`src/realtime.server.ts\`.` })
|
|
292
|
+
diagnostics.push(...planOf({ root, preset, realtime: mod.default }).diagnostics)
|
|
293
|
+
let verified = []
|
|
294
|
+
if (verify) {
|
|
295
|
+
const done = await verifyAll({ root, fns, realtime: mod.default, server, live, strict: true })
|
|
296
|
+
diagnostics.push(...done.diagnostics)
|
|
297
|
+
verified = done.verified
|
|
298
|
+
}
|
|
299
|
+
for (const [service, s] of Object.entries(contract.services ?? {})) {
|
|
300
|
+
const why = s.kind === 'native' && nativeProblem({ service, preset, wasm: rust?.services?.[service]?.wasm })
|
|
301
|
+
if (why) diagnostics.push({ code: 'R104', severity: 'error', why })
|
|
302
|
+
}
|
|
303
|
+
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
304
|
+
const path = join(root, '.weft', 'contract.json')
|
|
305
|
+
writeFileSync(path, JSON.stringify(contract, null, 2) + '\n')
|
|
306
|
+
return { path, functions: contract.functions.length, diagnostics, contract, verified }
|
|
307
|
+
})
|
|
308
|
+
}
|