@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.
Files changed (214) hide show
  1. package/README.md +1278 -12
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/README.md +322 -0
  5. package/crate/rust-toolchain.toml +3 -0
  6. package/crate/src/build/emit.rs +103 -0
  7. package/crate/src/build.rs +367 -0
  8. package/crate/src/ctx.rs +186 -0
  9. package/crate/src/envelope.rs +163 -0
  10. package/crate/src/functions.rs +173 -0
  11. package/crate/src/lib.rs +118 -0
  12. package/crate/src/native.rs +212 -0
  13. package/crate/src/otel.rs +62 -0
  14. package/crate/src/server.rs +203 -0
  15. package/package.json +46 -37
  16. package/src/app.mjs +38 -8
  17. package/src/asset-receipt.d.mts +13 -0
  18. package/src/asset-receipt.mjs +61 -0
  19. package/src/auth/better-auth.ts +215 -17
  20. package/src/auth/drizzle.ts +1 -1
  21. package/src/auth.ts +21 -6
  22. package/src/boot.ts +19 -8
  23. package/src/bus/postgres.ts +32 -19
  24. package/src/bus.ts +7 -4
  25. package/src/{capture.mjs → capture.ts} +34 -1
  26. package/src/changes-sql.d.mts +8 -2
  27. package/src/changes-sql.mjs +26 -10
  28. package/src/changes.ts +165 -66
  29. package/src/channel/connections.ts +216 -0
  30. package/src/channel/socket.ts +396 -0
  31. package/src/channel-fake.ts +19 -4
  32. package/src/channel.ts +120 -492
  33. package/src/client.tsx +9 -2
  34. package/src/contract/describe.mjs +231 -0
  35. package/src/contract.mjs +144 -157
  36. package/src/data/cache.ts +354 -0
  37. package/src/data/definitions.ts +94 -0
  38. package/src/data/draft.ts +104 -0
  39. package/src/data/foundation.ts +95 -0
  40. package/src/data/live-api.ts +22 -0
  41. package/src/data/mutation.ts +138 -0
  42. package/src/data/primitives.ts +135 -0
  43. package/src/data/query.ts +106 -0
  44. package/src/data/transport.ts +121 -0
  45. package/src/data/types.ts +303 -0
  46. package/src/data.ts +69 -894
  47. package/src/db/bun-sqlite.d.ts +4 -1
  48. package/src/db/changes-mysql.ts +61 -21
  49. package/src/db/changes-postgres.ts +197 -118
  50. package/src/db/changes-sqlite.ts +131 -64
  51. package/src/db/core.ts +38 -13
  52. package/src/db/index.ts +20 -4
  53. package/src/db/mysql.ts +22 -6
  54. package/src/db/postgres.ts +49 -13
  55. package/src/db/sqlite.ts +53 -27
  56. package/src/derive.d.mts +4 -1
  57. package/src/derive.mjs +136 -39
  58. package/src/dev-runtime.mjs +8 -4
  59. package/src/exec/errors.ts +57 -0
  60. package/src/exec/landing.ts +66 -0
  61. package/src/exec/scope.ts +174 -0
  62. package/src/exec/touch.ts +80 -0
  63. package/src/exec/validation.ts +176 -0
  64. package/src/exec.ts +191 -341
  65. package/src/extension.ts +8 -2
  66. package/src/first-paint.ts +26 -9
  67. package/src/form/value.ts +15 -0
  68. package/src/form.tsx +132 -43
  69. package/src/hub/channels.ts +102 -0
  70. package/src/hub/connections.ts +147 -0
  71. package/src/hub/delivery.ts +62 -0
  72. package/src/hub/feed.ts +168 -0
  73. package/src/hub/presence.ts +64 -0
  74. package/src/hub/route.ts +158 -0
  75. package/src/hub/subscriptions.ts +212 -0
  76. package/src/hub/topic.ts +98 -0
  77. package/src/hub/tracked.ts +12 -0
  78. package/src/hub/types.ts +190 -0
  79. package/src/hub/view.ts +147 -0
  80. package/src/hub.ts +132 -758
  81. package/src/index.ts +84 -10
  82. package/src/inline.mjs +356 -0
  83. package/src/inspect/compare.mjs +162 -0
  84. package/src/inspect/routes.mjs +154 -0
  85. package/src/inspect/samples.mjs +59 -0
  86. package/src/inspect/url.mjs +2 -0
  87. package/src/inspect.mjs +146 -187
  88. package/src/link/election.ts +178 -0
  89. package/src/link/lines.ts +27 -0
  90. package/src/link.ts +90 -166
  91. package/src/list-drizzle.ts +104 -18
  92. package/src/list.ts +127 -36
  93. package/src/listen.ts +32 -6
  94. package/src/live/api.ts +18 -0
  95. package/src/live/backoff.ts +6 -0
  96. package/src/live/frames.ts +42 -0
  97. package/src/live/read.ts +77 -0
  98. package/src/live/sub-types.ts +24 -0
  99. package/src/live/transport.ts +86 -0
  100. package/src/live/types.ts +52 -0
  101. package/src/live-fake.ts +20 -5
  102. package/src/live-target.mjs +112 -37
  103. package/src/live.ts +116 -225
  104. package/src/media.ts +8 -1
  105. package/src/migrations.mjs +14 -6
  106. package/src/mux.ts +161 -64
  107. package/src/og/DefaultCard.tsx +40 -9
  108. package/src/og/fonts.mjs +16 -8
  109. package/src/og/images.mjs +43 -22
  110. package/src/og/index.mjs +119 -43
  111. package/src/og/native.mjs +11 -4
  112. package/src/range.mjs +15 -3
  113. package/src/router.ts +56 -6
  114. package/src/routes.mjs +73 -22
  115. package/src/run/admission.ts +80 -0
  116. package/src/run/boot.tsx +115 -0
  117. package/src/run/cache-presentation.ts +67 -0
  118. package/src/run/cache.ts +254 -0
  119. package/src/run/helpers.ts +197 -0
  120. package/src/run/listeners.ts +155 -0
  121. package/src/run/navigation.ts +382 -0
  122. package/src/run/page-skeleton.tsx +35 -0
  123. package/src/run/prefetch.ts +128 -0
  124. package/src/run/retain-view.ts +18 -0
  125. package/src/run/shape-preparation.ts +97 -0
  126. package/src/run/shape-styles.ts +72 -0
  127. package/src/run/source-context/pin.ts +124 -0
  128. package/src/run/source-context.ts +311 -0
  129. package/src/run.tsx +50 -178
  130. package/src/rust/common.ts +163 -0
  131. package/src/rust/native.ts +282 -0
  132. package/src/rust.ts +103 -229
  133. package/src/server/card.tsx +65 -0
  134. package/src/server/compression.ts +115 -0
  135. package/src/server/core.ts +70 -0
  136. package/src/server/dispatch.ts +70 -0
  137. package/src/server/form.ts +85 -0
  138. package/src/server/functions.ts +129 -0
  139. package/src/server/handler.ts +39 -0
  140. package/src/server/head.ts +96 -0
  141. package/src/server/media.ts +60 -0
  142. package/src/server/navigation-cache.ts +80 -0
  143. package/src/server/navigation.tsx +85 -0
  144. package/src/server/page-cold.ts +95 -0
  145. package/src/server/page-stream.tsx +181 -0
  146. package/src/server/page-types.ts +51 -0
  147. package/src/server/page-warm.ts +130 -0
  148. package/src/server/page.ts +69 -0
  149. package/src/server/payload.ts +89 -0
  150. package/src/server/prerender.ts +60 -0
  151. package/src/server/preview.tsx +67 -0
  152. package/src/server/render.ts +167 -0
  153. package/src/server/request.ts +98 -0
  154. package/src/server/resolver.ts +228 -0
  155. package/src/server/routes.ts +150 -0
  156. package/src/server/services.ts +69 -0
  157. package/src/server/text.ts +29 -0
  158. package/src/server/types.ts +160 -0
  159. package/src/server.tsx +82 -971
  160. package/src/shape.mjs +30 -6
  161. package/src/share/display.ts +50 -0
  162. package/src/share/network.ts +119 -0
  163. package/src/share/receive.ts +70 -0
  164. package/src/share/state.ts +30 -0
  165. package/src/share/tab-queue.ts +28 -0
  166. package/src/share-server.ts +128 -43
  167. package/src/share.ts +103 -209
  168. package/src/sheets.mjs +30 -18
  169. package/src/sidecar/build.mjs +170 -0
  170. package/src/sidecar/cargo.mjs +164 -0
  171. package/src/sidecar/common.mjs +17 -0
  172. package/src/sidecar/dev-build.mjs +91 -0
  173. package/src/sidecar/dev-watch.mjs +88 -0
  174. package/src/sidecar/manifest.mjs +19 -0
  175. package/src/sidecar/process.mjs +58 -0
  176. package/src/sidecar/refresh.mjs +19 -0
  177. package/src/sidecar.mjs +75 -416
  178. package/src/socket/check.ts +50 -0
  179. package/src/socket/deliver.ts +16 -0
  180. package/src/socket/publish.ts +56 -0
  181. package/src/socket/rooms.ts +41 -0
  182. package/src/socket/route.ts +79 -0
  183. package/src/socket/shared.ts +26 -0
  184. package/src/socket/types.ts +48 -0
  185. package/src/socket.ts +205 -194
  186. package/src/stream.ts +10 -1
  187. package/src/verify/app.ts +39 -0
  188. package/src/verify/cleanup.ts +27 -0
  189. package/src/verify/database.ts +11 -0
  190. package/src/verify/live.ts +63 -0
  191. package/src/verify/notify.ts +35 -0
  192. package/src/verify/skip.ts +19 -0
  193. package/src/verify/support.ts +149 -0
  194. package/src/verify/timing.ts +15 -0
  195. package/src/verify/trigger.ts +14 -0
  196. package/src/verify.ts +77 -176
  197. package/src/view.tsx +96 -11
  198. package/src/vite/asset-bytes.mjs +95 -0
  199. package/src/vite/assets.mjs +117 -0
  200. package/src/vite/config-hooks.mjs +166 -0
  201. package/src/vite/dev-hooks.mjs +103 -0
  202. package/src/vite/dev.mjs +102 -0
  203. package/src/vite/entries.mjs +145 -0
  204. package/src/vite/module-hooks.mjs +288 -0
  205. package/src/vite/route-modules.mjs +289 -0
  206. package/src/vite/server-functions.mjs +222 -0
  207. package/src/vite/shape-entry-css.mjs +187 -0
  208. package/src/vite/shape-override.mjs +226 -0
  209. package/src/vite/shared.mjs +51 -0
  210. package/src/vite/sheets.mjs +92 -0
  211. package/src/vite/state.mjs +89 -0
  212. package/src/vite/syntax.mjs +139 -0
  213. package/src/vite.mjs +152 -853
  214. package/src/capture.d.mts +0 -1
@@ -0,0 +1,170 @@
1
+ import {
2
+ copyFileSync,
3
+ cpSync,
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs'
11
+ import { randomBytes } from 'node:crypto'
12
+ import { basename, join, relative, resolve } from 'node:path'
13
+ import { cargo, checkWasm } from './cargo.mjs'
14
+ import { launch, stop } from './process.mjs'
15
+ import { buildable, crateDir, isNative, stem, urlVariable } from './common.mjs'
16
+
17
+ // ---- build --------------------------------------------------------------------------------------------------------
18
+
19
+ const BUILT = ['.weft', 'sidecars'] // a build empties `.output`, and these have to run during it
20
+ const SHIPPED = ['.output', 'rust']
21
+ const SHIPPED_NATIVE = ['.output', 'server', 'rust'] // with the server, so a host that moves it moves these
22
+
23
+ /** Release binaries into `.weft/sidecars/`, and what `weft start` needs to run them. */
24
+ export async function buildSidecars({ root, contract, rust, preset, log = () => {} }) {
25
+ root = resolve(root)
26
+ const out = join(root, ...BUILT)
27
+ const services = buildable(contract, rust)
28
+ rmSync(out, { recursive: true, force: true })
29
+ if (!services.length) return { services: [], diagnostics: [] }
30
+ mkdirSync(out, { recursive: true })
31
+ const manifest = {}
32
+ const diagnostics = []
33
+ for (const service of services) {
34
+ const dir = crateDir(root, rust, service)
35
+ if (!existsSync(dir)) {
36
+ diagnostics.push({
37
+ code: 'R109',
38
+ severity: 'error',
39
+ file: 'weft.config.ts',
40
+ span: [1, 1, 1, 1],
41
+ why: `\`rust://${service}\` has no crate at \`${relative(root, dir)}\`. Create it, point \`rust.services.${service}.crate\` at it, or set \`external: true\`.`,
42
+ })
43
+ continue
44
+ }
45
+ log(`building ${service}…`)
46
+ const built = await cargo('build', { root, dir, service, contract, release: true })
47
+ if (!built.ok || !(built.exe || built.lib)) {
48
+ diagnostics.push(...built.diagnostics)
49
+ continue
50
+ }
51
+ if (isNative(contract, service)) {
52
+ const addon = `${stem(service)}.${process.platform}-${process.arch}.node`
53
+ copyFileSync(built.lib, join(out, addon))
54
+ manifest[service] = {
55
+ kind: 'native',
56
+ addon,
57
+ hash: contract.services[service].hash,
58
+ bytes: statSync(built.lib).size,
59
+ }
60
+ const wasm = await checkWasm({ root, dir, service, contract, preset, rust, build: true })
61
+ diagnostics.push(...wasm.diagnostics)
62
+ if (wasm.lib) {
63
+ copyFileSync(wasm.lib, join(out, `${stem(service)}.wasm`))
64
+ Object.assign(manifest[service], {
65
+ wasm: `${stem(service)}.wasm`,
66
+ wasmBytes: statSync(wasm.lib).size,
67
+ })
68
+ }
69
+ continue
70
+ }
71
+ const bin = basename(built.exe)
72
+ copyFileSync(built.exe, join(out, bin))
73
+ manifest[service] = {
74
+ bin,
75
+ hash: contract.services[service].hash,
76
+ bytes: statSync(built.exe).size,
77
+ }
78
+ }
79
+ writeFileSync(join(out, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
80
+ return { services: Object.keys(manifest), diagnostics }
81
+ }
82
+
83
+ /** Into the output, once the server is built. */
84
+ export function shipSidecars(root) {
85
+ const from = join(resolve(root), ...BUILT)
86
+ if (!existsSync(from)) return
87
+ const manifest = JSON.parse(readFileSync(join(from, 'manifest.json'), 'utf8'))
88
+ const natives = Object.entries(manifest).filter(([, m]) => m.kind === 'native')
89
+ const sidecars = Object.entries(manifest).filter(([, m]) => m.kind !== 'native')
90
+ const copy = (list, to, names) => {
91
+ if (!list.length) return
92
+ mkdirSync(to, { recursive: true })
93
+ for (const [, m] of list) for (const f of names(m)) cpSync(join(from, f), join(to, f))
94
+ writeFileSync(
95
+ join(to, 'manifest.json'),
96
+ JSON.stringify(Object.fromEntries(list), null, 2) + '\n',
97
+ )
98
+ }
99
+ copy(sidecars, join(resolve(root), ...SHIPPED), (m) => [m.bin])
100
+ copy(natives, join(resolve(root), ...SHIPPED_NATIVE), (m) => [m.addon, m.wasm].filter(Boolean))
101
+ }
102
+
103
+ // ---- start --------------------------------------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Run `.output/rust/`'s binaries for `weft start` (or `built`'s, while `weft build` prerenders). A service with `WEFT_RUST_<NAME>_URL` already set is someone else's.
107
+ * Each keeps its port across restarts, so the server's address stays good.
108
+ * @returns {Promise<{ env: Record<string, string>, close(): void }>} `env` for the server process
109
+ */
110
+ export async function startSidecars({ root, built = false, log = console.log }) {
111
+ const dir = join(resolve(root), ...(built ? BUILT : SHIPPED))
112
+ const file = join(dir, 'manifest.json')
113
+ if (!existsSync(file)) return { env: {}, close() {} }
114
+ const manifest = JSON.parse(readFileSync(file, 'utf8'))
115
+ const names = Object.keys(manifest).filter(
116
+ (s) => manifest[s].kind !== 'native' && !process.env[urlVariable(s)],
117
+ )
118
+ // While a build prerenders, the libraries aren't next to a server yet.
119
+ const paths =
120
+ built && Object.values(manifest).some((m) => m.kind === 'native')
121
+ ? { WEFT_NATIVE_DIR: dir }
122
+ : {}
123
+ if (!names.length) return { env: paths, close() {} }
124
+ const secret = process.env.WEFT_RUST_SECRET || randomBytes(32).toString('hex')
125
+ const env = { ...paths, WEFT_RUST_SECRET: secret }
126
+ let closed = false
127
+ const live = new Map()
128
+
129
+ const supervise = (service) => {
130
+ const { bin, hash } = manifest[service]
131
+ let listen = '127.0.0.1:0',
132
+ tries = 0,
133
+ up
134
+ const begin = async () => {
135
+ up = Date.now()
136
+ const p = await launch({
137
+ exe: join(dir, bin),
138
+ service,
139
+ hash,
140
+ listen,
141
+ secret,
142
+ log,
143
+ onExit: (code, signal) => again(`exited (${signal ?? code})`),
144
+ })
145
+ listen = `127.0.0.1:${p.port}` // restarts rebind what the first start got
146
+ live.set(service, p)
147
+ return p
148
+ }
149
+ const again = (why) => {
150
+ if (closed) return
151
+ // One that ran for a while starts over; one that dies at once backs off.
152
+ tries = Date.now() - up > 30_000 ? 1 : tries + 1
153
+ const wait = Math.min(100 * 2 ** tries, 5_000)
154
+ log(`[weft] ${service} ${why}: restarting in ${wait} ms`)
155
+ setTimeout(() => {
156
+ if (!closed) begin().catch((e) => again(e.message.split('\n')[0]))
157
+ }, wait)
158
+ }
159
+ return begin()
160
+ }
161
+
162
+ for (const service of names) env[urlVariable(service)] = (await supervise(service)).url
163
+
164
+ const killAll = () => {
165
+ closed = true
166
+ for (const p of live.values()) stop(p, 1_000)
167
+ }
168
+ process.once('exit', killAll)
169
+ return { env, close: killAll }
170
+ }
@@ -0,0 +1,164 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { join, relative, resolve } from 'node:path'
3
+
4
+ const WASM = 'wasm32-unknown-unknown'
5
+
6
+ // ---- cargo --------------------------------------------------------------------------------------------------------
7
+
8
+ function run(cmd, args, { cwd, env, onLine }) {
9
+ return new Promise((ok) => {
10
+ let child
11
+ try {
12
+ child = spawn(cmd, args, {
13
+ cwd,
14
+ env: { ...process.env, ...env },
15
+ stdio: ['ignore', 'pipe', 'pipe'],
16
+ })
17
+ } catch (e) {
18
+ ok({ code: 127, stdout: '', stderr: e.message, missing: true })
19
+ return
20
+ }
21
+ let stdout = '',
22
+ stderr = ''
23
+ child.stdout.on('data', (d) => {
24
+ stdout += d
25
+ })
26
+ child.stderr.on('data', (d) => {
27
+ stderr += d
28
+ onLine?.(`${d}`)
29
+ })
30
+ child.on('error', (e) =>
31
+ ok({ code: 127, stdout, stderr: e.message, missing: e.code === 'ENOENT' }),
32
+ )
33
+ child.on('close', (code) => ok({ code, stdout, stderr }))
34
+ })
35
+ }
36
+
37
+ const pascal = (name) => name[0].toUpperCase() + name.slice(1)
38
+ const snake = (name) => name.replace(/[A-Z]/g, (c, i) => `${i ? '_' : ''}${c.toLowerCase()}`)
39
+
40
+ /**
41
+ * cargo's JSON messages as diagnostics. An error that names a contract function's types or method is R103, with the
42
+ * function's id, so it points at the TS edit that caused it (research 06 §7); any other is R109.
43
+ */
44
+ export function readCargo(stdout, { contract, service, root, dir }) {
45
+ const fns = (contract?.functions ?? []).filter((f) => f.impl?.service === service)
46
+ const names = fns.map((f) => ({
47
+ id: f.id,
48
+ re: new RegExp(`\\b(${pascal(f.name)}\\w*|${snake(f.name)})\\b`),
49
+ }))
50
+ const diagnostics = []
51
+ let exe,
52
+ lib,
53
+ ok = false
54
+ for (const line of stdout.split('\n')) {
55
+ if (!line.startsWith('{')) continue
56
+ let m
57
+ try {
58
+ m = JSON.parse(line)
59
+ } catch {
60
+ continue
61
+ }
62
+ if (m.reason === 'compiler-artifact' && m.executable && m.target?.kind?.includes('bin'))
63
+ exe = m.executable
64
+ if (m.reason === 'compiler-artifact' && m.target?.kind?.includes('cdylib'))
65
+ lib = m.filenames?.find((f) => /\.(dylib|so|dll|wasm)$/.test(f))
66
+ if (m.reason === 'build-finished') ok = m.success
67
+ if (m.reason !== 'compiler-message' || m.message?.level !== 'error' || !m.message.code) continue
68
+ const text = m.message.rendered ?? m.message.message
69
+ const hit = names.find((n) => n.re.test(text))
70
+ const at = m.message.spans?.find((s) => s.is_primary) ?? m.message.spans?.[0]
71
+ const file = at && relative(root, resolve(dir, at.file_name)).replaceAll('\\', '/')
72
+ diagnostics.push({
73
+ code: hit ? 'R103' : 'R109',
74
+ severity: 'error',
75
+ ...(hit && { function: hit.id }),
76
+ file: file ?? '.weft/contract.json',
77
+ span: at ? [at.line_start, at.column_start, at.line_end, at.column_end] : [1, 1, 1, 1],
78
+ why: hit
79
+ ? `\`${hit.id}\`: the Rust no longer matches the contract: ${m.message.message}`
80
+ : `\`${service}\` doesn't compile: ${m.message.message}`,
81
+ rendered: text,
82
+ })
83
+ }
84
+ return { ok, exe, lib, diagnostics }
85
+ }
86
+
87
+ /** `verb`: `build` or `check`. Failure names what to fix: a build script's panic is already R103 with the fix. */
88
+ export async function cargo(verb, { root, dir, service, contract, release, target, onLine }) {
89
+ const args = [
90
+ verb,
91
+ '--message-format=json',
92
+ ...(release ? ['--release'] : []),
93
+ ...(target ? ['--target', target] : []),
94
+ ]
95
+ const env = {
96
+ WEFT_CONTRACT: join(root, '.weft', 'contract.json'),
97
+ ...(target === WASM && {
98
+ CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: '-C target-feature=+simd128',
99
+ }),
100
+ }
101
+ const r = await run('cargo', args, { cwd: dir, env, onLine })
102
+ if (r.missing)
103
+ return {
104
+ ok: false,
105
+ diagnostics: [
106
+ {
107
+ code: 'R109',
108
+ severity: 'error',
109
+ file: 'package.json',
110
+ span: [1, 1, 1, 1],
111
+ why: `\`${service}\` is Rust you opted into, so it needs \`cargo\`, and it isn't on PATH. Install it from rustup.rs, or set \`external: true\` for a sidecar built elsewhere. The rest of weft doesn't need Rust.`,
112
+ },
113
+ ],
114
+ }
115
+ const read = readCargo(r.stdout, { contract, service, root, dir })
116
+ if (r.code === 0) return read
117
+ if (!read.diagnostics.length) {
118
+ const panic = /R103: ([^\n]+)/.exec(r.stderr)?.[1]
119
+ read.diagnostics.push({
120
+ code: panic ? 'R103' : 'R109',
121
+ severity: 'error',
122
+ file: relative(root, dir).replaceAll('\\', '/') + '/Cargo.toml',
123
+ span: [1, 1, 1, 1],
124
+ why:
125
+ panic ??
126
+ `cargo ${verb} failed in \`${service}\`:\n${r.stderr.trim().split('\n').slice(-12).join('\n')}`,
127
+ })
128
+ }
129
+ return { ...read, ok: false }
130
+ }
131
+
132
+ /** R104 for a `.wasm` that won't build: a warning unless the build needs it. `error`: the preset has no other way to run. */
133
+ export async function checkWasm({ root, dir, service, contract, preset, rust, build }) {
134
+ if (rust?.services?.[service]?.wasm === false) return { diagnostics: [] }
135
+ const r = await cargo(build ? 'build' : 'check', {
136
+ root,
137
+ dir,
138
+ service,
139
+ contract,
140
+ target: WASM,
141
+ release: build,
142
+ })
143
+ if (r.ok) return { lib: r.lib, diagnostics: [] }
144
+ const missing = r.diagnostics.find((d) =>
145
+ /can't find crate for `(std|core)`|target may not be installed/.test(
146
+ `${d.why}${d.rendered ?? ''}`,
147
+ ),
148
+ )
149
+ const why = missing
150
+ ? `\`rustup target add ${WASM}\`: this toolchain can't build \`${service}\`'s WASM fallback`
151
+ : `\`${service}\` has no WASM fallback: ${r.diagnostics[0]?.why ?? 'the build failed'}`
152
+ const needed = preset && !/^(node|bun|deno[-_]server)/.test(preset)
153
+ return {
154
+ diagnostics: [
155
+ {
156
+ code: 'R104',
157
+ severity: needed ? 'error' : 'warning',
158
+ file: relative(root, dir).replaceAll('\\', '/') + '/Cargo.toml',
159
+ span: [1, 1, 1, 1],
160
+ why: `${why}. An addon loads on this machine, and nothing else can run it. Fix it, or set \`rust.services.${service}.wasm: false\` to say so`,
161
+ },
162
+ ],
163
+ }
164
+ }
@@ -0,0 +1,17 @@
1
+ import { resolve } from 'node:path'
2
+
3
+ /** Same spelling as `urlVariable` in rust.ts, which can't share a module with this one: it ships in the server bundle. */
4
+ export const urlVariable = (service) =>
5
+ `WEFT_RUST_${service.toUpperCase().replaceAll('-', '_')}_URL`
6
+
7
+ const external = (rust, service) => rust?.services?.[service]?.external === true
8
+ /** The crate folder of a service. */
9
+ export const crateDir = (root, rust, service) =>
10
+ resolve(root, rust?.services?.[service]?.crate ?? `crates/${service}`)
11
+
12
+ /** The services in a contract that have a crate here. */
13
+ export const buildable = (contract, rust) =>
14
+ Object.keys(contract.services ?? {}).filter((s) => !external(rust, s))
15
+ export const isNative = (contract, service) => contract.services?.[service]?.kind === 'native'
16
+
17
+ export const stem = (service) => service.replaceAll('-', '_')
@@ -0,0 +1,91 @@
1
+ import { join } from 'node:path'
2
+ import { cargo } from './cargo.mjs'
3
+ import { crateDir, isNative, stem } from './common.mjs'
4
+ import { launch, stop } from './process.mjs'
5
+ import { copyFileSync, renameSync } from 'node:fs'
6
+
7
+ /** Copy, then rename: macOS kills a process that loads a signed binary overwritten in place. */
8
+ function place(from, to) {
9
+ copyFileSync(from, `${to}.tmp`)
10
+ renameSync(`${to}.tmp`, to)
11
+ }
12
+
13
+ export function devBuilder(deps) {
14
+ const { log, states, closed } = deps
15
+ async function rebuild(service) {
16
+ const s = states.get(service)
17
+ if (s.building) return void (s.again = true)
18
+ s.building = true
19
+ try {
20
+ do {
21
+ s.again = false
22
+ await once(service, s, deps, exited)
23
+ } while (s.again && !closed())
24
+ } finally {
25
+ s.building = false
26
+ }
27
+ }
28
+
29
+ function exited(service, s, child, code, signal) {
30
+ if (closed() || !child || child.stopped || s.current !== child) return
31
+ s.current = undefined
32
+ const wait = Math.min(100 * 2 ** (s.crashes = (s.crashes ?? 0) + 1), 5_000)
33
+ log(`[weft] ${service} exited (${signal ?? code}): restarting in ${wait} ms`)
34
+ setTimeout(() => {
35
+ if (closed() || s.current || s.building) return
36
+ void rebuild(service) // cargo finds nothing to do, and the binary starts again
37
+ }, wait).unref()
38
+ }
39
+
40
+ return rebuild
41
+ }
42
+
43
+ export async function once(service, s, deps, exited) {
44
+ const { root, rust, secret, log, say, overlay, nativeDir, publish, contract } = deps
45
+ const hash = contract().services?.[service]?.hash
46
+ if (!hash) return
47
+ const dir = crateDir(root, rust, service)
48
+ const t = performance.now()
49
+ const built = await cargo('build', { root, dir, service, contract: contract() })
50
+ if (!built.ok || !(built.exe || built.lib)) {
51
+ built.diagnostics.forEach(say)
52
+ s.failure = built.diagnostics.map((d) => d.why).join('\n') || `\`${service}\` didn't build`
53
+ overlay(s.failure)
54
+ if (isNative(contract(), service) && !s.loaded) publish()
55
+ return
56
+ }
57
+ log(`[weft] ${service}: built in ${Math.round(performance.now() - t)} ms`)
58
+ if (isNative(contract(), service)) {
59
+ s.generation = (s.generation ?? 0) + 1
60
+ s.loaded = { hash, addon: `${stem(service)}-${s.generation}.node` }
61
+ s.failure = undefined
62
+ place(built.lib, join(nativeDir, s.loaded.addon))
63
+ return publish()
64
+ }
65
+ let next
66
+ try {
67
+ next = await launch({
68
+ exe: built.exe,
69
+ service,
70
+ hash,
71
+ listen: '127.0.0.1:0',
72
+ secret,
73
+ log,
74
+ onExit: (code, signal) => exited(service, s, next, code, signal),
75
+ })
76
+ } catch (e) {
77
+ s.failure = e.message
78
+ log(`[weft] ${e.message}`)
79
+ return overlay(e.message)
80
+ }
81
+ // The swap: new calls go to `next` from here; the old child finishes what it has, then goes.
82
+ const old = s.current
83
+ s.current = next
84
+ s.failure = undefined
85
+ s.exe = built.exe
86
+ s.crashes = 0
87
+ if (old) {
88
+ old.retired = true
89
+ if (old.inflight === 0) stop(old)
90
+ }
91
+ }
@@ -0,0 +1,88 @@
1
+ import { existsSync, watch } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isServerModule } from '../vite.mjs'
4
+ import { buildable, crateDir, isNative, urlVariable } from './common.mjs'
5
+
6
+ export async function watchServices(deps) {
7
+ const {
8
+ root,
9
+ rust,
10
+ contract,
11
+ setContract,
12
+ first,
13
+ states,
14
+ proxy,
15
+ publish,
16
+ rebuild,
17
+ refresh,
18
+ overlay,
19
+ log,
20
+ } = deps
21
+ const watchers = []
22
+
23
+ for (const service of buildable(contract(), rust)) {
24
+ const dir = crateDir(root, rust, service)
25
+ const s = {
26
+ current: undefined,
27
+ failure: existsSync(dir) ? undefined : `\`crates/${service}\` doesn't exist`,
28
+ building: false,
29
+ }
30
+ states.set(service, s)
31
+ if (!isNative(contract(), service)) {
32
+ const p = await proxy(s)
33
+ s.proxy = p
34
+ process.env[urlVariable(service)] = `http://127.0.0.1:${p.port}`
35
+ }
36
+ if (!existsSync(dir)) {
37
+ log(
38
+ `[weft] R109 error: ${s.failure}. Point \`rust.services.${service}.crate\` at it, or set \`external: true\``,
39
+ )
40
+ if (isNative(contract(), service)) publish()
41
+ continue
42
+ }
43
+ if (!first.blocked) await rebuild(service)
44
+ const changed = debounce(() => rebuild(service))
45
+ watchers.push(
46
+ watch(dir, { recursive: true }, (_, file) => {
47
+ if (file && !/^target([\\/]|$)/.test(file) && !file.endsWith('.lock')) changed()
48
+ }),
49
+ )
50
+ }
51
+
52
+ // A server module's edit may change a schema: the contract is regenerated, and only a change to a service's hash
53
+ // rebuilds it. cargo then stops where the Rust no longer fits.
54
+ const src = join(root, 'src')
55
+ const contractChanged = debounce(async () => {
56
+ try {
57
+ const fresh = await refresh()
58
+ if (fresh.blocked) return overlay(fresh.blocked.why)
59
+ const before = contract()
60
+ setContract(fresh.contract)
61
+ for (const service of buildable(contract(), rust)) {
62
+ if (
63
+ states.has(service) &&
64
+ before.services?.[service]?.hash !== contract().services[service].hash
65
+ )
66
+ void rebuild(service)
67
+ }
68
+ } catch (e) {
69
+ log(`[weft] the contract couldn't be regenerated: ${e.message.split('\n')[0]}`)
70
+ }
71
+ })
72
+ if (existsSync(src))
73
+ watchers.push(
74
+ watch(src, { recursive: true }, (_, file) => {
75
+ if (file && isServerModule(`src/${file.replaceAll('\\', '/')}`)) contractChanged()
76
+ }),
77
+ )
78
+
79
+ return watchers
80
+ }
81
+
82
+ const debounce = (fn, ms = 200) => {
83
+ let t
84
+ return () => {
85
+ clearTimeout(t)
86
+ t = setTimeout(() => fn(), ms)
87
+ }
88
+ }
@@ -0,0 +1,19 @@
1
+ import { renameSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { isNative } from './common.mjs'
4
+
5
+ export function publisher(states, nativeDir, contract) {
6
+ /** What the server's `native()` reads on each call: one rename, so it never sees half of it. */
7
+ return () => {
8
+ const manifest = Object.fromEntries(
9
+ [...states]
10
+ .filter(([k]) => isNative(contract(), k))
11
+ .map(([k, s]) => [
12
+ k,
13
+ s.loaded ?? { hash: '', failure: s.failure ?? `\`${k}\` hasn't built` },
14
+ ]),
15
+ )
16
+ writeFileSync(join(nativeDir, 'manifest.tmp'), JSON.stringify(manifest))
17
+ renameSync(join(nativeDir, 'manifest.tmp'), join(nativeDir, 'manifest.json'))
18
+ }
19
+ }
@@ -0,0 +1,58 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ /** Start a binary and wait until it listens and its health check names `hash`. Rejects with why, and leaves nothing running. */
4
+ export function launch({ exe, service, hash, listen, secret, onExit, log }) {
5
+ return new Promise((ok, no) => {
6
+ const child = spawn(exe, [], {
7
+ env: { ...process.env, WEFT_LISTEN: listen, WEFT_RUST_SECRET: secret },
8
+ stdio: ['ignore', 'pipe', 'inherit'],
9
+ })
10
+ let out = '',
11
+ ready = false
12
+ const failed = (why) => {
13
+ clearTimeout(timer)
14
+ child.kill('SIGKILL')
15
+ no(Object.assign(new Error(why), { code: 'R109' }))
16
+ }
17
+ const timer = setTimeout(() => failed(`\`${service}\` didn't listen within 30 s`), 30_000)
18
+ child.on('error', (e) => failed(`\`${service}\` didn't start: ${e.message}`))
19
+ child.on('exit', (code, signal) => {
20
+ if (!ready) return failed(`\`${service}\` exited before it listened (${signal ?? code})`)
21
+ onExit?.(code, signal)
22
+ })
23
+ child.stdout.on('data', (d) => void heard(d))
24
+ const heard = async (d) => {
25
+ if (ready) return void process.stdout.write(d)
26
+ out += d
27
+ const addr = /weft-listening (\S+)/.exec(out)?.[1]
28
+ if (!addr) return
29
+ ready = true
30
+ clearTimeout(timer)
31
+ const url = `http://${addr}`
32
+ try {
33
+ const health = await (await fetch(`${url}/_weft/health`)).json()
34
+ if (health.contract !== hash) {
35
+ ready = false
36
+ return failed(
37
+ `R103: \`${service}\` was built from contract ${health.contract}, and this app's is ${hash}: rebuild it`,
38
+ )
39
+ }
40
+ log?.(`[weft] ${service}: listening on ${addr}`)
41
+ ok({ child, url, port: Number(addr.split(':').pop()), inflight: 0 })
42
+ } catch (e) {
43
+ ready = false
44
+ failed(`\`${service}\`'s health check failed: ${e.message}`)
45
+ }
46
+ }
47
+ })
48
+ }
49
+
50
+ /** SIGTERM, which the crate drains on; SIGKILL if that takes too long. */
51
+ export function stop(p, wait = 5_000) {
52
+ if (!p || p.child.exitCode !== null) return
53
+ p.stopped = true
54
+ p.child.kill('SIGTERM')
55
+ const t = setTimeout(() => p.child.kill('SIGKILL'), wait)
56
+ t.unref()
57
+ p.child.once('exit', () => clearTimeout(t))
58
+ }
@@ -0,0 +1,19 @@
1
+ import { writeContract } from '../contract.mjs'
2
+
3
+ export function makeRefresh(root, site, rust, say) {
4
+ return async () => {
5
+ const held = process.env.WEFT_BUILD
6
+ process.env.WEFT_BUILD = '1' // the same as a build: server modules load, and nothing connects
7
+ try {
8
+ const { contract, diagnostics } = await writeContract({ root, site, rust })
9
+ diagnostics.filter((d) => d.code.startsWith('R1')).forEach(say)
10
+ return {
11
+ contract,
12
+ blocked: diagnostics.find((d) => /^R10[12]$/.test(d.code) && d.severity === 'error'),
13
+ }
14
+ } finally {
15
+ if (held === undefined) delete process.env.WEFT_BUILD
16
+ else process.env.WEFT_BUILD = held
17
+ }
18
+ }
19
+ }