@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
package/src/sidecar.mjs CHANGED
@@ -16,175 +16,32 @@
16
16
  // says which, so the next call loads it in place: an addon can't be unloaded, and needn't be. build: an addon and a
17
17
  // `.wasm` next to the server, in `.output/server/rust/`, so whatever moves the server moves them too.
18
18
 
19
- import { spawn } from 'node:child_process'
20
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from 'node:fs'
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'
21
20
  import { createServer, request as httpRequest, Agent } from 'node:http'
22
21
  import { randomBytes } from 'node:crypto'
23
- import { basename, join, relative, resolve } from 'node:path'
24
- import { writeContract } from './contract.mjs'
25
- import { isServerModule } from './vite.mjs'
22
+ import { join, resolve } from 'node:path'
23
+ import { makeRefresh } from './sidecar/refresh.mjs'
24
+ import { watchServices } from './sidecar/dev-watch.mjs'
25
+ import { buildable, isNative } from './sidecar/common.mjs'
26
+ import { publisher } from './sidecar/manifest.mjs'
27
+ import { devBuilder } from './sidecar/dev-build.mjs'
28
+ import { stop } from './sidecar/process.mjs'
26
29
 
27
30
  /** The app names a sidecar: a server module imports `remote`. Nothing else here runs for an app that doesn't. */
28
31
  export function usesRemote(root) {
29
32
  const src = join(root, 'src')
30
33
  if (!existsSync(src)) return false
31
- return readdirSync(src, { recursive: true, withFileTypes: true }).some(f => f.isFile() && /\.[cm]?[jt]sx?$/.test(f.name) && /['"]@weft-js\/start\/rust['"]/.test(readFileSync(join(f.parentPath, f.name), 'utf8')))
34
+ return readdirSync(src, { recursive: true, withFileTypes: true }).some(
35
+ (f) =>
36
+ f.isFile() &&
37
+ /\.[cm]?[jt]sx?$/.test(f.name) &&
38
+ /['"]@weft-js\/start\/rust['"]/.test(readFileSync(join(f.parentPath, f.name), 'utf8')),
39
+ )
32
40
  }
33
41
 
34
- /** Same spelling as `urlVariable` in rust.ts, which can't share a module with this one: it ships in the server bundle. */
35
- export const urlVariable = service => `WEFT_RUST_${service.toUpperCase().replaceAll('-', '_')}_URL`
42
+ export { crateDir, urlVariable } from './sidecar/common.mjs'
36
43
 
37
- const external = (rust, service) => rust?.services?.[service]?.external === true
38
- /** The crate folder of a service. */
39
- export const crateDir = (root, rust, service) => resolve(root, rust?.services?.[service]?.crate ?? `crates/${service}`)
40
-
41
- /** The services in a contract that have a crate here. */
42
- const buildable = (contract, rust) => Object.keys(contract.services ?? {}).filter(s => !external(rust, s))
43
- const isNative = (contract, service) => contract.services?.[service]?.kind === 'native'
44
- const WASM = 'wasm32-unknown-unknown'
45
-
46
- // ---- cargo --------------------------------------------------------------------------------------------------------
47
-
48
- function run(cmd, args, { cwd, env, onLine }) {
49
- return new Promise(ok => {
50
- let child
51
- try {
52
- child = spawn(cmd, args, { cwd, env: { ...process.env, ...env }, stdio: ['ignore', 'pipe', 'pipe'] })
53
- } catch (e) {
54
- ok({ code: 127, stdout: '', stderr: e.message, missing: true })
55
- return
56
- }
57
- let stdout = '', stderr = ''
58
- child.stdout.on('data', d => { stdout += d })
59
- child.stderr.on('data', d => {
60
- stderr += d
61
- onLine?.(`${d}`)
62
- })
63
- child.on('error', e => ok({ code: 127, stdout, stderr: e.message, missing: e.code === 'ENOENT' }))
64
- child.on('close', code => ok({ code, stdout, stderr }))
65
- })
66
- }
67
-
68
- const pascal = name => name[0].toUpperCase() + name.slice(1)
69
- const snake = name => name.replace(/[A-Z]/g, (c, i) => `${i ? '_' : ''}${c.toLowerCase()}`)
70
-
71
- /**
72
- * cargo's JSON messages as diagnostics. An error that names a contract function's types or method is R103, with the
73
- * function's id, so it points at the TS edit that caused it (research 06 §7); any other is R109.
74
- */
75
- export function readCargo(stdout, { contract, service, root, dir }) {
76
- const fns = (contract?.functions ?? []).filter(f => f.impl?.service === service)
77
- const names = fns.map(f => ({ id: f.id, re: new RegExp(`\\b(${pascal(f.name)}\\w*|${snake(f.name)})\\b`) }))
78
- const diagnostics = []
79
- let exe, lib, ok = false
80
- for (const line of stdout.split('\n')) {
81
- if (!line.startsWith('{')) continue
82
- let m
83
- try { m = JSON.parse(line) } catch { continue }
84
- if (m.reason === 'compiler-artifact' && m.executable && m.target?.kind?.includes('bin')) exe = m.executable
85
- if (m.reason === 'compiler-artifact' && m.target?.kind?.includes('cdylib')) lib = m.filenames?.find(f => /\.(dylib|so|dll|wasm)$/.test(f))
86
- if (m.reason === 'build-finished') ok = m.success
87
- if (m.reason !== 'compiler-message' || m.message?.level !== 'error' || !m.message.code) continue
88
- const text = m.message.rendered ?? m.message.message
89
- const hit = names.find(n => n.re.test(text))
90
- const at = m.message.spans?.find(s => s.is_primary) ?? m.message.spans?.[0]
91
- const file = at && relative(root, resolve(dir, at.file_name)).replaceAll('\\', '/')
92
- diagnostics.push({
93
- code: hit ? 'R103' : 'R109', severity: 'error', ...(hit && { function: hit.id }), file: file ?? '.weft/contract.json',
94
- span: at ? [at.line_start, at.column_start, at.line_end, at.column_end] : [1, 1, 1, 1],
95
- why: hit ? `\`${hit.id}\`: the Rust no longer matches the contract: ${m.message.message}` : `\`${service}\` doesn't compile: ${m.message.message}`,
96
- rendered: text,
97
- })
98
- }
99
- return { ok, exe, lib, diagnostics }
100
- }
101
-
102
- /** `verb`: `build` or `check`. Failure names what to fix: a build script's panic is already R103 with the fix. */
103
- export async function cargo(verb, { root, dir, service, contract, release, target, onLine }) {
104
- const args = [verb, '--message-format=json', ...(release ? ['--release'] : []), ...(target ? ['--target', target] : [])]
105
- const env = { WEFT_CONTRACT: join(root, '.weft', 'contract.json'), ...(target === WASM && { CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUSTFLAGS: '-C target-feature=+simd128' }) }
106
- const r = await run('cargo', args, { cwd: dir, env, onLine })
107
- if (r.missing) return { ok: false, diagnostics: [{ code: 'R109', severity: 'error', file: 'package.json', span: [1, 1, 1, 1], 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.` }] }
108
- const read = readCargo(r.stdout, { contract, service, root, dir })
109
- if (r.code === 0) return read
110
- if (!read.diagnostics.length) {
111
- const panic = /R103: ([^\n]+)/.exec(r.stderr)?.[1]
112
- read.diagnostics.push({ code: panic ? 'R103' : 'R109', severity: 'error', file: relative(root, dir).replaceAll('\\', '/') + '/Cargo.toml', span: [1, 1, 1, 1], why: panic ?? `cargo ${verb} failed in \`${service}\`:\n${r.stderr.trim().split('\n').slice(-12).join('\n')}` })
113
- }
114
- return { ...read, ok: false }
115
- }
116
-
117
- /** R104 for a `.wasm` that won't build: a warning unless the build needs it. `error`: the preset has no other way to run. */
118
- export async function checkWasm({ root, dir, service, contract, preset, rust, build }) {
119
- if (rust?.services?.[service]?.wasm === false) return { diagnostics: [] }
120
- const r = await cargo(build ? 'build' : 'check', { root, dir, service, contract, target: WASM, release: build })
121
- if (r.ok) return { lib: r.lib, diagnostics: [] }
122
- const missing = r.diagnostics.find(d => /can't find crate for `(std|core)`|target may not be installed/.test(`${d.why}${d.rendered ?? ''}`))
123
- const why = missing ? `\`rustup target add ${WASM}\`: this toolchain can't build \`${service}\`'s WASM fallback` : `\`${service}\` has no WASM fallback: ${r.diagnostics[0]?.why ?? 'the build failed'}`
124
- const needed = preset && !/^(node|bun|deno[-_]server)/.test(preset)
125
- return { diagnostics: [{ code: 'R104', severity: needed ? 'error' : 'warning', file: relative(root, dir).replaceAll('\\', '/') + '/Cargo.toml', span: [1, 1, 1, 1], 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` }] }
126
- }
127
-
128
- const stem = service => service.replaceAll('-', '_')
129
-
130
- /** Copy, then rename: macOS kills a process that loads a signed binary overwritten in place. */
131
- function place(from, to) {
132
- copyFileSync(from, `${to}.tmp`)
133
- renameSync(`${to}.tmp`, to)
134
- }
135
-
136
- // ---- children -----------------------------------------------------------------------------------------------------
137
-
138
- /** Start a binary and wait until it listens and its health check names `hash`. Rejects with why, and leaves nothing running. */
139
- function launch({ exe, service, hash, listen, secret, onExit, log }) {
140
- return new Promise((ok, no) => {
141
- const child = spawn(exe, [], { env: { ...process.env, WEFT_LISTEN: listen, WEFT_RUST_SECRET: secret }, stdio: ['ignore', 'pipe', 'inherit'] })
142
- let out = '', ready = false
143
- const failed = why => {
144
- clearTimeout(timer)
145
- child.kill('SIGKILL')
146
- no(Object.assign(new Error(why), { code: 'R109' }))
147
- }
148
- const timer = setTimeout(() => failed(`\`${service}\` didn't listen within 30 s`), 30_000)
149
- child.on('error', e => failed(`\`${service}\` didn't start: ${e.message}`))
150
- child.on('exit', (code, signal) => {
151
- if (!ready) return failed(`\`${service}\` exited before it listened (${signal ?? code})`)
152
- onExit?.(code, signal)
153
- })
154
- child.stdout.on('data', d => void heard(d))
155
- const heard = async d => {
156
- if (ready) return void process.stdout.write(d)
157
- out += d
158
- const addr = /weft-listening (\S+)/.exec(out)?.[1]
159
- if (!addr) return
160
- ready = true
161
- clearTimeout(timer)
162
- const url = `http://${addr}`
163
- try {
164
- const health = await (await fetch(`${url}/_weft/health`)).json()
165
- if (health.contract !== hash) {
166
- ready = false
167
- return failed(`R103: \`${service}\` was built from contract ${health.contract}, and this app's is ${hash}: rebuild it`)
168
- }
169
- log?.(`[weft] ${service}: listening on ${addr}`)
170
- ok({ child, url, port: Number(addr.split(':').pop()), inflight: 0 })
171
- } catch (e) {
172
- ready = false
173
- failed(`\`${service}\`'s health check failed: ${e.message}`)
174
- }
175
- }
176
- })
177
- }
178
-
179
- /** SIGTERM, which the crate drains on; SIGKILL if that takes too long. */
180
- function stop(p, wait = 5_000) {
181
- if (!p || p.child.exitCode !== null) return
182
- p.stopped = true
183
- p.child.kill('SIGTERM')
184
- const t = setTimeout(() => p.child.kill('SIGKILL'), wait)
185
- t.unref()
186
- p.child.once('exit', () => clearTimeout(t))
187
- }
44
+ export { cargo, checkWasm, readCargo } from './sidecar/cargo.mjs'
188
45
 
189
46
  // ---- dev ----------------------------------------------------------------------------------------------------------
190
47
 
@@ -196,18 +53,28 @@ function proxy(state) {
196
53
  if (!to) {
197
54
  if (!state.failure) return void res.destroy() // between crash and restart: `remote()` reports it, R107
198
55
  res.writeHead(422, { 'content-type': 'application/json' })
199
- return res.end(JSON.stringify({ ok: false, error: { code: 'Drift', message: state.failure } }))
56
+ return res.end(
57
+ JSON.stringify({ ok: false, error: { code: 'Drift', message: state.failure } }),
58
+ )
200
59
  }
201
60
  to.inflight++
202
- res.once('close', () => { if (--to.inflight === 0 && to.retired) stop(to) })
203
- const up = httpRequest(`${to.url}${req.url}`, { method: req.method, headers: req.headers, agent }, answer => {
204
- res.writeHead(answer.statusCode, answer.headers)
205
- answer.pipe(res)
61
+ res.once('close', () => {
62
+ if (--to.inflight === 0 && to.retired) stop(to)
206
63
  })
64
+ const up = httpRequest(
65
+ `${to.url}${req.url}`,
66
+ { method: req.method, headers: req.headers, agent },
67
+ (answer) => {
68
+ res.writeHead(answer.statusCode, answer.headers)
69
+ answer.pipe(res)
70
+ },
71
+ )
207
72
  up.on('error', () => res.destroy())
208
73
  req.pipe(up)
209
74
  })
210
- return new Promise(ok => { server.listen(0, '127.0.0.1', () => ok({ server, agent, port: server.address().port })) })
75
+ return new Promise((ok) => {
76
+ server.listen(0, '127.0.0.1', () => ok({ server, agent, port: server.address().port }))
77
+ })
211
78
  }
212
79
 
213
80
  /**
@@ -218,151 +85,53 @@ function proxy(state) {
218
85
  export async function devSidecars({ root, site, rust, log = console.log }) {
219
86
  root = resolve(root)
220
87
  const secret = (process.env.WEFT_RUST_SECRET ||= randomBytes(32).toString('hex'))
221
- const say = d => log(`[weft] ${d.code} ${d.severity}: ${d.why}${d.rendered ? `\n${d.rendered}` : ''}`)
222
- let vite, closed = false
223
- const overlay = message => vite?.ws.send({ type: 'error', err: { message, stack: '', plugin: 'weft:sidecar' } })
224
- const refresh = async () => {
225
- const held = process.env.WEFT_BUILD
226
- process.env.WEFT_BUILD = '1' // the same as a build: server modules load, and nothing connects
227
- try {
228
- const { contract, diagnostics } = await writeContract({ root, site, rust })
229
- diagnostics.filter(d => d.code.startsWith('R1')).forEach(say)
230
- return { contract, blocked: diagnostics.find(d => /^R10[12]$/.test(d.code) && d.severity === 'error') }
231
- } finally {
232
- if (held === undefined) delete process.env.WEFT_BUILD
233
- else process.env.WEFT_BUILD = held
234
- }
235
- }
88
+ const say = (d) =>
89
+ log(`[weft] ${d.code} ${d.severity}: ${d.why}${d.rendered ? `\n${d.rendered}` : ''}`)
90
+ let vite,
91
+ closed = false
92
+ const overlay = (message) =>
93
+ vite?.ws.send({ type: 'error', err: { message, stack: '', plugin: 'weft:sidecar' } })
94
+ const refresh = makeRefresh(root, site, rust, say)
236
95
 
237
96
  const first = await refresh()
238
97
  let contract = first.contract
239
98
  const states = new Map()
240
99
  const nativeDir = join(root, '.weft', 'native')
241
- /** What the server's `native()` reads on each call: one rename, so it never sees half of it. */
242
- const publish = () => {
243
- const manifest = Object.fromEntries([...states].filter(([k]) => isNative(contract, k)).map(([k, s]) => [k, s.loaded ?? { hash: '', failure: s.failure ?? `\`${k}\` hasn't built` }]))
244
- writeFileSync(join(nativeDir, 'manifest.tmp'), JSON.stringify(manifest))
245
- renameSync(join(nativeDir, 'manifest.tmp'), join(nativeDir, 'manifest.json'))
246
- }
247
- if (buildable(contract, rust).some(k => isNative(contract, k))) {
100
+ const publish = publisher(states, nativeDir, () => contract)
101
+ if (buildable(contract, rust).some((k) => isNative(contract, k))) {
248
102
  rmSync(nativeDir, { recursive: true, force: true })
249
103
  mkdirSync(nativeDir, { recursive: true })
250
104
  process.env.WEFT_NATIVE_DIR = nativeDir
251
105
  }
252
106
 
253
- async function rebuild(service) {
254
- const s = states.get(service)
255
- if (s.building) return void (s.again = true)
256
- s.building = true
257
- try {
258
- do {
259
- s.again = false
260
- await once(service, s)
261
- // oxlint-disable-next-line no-unmodified-loop-condition -- `again` and `closed` are set by what arrives during the await
262
- } while (s.again && !closed)
263
- } finally {
264
- s.building = false
265
- }
266
- }
267
-
268
- async function once(service, s) {
269
- const hash = contract.services?.[service]?.hash
270
- if (!hash) return
271
- const dir = crateDir(root, rust, service)
272
- const t = performance.now()
273
- const built = await cargo('build', { root, dir, service, contract })
274
- if (!built.ok || !(built.exe || built.lib)) {
275
- built.diagnostics.forEach(say)
276
- s.failure = built.diagnostics.map(d => d.why).join('\n') || `\`${service}\` didn't build`
277
- overlay(s.failure)
278
- if (isNative(contract, service) && !s.loaded) publish()
279
- return
280
- }
281
- log(`[weft] ${service}: built in ${Math.round(performance.now() - t)} ms`)
282
- if (isNative(contract, service)) {
283
- s.generation = (s.generation ?? 0) + 1
284
- s.loaded = { hash, addon: `${stem(service)}-${s.generation}.node` }
285
- s.failure = undefined
286
- place(built.lib, join(nativeDir, s.loaded.addon))
287
- return publish()
288
- }
289
- let next
290
- try {
291
- next = await launch({ exe: built.exe, service, hash, listen: '127.0.0.1:0', secret, log, onExit: (code, signal) => exited(service, s, next, code, signal) })
292
- } catch (e) {
293
- s.failure = e.message
294
- log(`[weft] ${e.message}`)
295
- return overlay(e.message)
296
- }
297
- // The swap: new calls go to `next` from here; the old child finishes what it has, then goes.
298
- const old = s.current
299
- s.current = next
300
- s.failure = undefined
301
- s.exe = built.exe
302
- s.crashes = 0
303
- if (old) {
304
- old.retired = true
305
- if (old.inflight === 0) stop(old)
306
- }
307
- }
308
-
309
- function exited(service, s, child, code, signal) {
310
- if (closed || !child || child.stopped || s.current !== child) return
311
- s.current = undefined
312
- const wait = Math.min(100 * 2 ** (s.crashes = (s.crashes ?? 0) + 1), 5_000)
313
- log(`[weft] ${service} exited (${signal ?? code}): restarting in ${wait} ms`)
314
- setTimeout(() => {
315
- if (closed || s.current || s.building) return
316
- void rebuild(service) // cargo finds nothing to do, and the binary starts again
317
- }, wait).unref()
318
- }
319
-
320
- const watchers = []
321
- const debounce = (fn, ms = 200) => {
322
- let t
323
- return () => { clearTimeout(t); t = setTimeout(() => fn(), ms) }
324
- }
325
-
326
- for (const service of buildable(contract, rust)) {
327
- const dir = crateDir(root, rust, service)
328
- const s = { current: undefined, failure: existsSync(dir) ? undefined : `\`crates/${service}\` doesn't exist`, building: false }
329
- states.set(service, s)
330
- if (!isNative(contract, service)) {
331
- const p = await proxy(s)
332
- s.proxy = p
333
- process.env[urlVariable(service)] = `http://127.0.0.1:${p.port}`
334
- }
335
- if (!existsSync(dir)) {
336
- log(`[weft] R109 error: ${s.failure}. Point \`rust.services.${service}.crate\` at it, or set \`external: true\``)
337
- if (isNative(contract, service)) publish()
338
- continue
339
- }
340
- if (!first.blocked) await rebuild(service)
341
- const changed = debounce(() => rebuild(service))
342
- watchers.push(watch(dir, { recursive: true }, (_, file) => {
343
- if (file && !/^target([\\/]|$)/.test(file) && !file.endsWith('.lock')) changed()
344
- }))
345
- }
107
+ const rebuild = devBuilder({
108
+ root,
109
+ rust,
110
+ secret,
111
+ log,
112
+ say,
113
+ overlay,
114
+ states,
115
+ nativeDir,
116
+ publish,
117
+ contract: () => contract,
118
+ closed: () => closed,
119
+ })
346
120
 
347
- // A server module's edit may change a schema: the contract is regenerated, and only a change to a service's hash
348
- // rebuilds it. cargo then stops where the Rust no longer fits.
349
- const src = join(root, 'src')
350
- const contractChanged = debounce(async () => {
351
- try {
352
- const fresh = await refresh()
353
- if (fresh.blocked) return overlay(fresh.blocked.why)
354
- const before = contract
355
- contract = fresh.contract
356
- for (const service of buildable(contract, rust)) {
357
- if (states.has(service) && before.services?.[service]?.hash !== contract.services[service].hash) void rebuild(service)
358
- }
359
- } catch (e) {
360
- log(`[weft] the contract couldn't be regenerated: ${e.message.split('\n')[0]}`)
361
- }
121
+ const watchers = await watchServices({
122
+ root,
123
+ rust,
124
+ contract: () => contract,
125
+ setContract: (next) => (contract = next),
126
+ first,
127
+ states,
128
+ proxy,
129
+ publish,
130
+ rebuild,
131
+ refresh,
132
+ overlay,
133
+ log,
362
134
  })
363
- if (existsSync(src)) watchers.push(watch(src, { recursive: true }, (_, file) => {
364
- if (file && isServerModule(`src/${file.replaceAll('\\', '/')}`)) contractChanged()
365
- }))
366
135
 
367
136
  const killAll = () => {
368
137
  closed = true
@@ -371,7 +140,9 @@ export async function devSidecars({ root, site, rust, log = console.log }) {
371
140
  process.once('exit', killAll)
372
141
  return {
373
142
  ready: Object.fromEntries([...states].map(([k, s]) => [k, !!(s.current || s.loaded)])),
374
- attach(server) { vite = server },
143
+ attach(server) {
144
+ vite = server
145
+ },
375
146
  async close() {
376
147
  process.removeListener('exit', killAll)
377
148
  killAll()
@@ -379,125 +150,13 @@ export async function devSidecars({ root, site, rust, log = console.log }) {
379
150
  for (const s of states.values()) {
380
151
  s.proxy?.agent.destroy()
381
152
  s.proxy?.server.closeAllConnections()
382
- await new Promise(ok => { if (s.proxy) s.proxy.server.close(ok); else ok() })
153
+ await new Promise((ok) => {
154
+ if (s.proxy) s.proxy.server.close(ok)
155
+ else ok()
156
+ })
383
157
  }
384
158
  },
385
159
  }
386
160
  }
387
161
 
388
- // ---- build --------------------------------------------------------------------------------------------------------
389
-
390
- const BUILT = ['.weft', 'sidecars'] // a build empties `.output`, and these have to run during it
391
- const SHIPPED = ['.output', 'rust']
392
- const SHIPPED_NATIVE = ['.output', 'server', 'rust'] // with the server, so a host that moves it moves these
393
-
394
- /** Release binaries into `.weft/sidecars/`, and what `weft start` needs to run them. */
395
- export async function buildSidecars({ root, contract, rust, preset, log = () => {} }) {
396
- root = resolve(root)
397
- const out = join(root, ...BUILT)
398
- const services = buildable(contract, rust)
399
- rmSync(out, { recursive: true, force: true })
400
- if (!services.length) return { services: [], diagnostics: [] }
401
- mkdirSync(out, { recursive: true })
402
- const manifest = {}
403
- const diagnostics = []
404
- for (const service of services) {
405
- const dir = crateDir(root, rust, service)
406
- if (!existsSync(dir)) {
407
- diagnostics.push({ code: 'R109', severity: 'error', file: 'weft.config.ts', span: [1, 1, 1, 1], why: `\`rust://${service}\` has no crate at \`${relative(root, dir)}\`. Create it, point \`rust.services.${service}.crate\` at it, or set \`external: true\`.` })
408
- continue
409
- }
410
- log(`building ${service}…`)
411
- const built = await cargo('build', { root, dir, service, contract, release: true })
412
- if (!built.ok || !(built.exe || built.lib)) {
413
- diagnostics.push(...built.diagnostics)
414
- continue
415
- }
416
- if (isNative(contract, service)) {
417
- const addon = `${stem(service)}.${process.platform}-${process.arch}.node`
418
- copyFileSync(built.lib, join(out, addon))
419
- manifest[service] = { kind: 'native', addon, hash: contract.services[service].hash, bytes: statSync(built.lib).size }
420
- const wasm = await checkWasm({ root, dir, service, contract, preset, rust, build: true })
421
- diagnostics.push(...wasm.diagnostics)
422
- if (wasm.lib) {
423
- copyFileSync(wasm.lib, join(out, `${stem(service)}.wasm`))
424
- Object.assign(manifest[service], { wasm: `${stem(service)}.wasm`, wasmBytes: statSync(wasm.lib).size })
425
- }
426
- continue
427
- }
428
- const bin = basename(built.exe)
429
- copyFileSync(built.exe, join(out, bin))
430
- manifest[service] = { bin, hash: contract.services[service].hash, bytes: statSync(built.exe).size }
431
- }
432
- writeFileSync(join(out, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
433
- return { services: Object.keys(manifest), diagnostics }
434
- }
435
-
436
- /** Into the output, once the server is built. */
437
- export function shipSidecars(root) {
438
- const from = join(resolve(root), ...BUILT)
439
- if (!existsSync(from)) return
440
- const manifest = JSON.parse(readFileSync(join(from, 'manifest.json'), 'utf8'))
441
- const natives = Object.entries(manifest).filter(([, m]) => m.kind === 'native')
442
- const sidecars = Object.entries(manifest).filter(([, m]) => m.kind !== 'native')
443
- const copy = (list, to, names) => {
444
- if (!list.length) return
445
- mkdirSync(to, { recursive: true })
446
- for (const [, m] of list) for (const f of names(m)) cpSync(join(from, f), join(to, f))
447
- writeFileSync(join(to, 'manifest.json'), JSON.stringify(Object.fromEntries(list), null, 2) + '\n')
448
- }
449
- copy(sidecars, join(resolve(root), ...SHIPPED), m => [m.bin])
450
- copy(natives, join(resolve(root), ...SHIPPED_NATIVE), m => [m.addon, m.wasm].filter(Boolean))
451
- }
452
-
453
- // ---- start --------------------------------------------------------------------------------------------------------
454
-
455
- /**
456
- * 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.
457
- * Each keeps its port across restarts, so the server's address stays good.
458
- * @returns {Promise<{ env: Record<string, string>, close(): void }>} `env` for the server process
459
- */
460
- export async function startSidecars({ root, built = false, log = console.log }) {
461
- const dir = join(resolve(root), ...(built ? BUILT : SHIPPED))
462
- const file = join(dir, 'manifest.json')
463
- if (!existsSync(file)) return { env: {}, close() {} }
464
- const manifest = JSON.parse(readFileSync(file, 'utf8'))
465
- const names = Object.keys(manifest).filter(s => manifest[s].kind !== 'native' && !process.env[urlVariable(s)])
466
- // While a build prerenders, the libraries aren't next to a server yet.
467
- const paths = built && Object.values(manifest).some(m => m.kind === 'native') ? { WEFT_NATIVE_DIR: dir } : {}
468
- if (!names.length) return { env: paths, close() {} }
469
- const secret = process.env.WEFT_RUST_SECRET || randomBytes(32).toString('hex')
470
- const env = { ...paths, WEFT_RUST_SECRET: secret }
471
- let closed = false
472
- const live = new Map()
473
-
474
- const supervise = service => {
475
- const { bin, hash } = manifest[service]
476
- let listen = '127.0.0.1:0', tries = 0, up
477
- const begin = async () => {
478
- up = Date.now()
479
- const p = await launch({ exe: join(dir, bin), service, hash, listen, secret, log, onExit: (code, signal) => again(`exited (${signal ?? code})`) })
480
- listen = `127.0.0.1:${p.port}` // restarts rebind what the first start got
481
- live.set(service, p)
482
- return p
483
- }
484
- const again = why => {
485
- if (closed) return
486
- // One that ran for a while starts over; one that dies at once backs off.
487
- tries = Date.now() - up > 30_000 ? 1 : tries + 1
488
- const wait = Math.min(100 * 2 ** tries, 5_000)
489
- log(`[weft] ${service} ${why}: restarting in ${wait} ms`)
490
- setTimeout(() => { if (!closed) begin().catch(e => again(e.message.split('\n')[0])) }, wait)
491
- }
492
- return begin()
493
- }
494
-
495
- for (const service of names) env[urlVariable(service)] = (await supervise(service)).url
496
-
497
- const killAll = () => {
498
- closed = true
499
- for (const p of live.values()) stop(p, 1_000)
500
- }
501
- process.once('exit', killAll)
502
- return { env, close: killAll }
503
- }
162
+ export { buildSidecars, shipSidecars, startSidecars } from './sidecar/build.mjs'
@@ -0,0 +1,50 @@
1
+ import { StartError, type Context, type Fn, type StandardSchemaV1 } from '../data'
2
+ import type { Bucket } from './types'
3
+
4
+ export function take(b: Bucket, perSecond: number, burst: number) {
5
+ const now = Date.now()
6
+ b.tokens = Math.min(burst, b.tokens + ((now - b.at) * perSecond) / 1000)
7
+ b.at = now
8
+ if (b.tokens < 1) return false
9
+ b.tokens--
10
+ return true
11
+ }
12
+
13
+ export async function check(schema: StandardSchemaV1 | undefined, value: unknown, what: string) {
14
+ if (!schema) return value
15
+ const r = await schema['~standard'].validate(value)
16
+ if (!r.issues) return r.value
17
+ throw new StartError(
18
+ 'Q002',
19
+ `${what} was refused: ${r.issues.map((i) => i.message).join('; ')}`,
20
+ 'send what the schema accepts',
21
+ {
22
+ status: 400,
23
+ issues: r.issues.map((i) => ({
24
+ message: i.message,
25
+ path: i.path?.map((p) => (typeof p === 'object' ? p.key : p)),
26
+ })),
27
+ },
28
+ )
29
+ }
30
+
31
+ type Authorize = ((args: { ctx: Context; params: unknown }) => unknown) &
32
+ Record<'read' | 'write', (args: { ctx: Context; key: unknown }) => unknown>
33
+ export const allowed = async (
34
+ fn: Fn,
35
+ ctx: Context,
36
+ params: unknown,
37
+ write?: boolean,
38
+ ): Promise<boolean> => {
39
+ const a = fn.def.authorize as Authorize
40
+ return !!(await (fn.kind === 'shared'
41
+ ? a[write ? 'write' : 'read']({ ctx, key: params })
42
+ : a({ ctx, params })))
43
+ }
44
+
45
+ export const denied = (fn: Fn) =>
46
+ new StartError(
47
+ 'R011',
48
+ `\`${fn.$id}\`: \`authorize\` refused this client`,
49
+ 'sign in, or check `authorize`',
50
+ )
@@ -0,0 +1,16 @@
1
+ import type { Hub, Ev } from '../hub'
2
+ import type { Sock, Join } from './types'
3
+ import { ASK } from './rooms'
4
+
5
+ /** Fan-out of a room's messages to one join. */
6
+ export function deliver(c: Sock, j: Join, h: Hub, ev: Ev, send: (c: Sock, out: unknown[]) => void) {
7
+ const m = (ev.p ??= JSON.parse(ev.raw)) as {
8
+ f?: string
9
+ x?: 1
10
+ t?: string
11
+ n: string
12
+ m: unknown
13
+ }
14
+ if (m.n === ASK || (m.x && m.f === c.k) || (m.t && m.t !== c.k)) return
15
+ send(c, ['m', j.n, h.cursorOf(ev.seq), m.n, m.m])
16
+ }