lecodes-cli 2.0.4 → 2.0.6

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 (74) hide show
  1. package/dist/index.js +3506 -1819
  2. package/package.json +20 -11
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
  6. package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
  7. package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
  8. package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
  9. package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
  10. package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
  11. package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
  12. package/runtime/web/player.js +18 -18
  13. package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  14. package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
  15. package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
  16. package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  17. package/runtime/web-lite/assets/{canvas-C6G_Pb2Q.js → canvas-WxiusXCB.js} +1 -1
  18. package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
  19. package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
  20. package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
  21. package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
  22. package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  23. package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
  24. package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  25. package/runtime/web-lite/assets/{pager-O712dZv9.js → pager-Db5BY7FB.js} +1 -1
  26. package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
  27. package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
  28. package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
  29. package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
  30. package/runtime/web-lite/assets/{vlist-gEB8Sn0S.js → vlist-DrV2Q7P5.js} +1 -1
  31. package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
  32. package/runtime/web-lite/features.json +87 -4
  33. package/runtime/web-lite/lite.js +39 -10
  34. package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
  35. package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
  36. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
  37. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
  38. package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
  39. package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
  40. package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
  41. package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
  42. package/runtime/web-lite/plugins.json +8 -0
  43. package/src/commands/compile.ts +7 -1
  44. package/src/commands/desktop.ts +7 -4
  45. package/src/commands/dev.ts +4 -14
  46. package/src/commands/render.ts +6 -2
  47. package/src/commands/test.ts +25 -9
  48. package/src/commands/update/index.ts +12 -7
  49. package/src/commands/update/types.ts +1 -1
  50. package/src/compile/projectCompile.ts +48 -1
  51. package/src/dev/androidDev.ts +1 -1
  52. package/src/dev/backendWorker.ts +138 -0
  53. package/src/dev/devServer.ts +119 -115
  54. package/src/dev/localBackend.ts +214 -0
  55. package/src/dev/projectBackend.ts +199 -0
  56. package/src/dev/serve.ts +164 -0
  57. package/src/hosts/cmgenTool.ts +6 -9
  58. package/src/hosts/desktopRenderer.ts +6 -5
  59. package/src/hosts/liteRunner.ts +52 -9
  60. package/src/hosts/matcTool.ts +5 -22
  61. package/src/hosts/packageManager.ts +45 -0
  62. package/src/hosts/peerInstall.ts +8 -13
  63. package/src/hosts/peers.ts +2 -0
  64. package/src/hosts/releases.ts +6 -0
  65. package/src/index.ts +5 -1
  66. package/src/plugin/generate.ts +10 -6
  67. package/src/web/build.ts +20 -3
  68. package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
  69. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  70. package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
  71. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
  72. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  73. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
  74. package/runtime/web-lite/assets/tween-t_XDy3Nd.js +0 -1
@@ -1,6 +1,6 @@
1
- import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"
2
1
  import { createReadStream, statSync } from "node:fs"
3
2
  import { join } from "node:path"
3
+ import { Readable } from "node:stream"
4
4
  import type { Manifest } from "../project/manifest"
5
5
  import { resolveLocalPath } from "../project/localFiles"
6
6
  import { collectLocalEntries } from "../compile/collectLocal"
@@ -9,13 +9,16 @@ import { ambiguousEntryWarning, compileProject } from "../compile/projectCompile
9
9
  import { applyProjectFonts } from "../compile/fonts"
10
10
  import { applyAppAssetIcons } from "../compile/assetIcons"
11
11
  import { renderDevScreen, renderPrelude } from "./clientTemplates"
12
- import { upgradeWebSocket, type WsConnection } from "./wsServer"
12
+ import { serve, type Socket, type SocketHandlers } from "./serve"
13
13
  import { toKey } from "../project/paths"
14
14
  import { makeStackDemangler } from "../compile/nativeStack"
15
15
  import type { WebRunner } from "./webRunner"
16
+ import { createProjectBackend } from "./projectBackend"
16
17
 
17
18
  /*
18
- * The `lecodes dev` LAN server: serves the project to devices and hot-reloads it on change.
19
+ * The `lecodes dev` LAN server: serves the project to devices and hot-reloads it on change. It
20
+ * listens through ./serve.ts — requests and websockets in the web-standard shape, the same on Node
21
+ * and on Bun.
19
22
  *
20
23
  * All routes live under a capability-token base path (/t/<token>):
21
24
  * GET /t/<tok>/dev.js the bootstrap bundle the QR points at (placeholder screen + prelude)
@@ -33,10 +36,17 @@ import type { WebRunner } from "./webRunner"
33
36
  * polling) and the log backchannel. A phone is sent here at once
34
37
  * GET /t/<tok>/lite/[file] the page of Web Lite (lecodes-web-lite's dist), opened as
35
38
  * lite/?dev=.. — the same bundle.js?t=web, the same reload and logs
39
+ * POST /t/<tok>/server/api/<id> the project's backend (its `*.server.ts` files, run on this
40
+ * WS /t/<tok>/server/ws machine — projectBackend.ts): the wire of sdk/src/runtime/rpc.ts,
41
+ * the one the runner serves on le.codes
36
42
  *
37
43
  * Compile is eager: the command calls recompile() on every file change; a successful compile bumps
38
44
  * `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
39
45
  * A failed compile keeps the last good bundle — devices stay on it until the next good compile.
46
+ *
47
+ * A project with server files is compiled twice — its server bundle and the app with the backend's
48
+ * url in its stubs — and the backend takes the new bundle BEFORE devices are told to reload. A
49
+ * server bundle that does not load fails the compile like a code error.
40
50
  */
41
51
 
42
52
  export type DevServerOptions = {
@@ -57,6 +67,8 @@ export type DevServerOptions = {
57
67
  lite?: string | null
58
68
  /** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
59
69
  log?: (msg: string) => void
70
+ /** The folder of the backend's database (default `.lecodes/db`; the browser tests keep their own). */
71
+ dbDir?: string
60
72
  }
61
73
 
62
74
  export type RecompileResult =
@@ -76,7 +88,10 @@ export type DevServer = {
76
88
  v: () => number
77
89
  clientCount: () => number
78
90
  recompile: () => Promise<RecompileResult>
79
- close: () => void
91
+ /** The project's backend starts again on an empty database (a test's case). */
92
+ resetBackend: () => Promise<void>
93
+ /** Resolves once the project's backend (if one runs) has closed its database. */
94
+ close: () => Promise<void>
80
95
  }
81
96
 
82
97
  const MIME: Record<string, string> = {
@@ -95,47 +110,43 @@ export const contentType = (path: string): string => {
95
110
  return (i >= 0 && MIME[path.slice(i + 1).toLowerCase()]) || "application/octet-stream"
96
111
  }
97
112
 
98
- const sendText = (res: ServerResponse, status: number, type: string, body: string) => {
99
- res.writeHead(status, { "content-type": type, "cache-control": "no-store" })
100
- res.end(body)
101
- }
113
+ const NO_STORE = { "cache-control": "no-store" }
114
+
115
+ const text = (status: number, type: string, body: string, headers: Record<string, string> = {}) =>
116
+ new Response(body, { status, headers: { "content-type": type, ...NO_STORE, ...headers } })
117
+
118
+ const json = (status: number, body: unknown, headers: Record<string, string> = {}) =>
119
+ text(status, "application/json", JSON.stringify(body), headers)
120
+
121
+ /** A file on disk as a response body (a range of it when `start` / `end` are given). */
122
+ const fileBody = (abs: string, range?: { start: number, end: number }) =>
123
+ Readable.toWeb(createReadStream(abs, range)) as unknown as ReadableStream
102
124
 
103
125
  /** A working-tree file with byte-range support. AVPlayer (the iOS host's video / audio players)
104
126
  * opens an HTTP source with `Range: bytes=0-1` and fails the item with -11850 "The server is not
105
127
  * correctly configured" when the answer is a plain 200, so an asset route must answer ranges. */
106
- const sendFile = (req: IncomingMessage, res: ServerResponse, abs: string, type: string) => {
128
+ const fileResponse = (req: Request, abs: string, type: string): Response => {
107
129
  const size = statSync(abs).size
108
- const headers = { "content-type": type, "cache-control": "no-store", "accept-ranges": "bytes" }
109
- const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "")
130
+ const headers = { "content-type": type, ...NO_STORE, "accept-ranges": "bytes" }
131
+ const head = req.method === "HEAD"
132
+ const m = /^bytes=(\d*)-(\d*)$/.exec(req.headers.get("range") ?? "")
110
133
  if (m && (m[1] || m[2])) {
111
134
  // bytes=a-b, bytes=a- (to the end), bytes=-n (the last n bytes)
112
135
  const start = m[1] ? Number(m[1]) : Math.max(0, size - Number(m[2]))
113
136
  const end = m[1] && m[2] ? Math.min(Number(m[2]), size - 1) : size - 1
114
- if (start >= size || start > end) {
115
- res.writeHead(416, { ...headers, "content-range": `bytes */${size}` })
116
- res.end()
117
- return
118
- }
119
- res.writeHead(206, { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": end - start + 1 })
120
- if (req.method === "HEAD") return void res.end()
121
- createReadStream(abs, { start, end }).pipe(res)
122
- return
137
+ if (start >= size || start > end) return new Response(null, { status: 416, headers: { ...headers, "content-range": `bytes */${size}` } })
138
+ return new Response(head ? null : fileBody(abs, { start, end }), {
139
+ status: 206, headers: { ...headers, "content-range": `bytes ${start}-${end}/${size}`, "content-length": String(end - start + 1) },
140
+ })
123
141
  }
124
- res.writeHead(200, { ...headers, "content-length": size })
125
- if (req.method === "HEAD") return void res.end()
126
- createReadStream(abs).pipe(res)
142
+ return new Response(head ? null : fileBody(abs), { status: 200, headers: { ...headers, "content-length": String(size) } })
127
143
  }
128
144
 
129
- const sendJson = (res: ServerResponse, status: number, body: unknown) =>
130
- sendText(res, status, "application/json", JSON.stringify(body))
131
-
132
- const readBody = (req: IncomingMessage): Promise<string> =>
133
- new Promise((resolve) => {
134
- let body = ""
135
- req.on("data", (c: Buffer) => { if (body.length < 256 * 1024) body += c.toString("utf8") })
136
- req.on("end", () => resolve(body))
137
- req.on("error", () => resolve(body))
138
- })
145
+ /** A request's body as text; null when it is larger than `limit`. */
146
+ const readBody = async (req: Request, limit = 256 * 1024): Promise<string | null> => {
147
+ const body = await req.text().catch(() => "")
148
+ return body.length > limit ? null : body
149
+ }
139
150
 
140
151
  export const startDevServer = async (options: DevServerOptions): Promise<DevServer> => {
141
152
  const { root, manifest, name, origin, token, entryOverride } = options
@@ -175,7 +186,11 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
175
186
  const ambiguous = ambiguousEntryWarning(entries)
176
187
  if (ambiguous) warnings.push(ambiguous)
177
188
  }
178
- const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning: (w) => warnings.push(w) })
189
+ const onWarning = (w: string) => { warnings.push(w) }
190
+ const serverBundle = await backend.compile(entries)
191
+ const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined })
192
+ // the backend takes the new bundle once the app compiled, and before devices are told to reload
193
+ warnings.push(...await backend.run(serverBundle))
179
194
  const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs)
180
195
  if (changed) {
181
196
  bundleJs = js
@@ -188,6 +203,12 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
188
203
  }
189
204
  }
190
205
 
206
+ // ---- the project's backend (projectBackend.ts), started with the first compile that has server files
207
+ const backend = createProjectBackend({ root, log, dbDir: options.dbDir, command: "lecodes dev" })
208
+ /** What the app's stubs call. Baked with the advertised origin; a bundle is served with the origin
209
+ * its request came through instead (see `rebased`). */
210
+ const serverUrl = `${origin}${base}/server`
211
+
191
212
  // Serialized: a recompile requested mid-compile runs after it (compileOnce never rejects).
192
213
  const recompile = (): Promise<RecompileResult> => {
193
214
  const run = compileChain.then(compileOnce)
@@ -205,7 +226,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
205
226
  })
206
227
 
207
228
  // ---- websocket clients -------------------------------------------------------------------------
208
- const clients = new Set<WsConnection>()
229
+ const clients = new Set<Socket>()
209
230
  const broadcast = (text: string) => {
210
231
  for (const client of clients) client.send(text)
211
232
  }
@@ -234,162 +255,145 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
234
255
  }
235
256
  // The prelude talks back to the origin THIS request came through (the Host header): a phone on
236
257
  // `adb reverse` reached us as 127.0.0.1:<port> and can't see the LAN address the QR carries.
237
- const withPrelude = (js: string, url: URL, v: number, reqOrigin: string, forced?: "poll") => {
238
- const transport = forced ?? transportOf(url)
258
+ const withPrelude = (js: string, url: URL, v: number, reqOrigin: string) => {
259
+ const transport = transportOf(url)
239
260
  return transport === "web" ? js : js + "\n" + renderPrelude({ origin: reqOrigin, base, transport, v })
240
261
  }
262
+ // The backend's url likewise: the app calls it through the origin the bundle was fetched from.
263
+ const rebased = (js: string, reqOrigin: string) =>
264
+ reqOrigin === origin ? js : js.split(serverUrl).join(`${reqOrigin}${base}/server`)
241
265
  const web = options.web ?? null
242
266
  const lite = options.lite ?? null
243
267
  /** A file of a built folder; the entry files revalidate — a CLI update swaps them under the same
244
268
  * url — while what is under assets/ carries its hash in its name. */
245
- const sendBuilt = (res: ServerResponse, dir: string, rel: string) => {
246
- if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return sendText(res, 400, "text/plain", "Bad path")
269
+ const built = (dir: string, rel: string): Response => {
270
+ if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return text(400, "text/plain", "Bad path")
247
271
  const abs = join(dir, ...rel.split("/"))
248
272
  try {
249
273
  if (!statSync(abs).isFile()) throw new Error("not a file")
250
274
  } catch {
251
- return sendText(res, 404, "text/plain", "Not found")
275
+ return text(404, "text/plain", "Not found")
252
276
  }
253
- res.writeHead(200, { "content-type": contentType(rel), "cache-control": rel.startsWith("assets/") ? "max-age=31536000, immutable" : "no-store" })
254
- createReadStream(abs).pipe(res)
277
+ return new Response(fileBody(abs), { headers: { "content-type": contentType(rel), "cache-control": rel.startsWith("assets/") ? "max-age=31536000, immutable" : "no-store" } })
255
278
  }
279
+ const redirect = (to: string) => new Response(null, { status: 302, headers: { location: to } })
256
280
 
257
- const handle = async (req: IncomingMessage, res: ServerResponse) => {
258
- const reqOrigin = req.headers.host ? `http://${req.headers.host}` : origin
259
- const url = new URL(req.url ?? "/", reqOrigin)
281
+ const handle = async (req: Request, ip: string | undefined): Promise<Response> => {
282
+ const host = req.headers.get("host")
283
+ const reqOrigin = host ? `http://${host}` : origin
284
+ const url = new URL(req.url)
260
285
  const path = url.pathname
261
286
  // Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
262
- if (path === "/favicon.ico") { res.writeHead(204); res.end(); return }
263
- if (!path.startsWith(base + "/")) return sendText(res, 404, "text/plain", "Not found")
287
+ if (path === "/favicon.ico") return new Response(null, { status: 204 })
288
+ if (!path.startsWith(base + "/")) return text(404, "text/plain", "Not found")
264
289
  const route = path.slice(base.length)
265
290
 
266
291
  if (route === "/dev.js") {
267
- return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, reqOrigin))
268
- }
269
- // The poll transport as a PATH: the Android app's initial.js only fetches a launch url that ends
270
- // in ".js" (anything else goes to le.codes/api/code), so `?t=poll` can't reach it. Same bundle,
271
- // prelude polls instead of the websocket — what `dev --android` opens under Bun (no ws there).
272
- if (route === "/dev-poll.js") {
273
- return sendText(res, 200, "text/javascript", withPrelude(devJs, url, -1, reqOrigin, "poll"))
292
+ return text(200, "text/javascript", withPrelude(devJs, url, -1, reqOrigin))
274
293
  }
275
294
  if (route === "/bundle.js") {
276
- if (bundleJs === null) return sendText(res, 503, "text/plain", "No successful compile yet — check the terminal.")
277
- res.setHeader("x-lecodes-v", String(v)) // the browser runner learns the version it runs
278
- return sendText(res, 200, "text/javascript", withPrelude(bundleJs, url, v, reqOrigin))
295
+ if (bundleJs === null) return text(503, "text/plain", "No successful compile yet — check the terminal.")
296
+ // x-lecodes-v: the browser runner learns the version it runs
297
+ return text(200, "text/javascript", withPrelude(rebased(bundleJs, reqOrigin), url, v, reqOrigin), { "x-lecodes-v": String(v) })
279
298
  }
299
+ if (route.startsWith("/server/")) return backend.handle(req, route.slice("/server".length), ip)
280
300
  // The browser: built folders with relative asset paths, so each needs its trailing slash. The
281
301
  // player is told the server it runs the project of (`dev`: this one, two folders up).
282
302
  if (route === "/web" || route.startsWith("/web/")) {
283
- if (!web) return sendText(res, 404, "text/plain", "No web player vendored with this CLI (lecodes-web-player's dist).")
284
- const redirect = (to: string) => { res.writeHead(302, { location: to }); res.end() }
303
+ if (!web) return text(404, "text/plain", "No web player vendored with this CLI (lecodes-web-player's dist).")
285
304
  const query = (extra?: [string, string]) => { if (extra) url.searchParams.set(...extra); const q = url.searchParams.toString(); return q ? `?${q}` : "" }
286
305
  const rel = route.slice("/web".length)
287
306
  if (rel === "/player" || (rel === "/player/" && !url.searchParams.has("dev"))) return redirect(`${base}/web/player/${query(["dev", "../.."])}`)
288
- if (rel.startsWith("/player/")) return sendBuilt(res, web.player, rel === "/player/" ? "index.html" : rel.slice("/player/".length))
307
+ if (rel.startsWith("/player/")) return built(web.player, rel === "/player/" ? "index.html" : rel.slice("/player/".length))
289
308
  // no page around the player in this CLI: the player alone
290
309
  if (!web.page) return redirect(`${base}/web/player/${query(["dev", "../.."])}`)
291
310
  if (rel === "") return redirect(`${base}/web/${query()}`)
292
- return sendBuilt(res, web.page, rel === "/" ? "index.html" : rel.slice(1))
311
+ return built(web.page, rel === "/" ? "index.html" : rel.slice(1))
293
312
  }
294
313
  if (route === "/lite" || route.startsWith("/lite/")) {
295
- if (!lite) return sendText(res, 404, "text/plain", "No Web Lite page with this CLI (lecodes-web-lite's dist).")
314
+ if (!lite) return text(404, "text/plain", "No Web Lite page with this CLI (lecodes-web-lite's dist).")
296
315
  const rel = route.slice("/lite".length)
297
316
  if (rel === "" || (rel === "/" && !url.searchParams.has("dev"))) {
298
317
  url.searchParams.set("dev", "..")
299
- res.writeHead(302, { location: `${base}/lite/?${url.searchParams.toString()}${url.hash}` })
300
- res.end()
301
- return
318
+ return redirect(`${base}/lite/?${url.searchParams.toString()}${url.hash}`)
302
319
  }
303
- return sendBuilt(res, lite, rel === "/" ? "index.html" : rel.slice(1))
320
+ return built(lite, rel === "/" ? "index.html" : rel.slice(1))
304
321
  }
305
322
  if (route === "/version") {
306
- return sendJson(res, 200, { v })
323
+ return json(200, { v })
307
324
  }
308
325
  if (route === "/log" && req.method === "POST") {
309
- printDeviceMessage(await readBody(req))
310
- return sendJson(res, 200, { ok: true })
326
+ printDeviceMessage(await readBody(req) ?? "")
327
+ return json(200, { ok: true })
311
328
  }
312
329
  if (route.startsWith("/shaders/")) {
313
330
  const name = route.slice("/shaders/".length)
314
- if (!isShaderArtifactName(name)) return sendText(res, 400, "text/plain", "Bad path")
331
+ if (!isShaderArtifactName(name)) return text(400, "text/plain", "Bad path")
315
332
  const abs = join(shadersCacheDir(root), name)
316
333
  try {
317
334
  if (!statSync(abs).isFile()) throw new Error("not a file")
318
335
  } catch {
319
- return sendText(res, 404, "text/plain", "Not found")
336
+ return text(404, "text/plain", "Not found")
320
337
  }
321
- res.writeHead(200, { "content-type": "application/octet-stream", "cache-control": "no-store" })
322
- createReadStream(abs).pipe(res)
323
- return
338
+ return new Response(fileBody(abs), { headers: { "content-type": "application/octet-stream", ...NO_STORE } })
324
339
  }
325
340
  if (route.startsWith("/assets/")) {
326
341
  let key: string
327
342
  try {
328
343
  key = toKey(decodeURIComponent(route.slice("/assets".length)))
329
344
  } catch {
330
- return sendText(res, 400, "text/plain", "Bad path")
345
+ return text(400, "text/plain", "Bad path")
331
346
  }
332
347
  const abs = resolveLocalPath(root, key)
333
- if (!abs || !statSync(abs).isFile()) return sendText(res, 404, "text/plain", "Not found")
334
- return sendFile(req, res, abs, contentType(key))
348
+ if (!abs || !statSync(abs).isFile()) return text(404, "text/plain", "Not found")
349
+ return fileResponse(req, abs, contentType(key))
335
350
  }
336
- return sendText(res, 404, "text/plain", "Not found")
351
+ return text(404, "text/plain", "Not found")
337
352
  }
338
353
 
339
- const server: Server = createServer((req, res) => {
340
- handle(req, res).catch((e) => {
341
- log(`request failed: ${e instanceof Error ? e.message : String(e)}`)
342
- if (!res.headersSent) sendText(res, 500, "text/plain", "Internal error")
343
- })
344
- })
345
-
346
- server.on("upgrade", (req, socket) => {
347
- const url = new URL(req.url ?? "/", origin)
348
- if (url.pathname !== base + "/ws") {
349
- socket.destroy()
350
- return
351
- }
352
- const conn = upgradeWebSocket(req, socket, {
353
- onMessage: (text) => {
354
+ // The reload channel: a (re)connecting world says what it runs, then sends its logs and errors.
355
+ const reloadSocket = (conn: Socket): SocketHandlers => {
356
+ clients.add(conn)
357
+ return {
358
+ onMessage: (raw) => {
354
359
  let m: { type?: string, v?: number } = {}
355
- try { m = JSON.parse(text) } catch { /* not ours */ }
360
+ try { m = JSON.parse(raw) } catch { /* not ours */ }
356
361
  if (m.type === "hello") {
357
- // A (re)connecting world announces what it runs; nudge it forward if it's stale.
358
- if (typeof m.v === "number" && m.v !== v && bundleJs !== null) {
359
- conn?.send(JSON.stringify({ type: "reload", v }))
360
- }
362
+ // nudge it forward if it's stale
363
+ if (typeof m.v === "number" && m.v !== v && bundleJs !== null) conn.send(JSON.stringify({ type: "reload", v }))
361
364
  } else {
362
- printDeviceMessage(text)
365
+ printDeviceMessage(raw)
363
366
  }
364
367
  },
365
- onClose: () => { if (conn) clients.delete(conn) },
366
- })
367
- if (conn) clients.add(conn)
368
- })
368
+ onClose: () => { clients.delete(conn) },
369
+ }
370
+ }
369
371
 
370
- await new Promise<void>((resolve, reject) => {
371
- server.once("error", reject)
372
- server.listen(options.port, options.bind ?? "0.0.0.0", () => {
373
- server.removeListener("error", reject)
374
- resolve()
375
- })
372
+ const server = await serve({
373
+ port: options.port,
374
+ hostname: options.bind,
375
+ fetch: (req, peer) => handle(req, peer.ip),
376
+ websocket: (req, peer) => {
377
+ const path = new URL(req.url).pathname
378
+ return path === base + "/ws" ? reloadSocket : path === base + "/server/ws" ? backend.socket(req, peer.ip) : null
379
+ },
380
+ onError: (e) => log(`request failed: ${e instanceof Error ? e.message : String(e)}`),
376
381
  })
377
382
 
378
- const address = server.address()
379
- const boundPort = typeof address === "object" && address !== null ? address.port : options.port
380
-
381
383
  return {
382
384
  url: `${origin}${base}/dev.js`,
383
385
  webUrl: web ? `${origin}${base}/web/` : null,
384
386
  liteUrl: lite ? `${origin}${base}/lite/` : null,
385
387
  base,
386
- port: boundPort,
388
+ port: server.port,
387
389
  v: () => v,
388
390
  clientCount: () => clients.size,
389
391
  recompile,
390
- close: () => {
391
- for (const client of clients) client.close()
392
+ resetBackend: () => backend.reset(),
393
+ close: async () => {
394
+ for (const client of [...clients]) client.close()
392
395
  server.close()
396
+ await backend.close()
393
397
  },
394
398
  }
395
399
  }
@@ -0,0 +1,214 @@
1
+ import { spawn, type ChildProcess } from "node:child_process"
2
+ import { basename, dirname, join } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import type { ChannelGroup, InvokeResult, RequestContext, ServerDescription } from "lecodes-sdk/server"
5
+ import { LECODES_DIR } from "../project/manifest"
6
+
7
+ /*
8
+ * The app backend on this machine: the project's server bundle (its `*.server.ts` files) run here,
9
+ * with its database in `.lecodes/db` — what the runner (tools/runner) is on le.codes, for one
10
+ * project and one developer. projectBackend.ts puts it on the wire the SDK's client transport
11
+ * speaks (sdk/src/runtime/rpc.ts): under the dev server's `/t/<token>/server`, or on a port of its
12
+ * own for a host the CLI starts.
13
+ *
14
+ * The bundle runs in a process of its own (backendWorker.ts — this CLI started again with
15
+ * BACKEND_WORKER_ARG): server code that hangs or crashes takes neither the dev server nor the
16
+ * devices' reload channel with it, and a deploy starts from nothing. A deploy stops the running
17
+ * process FIRST (one process holds the database), then starts the new bundle; when that one fails
18
+ * to load, the bundle that ran before is started again and the failure is the deploy's error.
19
+ */
20
+
21
+ /** argv[2] of the CLI when it is started as the backend's process (src/index.ts). */
22
+ export const BACKEND_WORKER_ARG = "__backend-worker"
23
+
24
+ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
25
+ type Ctx = Omit<RequestContext, "id">
26
+
27
+ export type ToWorker =
28
+ | { t: "load", code: string, dbDir: string }
29
+ | { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
30
+ | { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
31
+ | { t: "stop" }
32
+
33
+ export type FromWorker =
34
+ | { t: "loaded", describe: ServerDescription, notes: string[] }
35
+ | { t: "loadError", message: string }
36
+ | { t: "result", req: number, result: InvokeResult }
37
+ | { t: "publish", ch: string, group: ChannelGroup | null, data: unknown }
38
+ | { t: "log", level: LogLevel, text: string }
39
+
40
+ export type LocalBackendOptions = {
41
+ root: string
42
+ /** The database's folder (default `.lecodes/db`). */
43
+ dbDir?: string
44
+ log: (msg: string) => void
45
+ onPublish: (ch: string, group: ChannelGroup | null, data: unknown) => void
46
+ invokeTimeoutMs?: number
47
+ loadTimeoutMs?: number
48
+ }
49
+
50
+ export type DeployResult = { changed: boolean, describe: ServerDescription, notes: string[] }
51
+
52
+ export type LocalBackend = {
53
+ /** Run this bundle from now on. Rejects with the reason when it does not load. */
54
+ deploy: (code: string) => Promise<DeployResult>
55
+ invoke: (id: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
56
+ /** A channel subscription: its hooks → the subscriber's group (the envelope's `result`). */
57
+ subscribe: (ch: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
58
+ close: () => Promise<void>
59
+ }
60
+
61
+ type Live = {
62
+ child: ChildProcess
63
+ describe: ServerDescription
64
+ pending: Map<number, { resolve: (r: InvokeResult) => void, timer: ReturnType<typeof setTimeout> }>
65
+ gone: Promise<void>
66
+ }
67
+
68
+ /** How this CLI starts itself again: its entry under node / bun — this file IS the entry in the
69
+ * built CLI (one bundle), and sits beside it in the sources — or the standalone binary alone. */
70
+ const selfCommand = (): string[] => {
71
+ const here = fileURLToPath(import.meta.url)
72
+ if (/[\\/](\$bunfs|~BUN)[\\/]/.test(here)) return [process.execPath]
73
+ const entry = /^localBackend\.[cm]?[jt]s$/.test(basename(here)) ? join(dirname(here), "..", "index.ts") : here
74
+ return [process.execPath, ...process.execArgv, entry]
75
+ }
76
+
77
+ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend => {
78
+ const { log } = options
79
+ const invokeTimeoutMs = options.invokeTimeoutMs ?? 30_000
80
+ const loadTimeoutMs = options.loadTimeoutMs ?? 15_000
81
+ const dbDir = options.dbDir ?? join(options.root, LECODES_DIR, "db")
82
+
83
+ let live: Live | null = null
84
+ /** The bundle that is deployed — what a crashed or timed-out process is started from again. */
85
+ let code: string | null = null
86
+ let starting: Promise<Live> | null = null
87
+ let chain: Promise<unknown> = Promise.resolve()
88
+ let nextReq = 1
89
+ let closed = false
90
+
91
+ // However this process ends, the server's process goes with it (it holds the database).
92
+ process.once("exit", () => { live?.child.kill() })
93
+
94
+ const failPending = (l: Live, message: string) => {
95
+ for (const [, p] of l.pending) { clearTimeout(p.timer); p.resolve({ ok: false, status: 503, message }) }
96
+ l.pending.clear()
97
+ }
98
+
99
+ const start = (bundle: string): Promise<{ live: Live, notes: string[] }> => new Promise((resolve, reject) => {
100
+ const [exe, ...args] = selfCommand()
101
+ const child = spawn(exe!, [...args, BACKEND_WORKER_ARG], { cwd: options.root, stdio: ["ignore", "inherit", "inherit", "ipc"] })
102
+ let exited!: () => void
103
+ const l: Live = { child, describe: null as unknown as ServerDescription, pending: new Map(), gone: new Promise<void>((r) => { exited = r }) }
104
+ let settled = false
105
+ const fail = (message: string) => {
106
+ if (settled) return
107
+ settled = true; clearTimeout(timer)
108
+ child.kill()
109
+ reject(new Error(message))
110
+ }
111
+ const timer = setTimeout(() => fail(`the server bundle did not load within ${loadTimeoutMs} ms`), loadTimeoutMs)
112
+ child.on("message", (m: FromWorker) => {
113
+ switch (m.t) {
114
+ case "loaded":
115
+ if (settled) return
116
+ settled = true; clearTimeout(timer)
117
+ l.describe = m.describe
118
+ return resolve({ live: l, notes: m.notes })
119
+ case "loadError":
120
+ return fail(m.message)
121
+ case "result": {
122
+ const p = l.pending.get(m.req)
123
+ if (!p) return
124
+ l.pending.delete(m.req)
125
+ clearTimeout(p.timer)
126
+ return p.resolve(m.result)
127
+ }
128
+ case "publish":
129
+ return options.onPublish(m.ch, m.group, m.data)
130
+ case "log":
131
+ return log(`[server:${m.level}] ${m.text}`)
132
+ }
133
+ })
134
+ child.on("error", (e) => fail(`the server process did not start: ${e.message}`))
135
+ child.on("exit", (status) => {
136
+ exited()
137
+ fail(`the server process exited (${status ?? "killed"}) while loading`)
138
+ failPending(l, "server restarting")
139
+ if (live === l) {
140
+ live = null
141
+ if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
142
+ }
143
+ })
144
+ child.send({ t: "load", code: bundle, dbDir } satisfies ToWorker)
145
+ })
146
+
147
+ const stop = async (l: Live) => {
148
+ if (live === l) live = null
149
+ failPending(l, "server restarting")
150
+ if (l.child.exitCode !== null || l.child.signalCode !== null) return
151
+ try { l.child.send({ t: "stop" } satisfies ToWorker) } catch { /* channel already closed */ }
152
+ const forced = setTimeout(() => l.child.kill(), 2000)
153
+ await l.gone
154
+ clearTimeout(forced)
155
+ }
156
+
157
+ /** The running process, started from the deployed bundle when it is gone (a crash, a timeout). */
158
+ const ensure = async (): Promise<Live | null> => {
159
+ await chain.catch(() => {})
160
+ if (live || code === null || closed) return live
161
+ starting ??= start(code).then((r) => { live = r.live; return r.live }).finally(() => { starting = null })
162
+ try { return await starting } catch (e) { log(`[server:error] ${(e as Error).message}`); return null }
163
+ }
164
+
165
+ const roundTrip = async (msg: Extract<ToWorker, { t: "invoke" | "subscribe" }>): Promise<InvokeResult> => {
166
+ const l = await ensure()
167
+ if (!l) return { ok: false, status: 503, message: "The project's server is not running — see the terminal of the command that started it" }
168
+ return new Promise<InvokeResult>((resolve) => {
169
+ const req = nextReq++
170
+ const timer = setTimeout(() => {
171
+ if (!l.pending.delete(req)) return
172
+ const what = msg.t === "invoke" ? `endpoint ${msg.id}` : `subscription to ${msg.ch}`
173
+ log(`[server:error] ${what} exceeded ${invokeTimeoutMs} ms — the server process is restarted`)
174
+ resolve({ ok: false, status: 504, message: `Endpoint timed out after ${invokeTimeoutMs} ms` })
175
+ if (live === l) live = null
176
+ failPending(l, "server restarting")
177
+ l.child.kill()
178
+ }, invokeTimeoutMs)
179
+ l.pending.set(req, { resolve, timer })
180
+ l.child.send({ ...msg, req })
181
+ })
182
+ }
183
+
184
+ const deployNow = async (bundle: string): Promise<DeployResult> => {
185
+ if (bundle === code && live) return { changed: false, describe: live.describe, notes: [] }
186
+ const before = code
187
+ if (live) await stop(live)
188
+ try {
189
+ const next = await start(bundle)
190
+ live = next.live
191
+ code = bundle
192
+ return { changed: true, describe: next.live.describe, notes: next.notes }
193
+ } catch (e) {
194
+ // the bundle that ran before answers the devices again (they still run its client)
195
+ if (before !== null) await start(before).then((r) => { live = r.live }, () => {})
196
+ throw e
197
+ }
198
+ }
199
+
200
+ return {
201
+ deploy: (bundle) => {
202
+ const run = chain.catch(() => {}).then(() => deployNow(bundle))
203
+ chain = run
204
+ return run
205
+ },
206
+ invoke: (id, args, ctx) => roundTrip({ t: "invoke", req: 0, id, args, ctx }),
207
+ subscribe: (ch, args, ctx) => roundTrip({ t: "subscribe", req: 0, ch, args, ctx }),
208
+ close: async () => {
209
+ closed = true
210
+ await chain.catch(() => {})
211
+ if (live) await stop(live)
212
+ },
213
+ }
214
+ }