lecodes-cli 2.0.3 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2580 -872
- package/package.json +16 -11
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
- package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
- package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
- package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
- package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
- package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
- package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
- package/runtime/web/player.js +17 -17
- package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
- package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web-lite/assets/canvas-WxiusXCB.js +1 -0
- package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
- package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
- package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
- package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
- package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web-lite/assets/pager-Db5BY7FB.js +1 -0
- package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
- package/runtime/web-lite/assets/roboto-cyrillic-DVBNKCCH.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-cyrillic-ext-1gPz9dK9.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-DVLYEgdx.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-ext-D98D44EL.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-cAJwcdBQ.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-ext-t610Sqxq.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-rest-C7JkyNHT.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-vietnamese-w-p-nKZZ.woff2 +0 -0
- package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
- package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
- package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
- package/runtime/web-lite/assets/vlist-DrV2Q7P5.js +1 -0
- package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web-lite/features.json +134 -0
- package/runtime/web-lite/lite.js +90 -13
- package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
- package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
- package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
- package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
- package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
- package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
- package/runtime/web-lite/plugins.json +8 -0
- package/src/commands/compile.ts +7 -1
- package/src/commands/desktop.ts +7 -4
- package/src/commands/dev.ts +4 -14
- package/src/commands/render.ts +6 -2
- package/src/commands/test.ts +25 -9
- package/src/commands/update/index.ts +5 -4
- package/src/compile/projectCompile.ts +47 -1
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/backendWorker.ts +138 -0
- package/src/dev/devServer.ts +119 -115
- package/src/dev/localBackend.ts +214 -0
- package/src/dev/projectBackend.ts +199 -0
- package/src/dev/serve.ts +164 -0
- package/src/hosts/archives.ts +41 -7
- package/src/hosts/cmgenTool.ts +6 -9
- package/src/hosts/desktopRenderer.ts +6 -5
- package/src/hosts/liteRunner.ts +52 -9
- package/src/hosts/matcTool.ts +5 -22
- package/src/hosts/peerInstall.ts +7 -7
- package/src/hosts/peers.ts +1 -0
- package/src/hosts/releases.ts +6 -0
- package/src/index.ts +5 -1
- package/src/plugin/generate.ts +10 -6
- package/src/web/build.ts +45 -5
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
- package/runtime/web-lite/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/runtime/web-lite/assets/creatorTween-PF8B6T8V.wasm +0 -0
package/src/dev/devServer.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
128
|
+
const fileResponse = (req: Request, abs: string, type: string): Response => {
|
|
107
129
|
const size = statSync(abs).size
|
|
108
|
-
const headers = { "content-type": type,
|
|
109
|
-
const
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
189
|
+
const onWarning = (w: string) => { warnings.push(w) }
|
|
190
|
+
const serverBundle = await backend.compile(entries, onWarning)
|
|
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<
|
|
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
|
|
238
|
-
const transport =
|
|
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
|
|
246
|
-
if (rel.split("/").some((seg) => seg === ".." || seg === "" || seg === ".")) return
|
|
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
|
|
275
|
+
return text(404, "text/plain", "Not found")
|
|
252
276
|
}
|
|
253
|
-
|
|
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:
|
|
258
|
-
const
|
|
259
|
-
const
|
|
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")
|
|
263
|
-
if (!path.startsWith(base + "/")) return
|
|
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
|
|
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
|
|
277
|
-
|
|
278
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
300
|
-
res.end()
|
|
301
|
-
return
|
|
318
|
+
return redirect(`${base}/lite/?${url.searchParams.toString()}${url.hash}`)
|
|
302
319
|
}
|
|
303
|
-
return
|
|
320
|
+
return built(lite, rel === "/" ? "index.html" : rel.slice(1))
|
|
304
321
|
}
|
|
305
322
|
if (route === "/version") {
|
|
306
|
-
return
|
|
323
|
+
return json(200, { v })
|
|
307
324
|
}
|
|
308
325
|
if (route === "/log" && req.method === "POST") {
|
|
309
|
-
printDeviceMessage(await readBody(req))
|
|
310
|
-
return
|
|
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
|
|
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
|
|
336
|
+
return text(404, "text/plain", "Not found")
|
|
320
337
|
}
|
|
321
|
-
|
|
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
|
|
345
|
+
return text(400, "text/plain", "Bad path")
|
|
331
346
|
}
|
|
332
347
|
const abs = resolveLocalPath(root, key)
|
|
333
|
-
if (!abs || !statSync(abs).isFile()) return
|
|
334
|
-
return
|
|
348
|
+
if (!abs || !statSync(abs).isFile()) return text(404, "text/plain", "Not found")
|
|
349
|
+
return fileResponse(req, abs, contentType(key))
|
|
335
350
|
}
|
|
336
|
-
return
|
|
351
|
+
return text(404, "text/plain", "Not found")
|
|
337
352
|
}
|
|
338
353
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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(
|
|
360
|
+
try { m = JSON.parse(raw) } catch { /* not ours */ }
|
|
356
361
|
if (m.type === "hello") {
|
|
357
|
-
//
|
|
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(
|
|
365
|
+
printDeviceMessage(raw)
|
|
363
366
|
}
|
|
364
367
|
},
|
|
365
|
-
onClose: () => {
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
})
|
|
368
|
+
onClose: () => { clients.delete(conn) },
|
|
369
|
+
}
|
|
370
|
+
}
|
|
369
371
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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:
|
|
388
|
+
port: server.port,
|
|
387
389
|
v: () => v,
|
|
388
390
|
clientCount: () => clients.size,
|
|
389
391
|
recompile,
|
|
390
|
-
|
|
391
|
-
|
|
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
|
+
}
|