@weft-js/start 0.1.1 → 0.1.3
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/README.md +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +38 -8
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +90 -166
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +112 -37
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -178
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -971
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- package/src/capture.d.mts +0 -1
package/src/client.tsx
CHANGED
|
@@ -12,9 +12,16 @@ export type { ClientRoutes } from './run'
|
|
|
12
12
|
* Hydrate the page in `#app` and take over its links. Resolves once hydrated, which on a page of lazy islands is when
|
|
13
13
|
* one is first needed. `eager`: don't wait for that.
|
|
14
14
|
*/
|
|
15
|
-
export function start(
|
|
15
|
+
export function start(
|
|
16
|
+
routes: ClientRoutes,
|
|
17
|
+
options: { container?: Element; eager?: boolean } = {},
|
|
18
|
+
): ReturnType<typeof run> {
|
|
16
19
|
return new Promise((settle, fail) => {
|
|
17
|
-
const full = () =>
|
|
20
|
+
const full = () =>
|
|
21
|
+
import('./run')
|
|
22
|
+
.then((m) => m.start(routes, options))
|
|
23
|
+
.then(settle)
|
|
24
|
+
.catch((e) => (fail(e), Promise.reject(e)))
|
|
18
25
|
options.eager ? void full() : boot(full)
|
|
19
26
|
})
|
|
20
27
|
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { toJson } from '../shape.mjs'
|
|
3
|
+
|
|
4
|
+
const tagsOf = (t) => (typeof t === 'function' ? 'dynamic' : [...(t ?? [])])
|
|
5
|
+
|
|
6
|
+
/** The first string or array in a JSON Schema with no upper bound, as a path. */
|
|
7
|
+
function unbounded(s, path = 'input') {
|
|
8
|
+
if (!s || typeof s !== 'object') return
|
|
9
|
+
if (s.type === 'string' && s.maxLength === undefined && !s.enum && s.const === undefined)
|
|
10
|
+
return path
|
|
11
|
+
if (s.type === 'array' && s.maxItems === undefined) return path
|
|
12
|
+
if (s.items) {
|
|
13
|
+
const hit = unbounded(s.items, `${path}[]`)
|
|
14
|
+
if (hit) return hit
|
|
15
|
+
}
|
|
16
|
+
for (const [k, v] of Object.entries(s.properties ?? {})) {
|
|
17
|
+
const hit = unbounded(v, `${path}.${k}`)
|
|
18
|
+
if (hit) return hit
|
|
19
|
+
}
|
|
20
|
+
for (const v of [...(s.anyOf ?? []), ...(s.oneOf ?? [])]) {
|
|
21
|
+
const hit = unbounded(v, path)
|
|
22
|
+
if (hit) return hit
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A one-way `.transform()` or `preprocess` in a Zod schema: parsed input it touched can't be sent on (`encode` throws). */
|
|
27
|
+
function oneWay(schema, seen = new Set()) {
|
|
28
|
+
if (!schema || typeof schema !== 'object' || seen.has(schema)) return false
|
|
29
|
+
seen.add(schema)
|
|
30
|
+
const def = schema._zod?.def
|
|
31
|
+
if (def?.type === 'transform') return true
|
|
32
|
+
return Object.values(def ?? schema).some((v) => oneWay(v, seen)) // arrays and `shape` hold schemas too
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The first union in a JSON Schema with two object or array arms, as a path: typify has no faithful type for one. */
|
|
36
|
+
function plainUnion(s, path) {
|
|
37
|
+
if (!s || typeof s !== 'object') return
|
|
38
|
+
const arms = s.anyOf?.filter((x) => x.type !== 'null')
|
|
39
|
+
if (arms?.length > 1 && arms.some((x) => x.type === 'object' || x.type === 'array' || x.$ref))
|
|
40
|
+
return path
|
|
41
|
+
for (const [k, v] of Object.entries(s.properties ?? {})) {
|
|
42
|
+
const hit = plainUnion(v, `${path}.${k}`)
|
|
43
|
+
if (hit) return hit
|
|
44
|
+
}
|
|
45
|
+
for (const v of [
|
|
46
|
+
s.items,
|
|
47
|
+
...(s.prefixItems ?? []),
|
|
48
|
+
...(s.anyOf ?? []),
|
|
49
|
+
...(s.oneOf ?? []),
|
|
50
|
+
...Object.values(s.$defs ?? {}),
|
|
51
|
+
]) {
|
|
52
|
+
const hit = plainUnion(v, path)
|
|
53
|
+
if (hit) return hit
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CODEC =
|
|
58
|
+
'`z.codec(z.iso.datetime(), z.date(), { decode, encode })` for a date, `z.codec(z.string(), z.bigint(), …)` for a bigint'
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A function with `impl`, as a sidecar sees it (research 36 §3). The wire is the *input* side of every schema: TS sends
|
|
62
|
+
* parsed input encoded back, and parses what comes back, so codecs and transforms on an output are TS's business.
|
|
63
|
+
*/
|
|
64
|
+
function implOf(f, report) {
|
|
65
|
+
const { impl, input, output, errors = {} } = f.def
|
|
66
|
+
const r101 = (why) => report({ code: 'R101', severity: 'error', why: `\`${f.$id}\`: ${why}` })
|
|
67
|
+
const wire = (schema, what) => {
|
|
68
|
+
const json = toJson(schema, 'input')
|
|
69
|
+
if (!json)
|
|
70
|
+
r101(`${what} has a type JSON can't carry, so no Rust type can be generated. Use ${CODEC}.`)
|
|
71
|
+
return json ?? {}
|
|
72
|
+
}
|
|
73
|
+
if (input) wire(input, '`input`')
|
|
74
|
+
if (input && oneWay(input))
|
|
75
|
+
r101(
|
|
76
|
+
"`input` has a one-way `.transform()`, so its parsed value can't be sent. Use `z.codec(…)`, or do it in Rust.",
|
|
77
|
+
)
|
|
78
|
+
if (!output)
|
|
79
|
+
r101(
|
|
80
|
+
'a function with `impl` needs `output`: it\'s the contract for what comes back, and `z.null()` says "nothing".',
|
|
81
|
+
)
|
|
82
|
+
return {
|
|
83
|
+
kind: impl.kind,
|
|
84
|
+
target: impl.target,
|
|
85
|
+
service: impl.service,
|
|
86
|
+
ctx:
|
|
87
|
+
f.def.cache && f.def.cache.scope !== 'user'
|
|
88
|
+
? 'shared'
|
|
89
|
+
: f.def.auth === 'user'
|
|
90
|
+
? 'user'
|
|
91
|
+
: 'ctx',
|
|
92
|
+
output: output ? wire(output, '`output`') : {},
|
|
93
|
+
errors: Object.fromEntries(
|
|
94
|
+
Object.entries(errors).map(([code, schema]) => [code, wire(schema, `error \`${code}\``)]),
|
|
95
|
+
),
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What a sidecar is built from, per service: a binary whose hash differs was built from another contract (R103). */
|
|
100
|
+
function services(functions) {
|
|
101
|
+
const out = {}
|
|
102
|
+
for (const f of functions)
|
|
103
|
+
if (f.impl)
|
|
104
|
+
(out[f.impl.service] ??= {
|
|
105
|
+
kind: f.impl.kind,
|
|
106
|
+
hash: createHash('sha256'),
|
|
107
|
+
functions: [],
|
|
108
|
+
}).functions.push(f.id)
|
|
109
|
+
for (const f of functions)
|
|
110
|
+
if (f.impl) out[f.impl.service].hash.update(JSON.stringify([f.id, f.kind, f.input, f.impl]))
|
|
111
|
+
return Object.fromEntries(
|
|
112
|
+
Object.entries(out).map(([k, v]) => [
|
|
113
|
+
k,
|
|
114
|
+
{ kind: v.kind, hash: v.hash.digest('hex').slice(0, 16), functions: v.functions },
|
|
115
|
+
]),
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Presets with no file for an addon or a `.wasm` to be read from. */
|
|
120
|
+
function channelDescription(f, bound) {
|
|
121
|
+
const part = (key) => (f.def[key] ? (toJson(f.def[key], 'input') ?? {}) : undefined)
|
|
122
|
+
const messages = (list) =>
|
|
123
|
+
Object.fromEntries(Object.entries(list ?? {}).map(([k, s]) => [k, toJson(s, 'input') ?? {}]))
|
|
124
|
+
const client = f.kind === 'shared' ? { value: part('value') } : messages(f.def.client)
|
|
125
|
+
bound(part(f.kind === 'shared' ? 'key' : 'params'), f.kind === 'shared' ? 'key' : 'params')
|
|
126
|
+
for (const [k, json] of Object.entries(client)) bound(json, `client.${k}`)
|
|
127
|
+
return {
|
|
128
|
+
id: f.$id,
|
|
129
|
+
name: f.$id.replace(/\..*$/, ''),
|
|
130
|
+
kind: f.kind,
|
|
131
|
+
input: part(f.kind === 'shared' ? 'key' : 'params') ?? null,
|
|
132
|
+
output: null,
|
|
133
|
+
client,
|
|
134
|
+
server: messages(f.def.server),
|
|
135
|
+
auth: 'authorize',
|
|
136
|
+
errors: ['R011', 'Q002'],
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function describeFunction(f, rust, diagnostics) {
|
|
141
|
+
const schema = (key, side) => {
|
|
142
|
+
const s = f.def[key]
|
|
143
|
+
if (!s) return undefined
|
|
144
|
+
const json = toJson(s, side)
|
|
145
|
+
if (!json)
|
|
146
|
+
diagnostics.push({
|
|
147
|
+
code: 'C001',
|
|
148
|
+
severity: 'warning',
|
|
149
|
+
why: `\`${f.$id}\`'s \`${key}\` schema can't become JSON Schema: the contract says \`{}\`.`,
|
|
150
|
+
})
|
|
151
|
+
return json ?? {}
|
|
152
|
+
}
|
|
153
|
+
const errors = [
|
|
154
|
+
...(f.def.input ? ['Q002'] : []),
|
|
155
|
+
...(f.def.auth ? ['A002'] : []),
|
|
156
|
+
...(f.kind === 'mutation' ? ['Q010'] : []),
|
|
157
|
+
...Object.keys(f.def.errors ?? {}),
|
|
158
|
+
]
|
|
159
|
+
const impl = f.def.impl && implOf(f, (d) => diagnostics.push(d))
|
|
160
|
+
const input = impl
|
|
161
|
+
? f.def.input
|
|
162
|
+
? (toJson(f.def.input, 'input') ?? {})
|
|
163
|
+
: null
|
|
164
|
+
: (schema('input', 'input') ?? null)
|
|
165
|
+
if (impl && rust.unions !== 'allow') {
|
|
166
|
+
const hit = [
|
|
167
|
+
['input', input],
|
|
168
|
+
['output', impl.output],
|
|
169
|
+
...Object.entries(impl.errors).map(([k, v]) => [`error ${k}`, v]),
|
|
170
|
+
]
|
|
171
|
+
.map(([name, s]) => plainUnion(s, name))
|
|
172
|
+
.find(Boolean)
|
|
173
|
+
if (hit)
|
|
174
|
+
diagnostics.push({
|
|
175
|
+
code: 'R102',
|
|
176
|
+
severity: 'error',
|
|
177
|
+
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.`,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
const bound = (json, path) => {
|
|
181
|
+
const hit = unbounded(json, path)
|
|
182
|
+
if (hit)
|
|
183
|
+
diagnostics.push({
|
|
184
|
+
code: 'R005',
|
|
185
|
+
severity: 'warning',
|
|
186
|
+
why: `\`${f.$id}\`'s \`${hit}\` has no upper bound: a client decides how much the server holds. Add \`.max()\`.`,
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
if (f.kind === 'stream') bound(input)
|
|
190
|
+
if (f.kind === 'channel' || f.kind === 'shared') return channelDescription(f, bound)
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
id: f.$id,
|
|
194
|
+
name: f.$id.replace(/\..*$/, ''),
|
|
195
|
+
kind: f.kind,
|
|
196
|
+
input,
|
|
197
|
+
output: impl
|
|
198
|
+
? impl.output
|
|
199
|
+
: (schema(f.kind === 'stream' ? 'message' : 'output', 'output') ?? null),
|
|
200
|
+
...(impl && { impl }),
|
|
201
|
+
...(f.kind === 'query'
|
|
202
|
+
? { tags: tagsOf(f.def.tags), cache: f.def.cache ?? null }
|
|
203
|
+
: f.kind === 'mutation'
|
|
204
|
+
? { invalidates: tagsOf(f.def.invalidates) }
|
|
205
|
+
: {}),
|
|
206
|
+
auth: f.def.auth ?? 'public',
|
|
207
|
+
errors,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** @param {Map<string, any>} fns @param {{ unions?: 'allow' }} [rust] the config's `rust` */
|
|
212
|
+
export function describe(fns, rust = {}) {
|
|
213
|
+
const diagnostics = []
|
|
214
|
+
const functions = [...fns.values()]
|
|
215
|
+
.sort((a, b) => a.$id.localeCompare(b.$id))
|
|
216
|
+
.map((f) => describeFunction(f, rust, diagnostics))
|
|
217
|
+
const rusts = services(functions)
|
|
218
|
+
for (const f of functions)
|
|
219
|
+
if (f.impl && f.impl.kind !== rusts[f.impl.service].kind)
|
|
220
|
+
diagnostics.push({
|
|
221
|
+
code: 'R109',
|
|
222
|
+
severity: 'error',
|
|
223
|
+
why: `\`${f.impl.service}\` is named by both \`remote()\` and \`native()\`: a crate is one or the other. Rename one.`,
|
|
224
|
+
})
|
|
225
|
+
return {
|
|
226
|
+
version: 1,
|
|
227
|
+
...(Object.keys(rusts).length && { services: rusts }),
|
|
228
|
+
functions,
|
|
229
|
+
diagnostics,
|
|
230
|
+
}
|
|
231
|
+
}
|
package/src/contract.mjs
CHANGED
|
@@ -7,152 +7,46 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from
|
|
|
7
7
|
import { join, resolve } from 'node:path'
|
|
8
8
|
import { createServer } from 'vite'
|
|
9
9
|
import weft from '@weft-js/core/vite'
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
holdsSockets,
|
|
12
|
+
literalSecrets,
|
|
13
|
+
liveTarget,
|
|
14
|
+
livePlan,
|
|
15
|
+
refusal,
|
|
16
|
+
resolveTarget,
|
|
17
|
+
scanRequired,
|
|
18
|
+
} from './live-target.mjs'
|
|
11
19
|
import { readMigrations } from './migrations.mjs'
|
|
12
|
-
import { shapeTree
|
|
20
|
+
import { shapeTree } from './shape.mjs'
|
|
21
|
+
import { describe } from './contract/describe.mjs'
|
|
13
22
|
import weftStart, { startDir } from './vite.mjs'
|
|
14
23
|
|
|
15
|
-
|
|
24
|
+
export { describe }
|
|
16
25
|
|
|
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
26
|
const NO_FILES = /^(cloudflare|vercel[-_]edge|netlify[-_]edge|deno[-_]deploy|bunny|fastly)/
|
|
95
27
|
/** Presets that run where they were built, so the addon from this machine is the right one. */
|
|
96
28
|
const LOCAL = /^(node|bun|deno[-_]server)/
|
|
97
29
|
|
|
98
30
|
/** R104 for a build: `native()` on a preset that has no way to run it. */
|
|
99
31
|
export function nativeProblem({ service, preset, wasm }) {
|
|
100
|
-
if (preset && NO_FILES.test(preset))
|
|
101
|
-
|
|
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 }
|
|
32
|
+
if (preset && NO_FILES.test(preset))
|
|
33
|
+
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\``
|
|
34
|
+
if (wasm === false && preset && !LOCAL.test(preset))
|
|
35
|
+
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()\``
|
|
146
36
|
}
|
|
147
37
|
|
|
148
38
|
/** Presets that run many instances: a process-local bus reaches a fraction of the clients (R002). */
|
|
149
|
-
const MULTI =
|
|
39
|
+
const MULTI =
|
|
40
|
+
/^(vercel|netlify|aws[-_]|azure|cloudflare|firebase|deno[-_]deploy|digitalocean|render|koyeb|zeabur)/
|
|
150
41
|
|
|
151
|
-
const usesLive = root => {
|
|
152
|
-
const walk =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
42
|
+
const usesLive = (root) => {
|
|
43
|
+
const walk = (dir) =>
|
|
44
|
+
readdirSync(dir, { withFileTypes: true }).some((d) => {
|
|
45
|
+
const f = join(dir, d.name)
|
|
46
|
+
return d.isDirectory()
|
|
47
|
+
? walk(f)
|
|
48
|
+
: /\.[cm]?[jt]sx?$/.test(d.name) && /[\w)\]]\.live\(/.test(readFileSync(f, 'utf8'))
|
|
49
|
+
})
|
|
156
50
|
return existsSync(join(root, 'src')) && walk(join(root, 'src'))
|
|
157
51
|
}
|
|
158
52
|
|
|
@@ -164,23 +58,45 @@ export const busProblem = ({ preset, bus, streams, root }) =>
|
|
|
164
58
|
bus === 'durable-object' && preset !== 'cloudflare_durable'
|
|
165
59
|
? `\`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
60
|
: bus === 'memory' && MULTI.test(preset ?? '') && (streams || usesLive(root))
|
|
167
|
-
|
|
168
|
-
|
|
61
|
+
? `the \`${preset}\` preset runs many instances, and the memory bus only reaches clients on its own: pokes and messages would be lost`
|
|
62
|
+
: undefined
|
|
169
63
|
|
|
170
64
|
/** What this deploy gets from live data, with the config loaded. R018 is a table `changes` lists and no migration covers. */
|
|
171
65
|
function planOf({ root, preset, realtime }) {
|
|
172
66
|
const source = realtime?.changes
|
|
173
|
-
const plan = livePlan({
|
|
67
|
+
const plan = livePlan({
|
|
68
|
+
preset,
|
|
69
|
+
source: source && { name: source.name, external: source.external, tables: source.tables },
|
|
70
|
+
required: scanRequired(root),
|
|
71
|
+
})
|
|
174
72
|
if (source?.dialect) {
|
|
175
|
-
const sql = readMigrations(root)
|
|
176
|
-
|
|
177
|
-
|
|
73
|
+
const sql = readMigrations(root)
|
|
74
|
+
.map((m) => m.sql)
|
|
75
|
+
.join('\n')
|
|
76
|
+
const missing = source.tables.filter(
|
|
77
|
+
(t) =>
|
|
78
|
+
!new RegExp(
|
|
79
|
+
`weft_changes_(${t.replace(/\W/g, '_')}_)?upd\\b[^;]*["\`\\s]${t}["\`\\s]`,
|
|
80
|
+
).test(sql),
|
|
81
|
+
)
|
|
82
|
+
if (missing.length)
|
|
83
|
+
plan.diagnostics.push({
|
|
84
|
+
code: 'R018',
|
|
85
|
+
severity: 'warning',
|
|
86
|
+
why: `no migration adds change triggers to ${missing.map((t) => `\`${t}\``).join(', ')}: run \`weft db live\`. Until then the server falls back to polling.`,
|
|
87
|
+
})
|
|
178
88
|
}
|
|
179
89
|
return plan
|
|
180
90
|
}
|
|
181
91
|
|
|
182
|
-
const verifiedFile = root => join(root, '.weft', 'verify.json')
|
|
183
|
-
const readVerified =
|
|
92
|
+
const verifiedFile = (root) => join(root, '.weft', 'verify.json')
|
|
93
|
+
const readVerified = (root) => {
|
|
94
|
+
try {
|
|
95
|
+
return JSON.parse(readFileSync(verifiedFile(root), 'utf8'))
|
|
96
|
+
} catch {
|
|
97
|
+
return {}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
184
100
|
|
|
185
101
|
/**
|
|
186
102
|
* Every query's `live.verify`, run. `strict` is a build: a target that isn't reachable from here fails it, where a
|
|
@@ -188,9 +104,16 @@ const readVerified = root => { try { return JSON.parse(readFileSync(verifiedFile
|
|
|
188
104
|
* @returns {Promise<{ diagnostics: any[], verified: any[] }>}
|
|
189
105
|
*/
|
|
190
106
|
async function verifyAll({ root, fns, realtime, server, live, strict }) {
|
|
191
|
-
const diagnostics = [],
|
|
192
|
-
|
|
193
|
-
const
|
|
107
|
+
const diagnostics = [],
|
|
108
|
+
verified = []
|
|
109
|
+
for (const hit of literalSecrets(root))
|
|
110
|
+
diagnostics.push({
|
|
111
|
+
code: 'R020',
|
|
112
|
+
severity: 'error',
|
|
113
|
+
file: hit.file,
|
|
114
|
+
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.`,
|
|
115
|
+
})
|
|
116
|
+
const queries = [...fns.values()].filter((f) => f.kind === 'query' && f.def.live?.verify)
|
|
194
117
|
if (!queries.length) return { diagnostics, verified }
|
|
195
118
|
const { roundTrip } = await server.ssrLoadModule(join(startDir, 'verify.ts'))
|
|
196
119
|
const cache = readVerified(root)
|
|
@@ -202,7 +125,11 @@ async function verifyAll({ root, fns, realtime, server, live, strict }) {
|
|
|
202
125
|
const options = typeof v === 'object' ? v : {}
|
|
203
126
|
const target = resolveTarget(v, { targets: live?.targets, env })
|
|
204
127
|
if (target.missing) {
|
|
205
|
-
diagnostics.push({
|
|
128
|
+
diagnostics.push({
|
|
129
|
+
code: 'R021',
|
|
130
|
+
severity: strict ? 'error' : 'warning',
|
|
131
|
+
why: `\`${name}\` can't be verified on this machine: ${target.missing}${strict ? ' A build needs it, because the query declares it.' : ' Skipped.'}`,
|
|
132
|
+
})
|
|
206
133
|
continue
|
|
207
134
|
}
|
|
208
135
|
const stop = refusal(target, { targets: live?.targets })
|
|
@@ -210,19 +137,46 @@ async function verifyAll({ root, fns, realtime, server, live, strict }) {
|
|
|
210
137
|
diagnostics.push({ code: 'R022', severity: 'error', why: `\`${name}\`: ${stop}.` })
|
|
211
138
|
continue
|
|
212
139
|
}
|
|
213
|
-
const key = createHash('sha256')
|
|
140
|
+
const key = createHash('sha256')
|
|
141
|
+
.update(
|
|
142
|
+
JSON.stringify([
|
|
143
|
+
contract.find((c) => c.id === f.$id),
|
|
144
|
+
realtime?.changes?.tables,
|
|
145
|
+
target.name,
|
|
146
|
+
target.url ?? '',
|
|
147
|
+
!!target.db,
|
|
148
|
+
]),
|
|
149
|
+
)
|
|
150
|
+
.digest('hex')
|
|
151
|
+
.slice(0, 16)
|
|
214
152
|
if (cache[f.$id]?.key === key && !env.WEFT_VERIFY_FRESH) {
|
|
215
153
|
verified.push({ name, cached: true, ...cache[f.$id].result })
|
|
216
154
|
continue
|
|
217
155
|
}
|
|
218
|
-
const result = await roundTrip({
|
|
156
|
+
const result = await roundTrip({
|
|
157
|
+
fn: f,
|
|
158
|
+
source: realtime?.changes,
|
|
159
|
+
url: target.url,
|
|
160
|
+
db: target.db,
|
|
161
|
+
options,
|
|
162
|
+
env,
|
|
163
|
+
})
|
|
219
164
|
verified.push({ name, ...result })
|
|
220
165
|
if (result.ok) cache[f.$id] = { key, result, at: new Date().toISOString() }
|
|
221
166
|
else {
|
|
222
167
|
delete cache[f.$id]
|
|
223
|
-
diagnostics.push({
|
|
168
|
+
diagnostics.push({
|
|
169
|
+
code: 'R019',
|
|
170
|
+
severity: 'error',
|
|
171
|
+
why: `\`${name}\` failed its live round trip at \`${result.broke.link}\`: ${result.broke.why}.`,
|
|
172
|
+
})
|
|
224
173
|
}
|
|
225
|
-
if (result.ok && result.untested.length)
|
|
174
|
+
if (result.ok && result.untested.length)
|
|
175
|
+
diagnostics.push({
|
|
176
|
+
code: 'R023',
|
|
177
|
+
severity: 'warning',
|
|
178
|
+
why: `\`${name}\` passed, with ${result.untested.map((u) => `\`${u.link}\``).join(', ')} untested: ${[...new Set(result.untested.map((u) => u.why))].join(' ')}`,
|
|
179
|
+
})
|
|
226
180
|
}
|
|
227
181
|
mkdirSync(join(root, '.weft'), { recursive: true })
|
|
228
182
|
writeFileSync(verifiedFile(root), JSON.stringify(cache, null, 2) + '\n')
|
|
@@ -236,13 +190,23 @@ export const readLivePlan = ({ root, site, preset, live, verify = true }) => {
|
|
|
236
190
|
const realtime = (await server.ssrLoadModule('virtual:weft/realtime')).default
|
|
237
191
|
const plan = planOf({ root, preset, realtime })
|
|
238
192
|
if (!verify) return { ...plan, verified: [] }
|
|
239
|
-
const { diagnostics, verified } = await verifyAll({
|
|
193
|
+
const { diagnostics, verified } = await verifyAll({
|
|
194
|
+
root,
|
|
195
|
+
fns,
|
|
196
|
+
realtime,
|
|
197
|
+
server,
|
|
198
|
+
live,
|
|
199
|
+
strict: false,
|
|
200
|
+
})
|
|
240
201
|
return { ...plan, diagnostics: [...plan.diagnostics, ...diagnostics], verified }
|
|
241
202
|
})
|
|
242
203
|
}
|
|
243
204
|
|
|
244
205
|
/** `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) =>
|
|
206
|
+
export const withRealtime = ({ root, site }, fn) =>
|
|
207
|
+
withFns({ root: resolve(root), site }, async (_, server) =>
|
|
208
|
+
fn((await server.ssrLoadModule('virtual:weft/realtime')).default ?? {}, server),
|
|
209
|
+
)
|
|
246
210
|
|
|
247
211
|
/** Every query's shape tree (research 31 §3), by id. */
|
|
248
212
|
export function shapeTable(fns) {
|
|
@@ -264,7 +228,10 @@ async function withFns({ root, site = {} }, fn) {
|
|
|
264
228
|
let server
|
|
265
229
|
try {
|
|
266
230
|
server = await createServer({
|
|
267
|
-
root,
|
|
231
|
+
root,
|
|
232
|
+
configFile: false,
|
|
233
|
+
logLevel: 'silent',
|
|
234
|
+
appType: 'custom',
|
|
268
235
|
server: { middlewareMode: true, hmr: false, ws: false },
|
|
269
236
|
plugins: [weft({ hydratable: true }), weftStart({ root, site })],
|
|
270
237
|
})
|
|
@@ -279,7 +246,8 @@ async function withFns({ root, site = {} }, fn) {
|
|
|
279
246
|
}
|
|
280
247
|
|
|
281
248
|
/** The shape table at build: nothing is running yet, so the server modules load the way the contract's do. */
|
|
282
|
-
export const readShapes =
|
|
249
|
+
export const readShapes = (options) =>
|
|
250
|
+
withFns({ ...options, root: resolve(options.root) }, shapeTable)
|
|
283
251
|
|
|
284
252
|
/** Load every server module and write `.weft/contract.json`. */
|
|
285
253
|
export function writeContract({ root, site = {}, preset, rust, live, verify }) {
|
|
@@ -287,12 +255,29 @@ export function writeContract({ root, site = {}, preset, rust, live, verify }) {
|
|
|
287
255
|
return withFns({ root, site }, async (fns, server) => {
|
|
288
256
|
const { diagnostics, ...contract } = describe(fns, rust)
|
|
289
257
|
const mod = await server.ssrLoadModule('virtual:weft/realtime')
|
|
290
|
-
const socket = [...fns.values()].find(f => f.kind === 'channel' || f.kind === 'shared')
|
|
258
|
+
const socket = [...fns.values()].find((f) => f.kind === 'channel' || f.kind === 'shared')
|
|
291
259
|
// a channel needs the socket itself: on a function host no bus helps, so R024 says where it can run instead of R002
|
|
292
260
|
const unheld = socket && !holdsSockets(preset)
|
|
293
|
-
if (unheld)
|
|
294
|
-
|
|
295
|
-
|
|
261
|
+
if (unheld)
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
code: 'R024',
|
|
264
|
+
severity: 'error',
|
|
265
|
+
why: `\`${socket.$id.replace(/\..*$/, '')}\` is a ${socket.kind === 'shared' ? 'shared state' : 'channel'}, which needs a WebSocket, and ${liveTarget(preset).host} on \`${preset}\` can't hold one: the page loads, and the channel says \`unavailable\`. \`weft deploy hosts\` lists the hosts that can (Fly, Render).`,
|
|
266
|
+
})
|
|
267
|
+
const bus = busProblem({
|
|
268
|
+
preset,
|
|
269
|
+
bus: mod.default?.bus?.name ?? 'memory',
|
|
270
|
+
streams: [...fns.values()].some(
|
|
271
|
+
(f) => f.kind === 'stream' || (!unheld && (f.kind === 'channel' || f.kind === 'shared')),
|
|
272
|
+
),
|
|
273
|
+
root,
|
|
274
|
+
})
|
|
275
|
+
if (bus)
|
|
276
|
+
diagnostics.push({
|
|
277
|
+
code: 'R002',
|
|
278
|
+
severity: 'error',
|
|
279
|
+
why: `${bus}. Add \`@weft-js/realtime-redis\` or \`@weft-js/realtime-nats\` to \`plugins\`, or set \`postgresBus\` in \`src/realtime.server.ts\`.`,
|
|
280
|
+
})
|
|
296
281
|
diagnostics.push(...planOf({ root, preset, realtime: mod.default }).diagnostics)
|
|
297
282
|
let verified = []
|
|
298
283
|
if (verify) {
|
|
@@ -301,7 +286,9 @@ export function writeContract({ root, site = {}, preset, rust, live, verify }) {
|
|
|
301
286
|
verified = done.verified
|
|
302
287
|
}
|
|
303
288
|
for (const [service, s] of Object.entries(contract.services ?? {})) {
|
|
304
|
-
const why =
|
|
289
|
+
const why =
|
|
290
|
+
s.kind === 'native' &&
|
|
291
|
+
nativeProblem({ service, preset, wasm: rust?.services?.[service]?.wasm })
|
|
305
292
|
if (why) diagnostics.push({ code: 'R104', severity: 'error', why })
|
|
306
293
|
}
|
|
307
294
|
mkdirSync(join(root, '.weft'), { recursive: true })
|