lecodes-cli 2.5.1 → 2.8.0
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 +20618 -19047
- package/package.json +15 -18
- package/runtime/materials/foliage-cards.mat +16 -2
- package/runtime/materials/foliage-impostor.mat +103 -26
- package/runtime/materials/foliage.mat +12 -1
- package/runtime/materials/grass.mat +6 -3
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/{materials-Cs_z1zLi.bin → materials-DE8QZ7Hn.bin} +0 -0
- package/runtime/web/assets/materials-world-CGNyYz_-.bin +0 -0
- package/runtime/web/player.js +16 -16
- package/runtime/web-lite/lite.js +2 -2
- package/src/commands/call.ts +96 -0
- package/src/commands/compile.ts +8 -4
- package/src/commands/desktop.ts +5 -4
- package/src/commands/dev.ts +42 -3
- package/src/commands/index.ts +2 -0
- package/src/commands/init/templates.ts +67 -3
- package/src/commands/plugin.ts +1 -1
- package/src/commands/render.ts +31 -22
- package/src/commands/test.ts +90 -24
- package/src/commands/web.ts +13 -4
- package/src/compile/headlessBundle.ts +5 -6
- package/src/compile/projectCompile.ts +35 -5
- package/src/declarations/lecodes-headless.d.ts +51 -2
- package/src/dev/backendCall.ts +44 -0
- package/src/dev/backendWorker.ts +60 -7
- package/src/dev/bots.ts +133 -0
- package/src/dev/devLink.ts +136 -0
- package/src/dev/devServer.ts +19 -6
- package/src/dev/localBackend.ts +57 -9
- package/src/dev/projectBackend.ts +227 -49
- package/src/hosts/desktopRenderer.ts +8 -2
- package/src/hosts/liteRunner.ts +1 -0
- package/src/plugin/generate.ts +5 -3
- package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { rmSync } from "node:fs"
|
|
2
2
|
import { join, relative } from "node:path"
|
|
3
|
-
import { createChannelHub, fileResponse, isMultipart, receiveUploads, UploadError, type Upload } from "lecodes-sdk/server"
|
|
3
|
+
import { callerKey, createChannelHub, createRateLimiter, fileResponse, isMultipart, RATE_LIMITS, receiveUploads, TOO_MANY_CALLS, UploadError, type Upload } from "lecodes-sdk/server"
|
|
4
4
|
import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
|
|
5
5
|
import { loadPeer } from "../hosts/peerInstall"
|
|
6
6
|
import { LECODES_DIR, readManifest } from "../project/manifest"
|
|
@@ -17,11 +17,22 @@ import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
|
|
|
17
17
|
* POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`);
|
|
18
18
|
* multipart when it carries files
|
|
19
19
|
* GET <base>/files/<id>/<name> a stored file (`t.file()`): `.lecodes/files`, beside the database
|
|
20
|
-
* WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
|
|
20
|
+
* WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes —
|
|
21
|
+
* channels and computed subscriptions (each process that starts runs the live
|
|
22
|
+
* ones again: `hub.rerun`), and who is subscribed where (`watchers`, `members`)
|
|
23
|
+
* GET <base>/status (from this machine only) `{ ok, root }`: whose backend this is — how the other
|
|
24
|
+
* commands find a running `lecodes dev` of their project (devLink.ts)
|
|
25
|
+
* POST <base>/reset (from this machine only) the database starts over empty (`render --fresh`)
|
|
26
|
+
*
|
|
27
|
+
* The limits are the runner's (lecodes-sdk/server rateLimit.ts): an endpoint's calls per (session, id),
|
|
28
|
+
* a session's subscriptions in the hub.
|
|
21
29
|
*
|
|
22
30
|
* Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
|
|
23
31
|
* browser tests), and `runBackend` listens on a port of its own for the commands that run a bundle
|
|
24
32
|
* on a host this CLI starts (`desktop run`, `serve`, `render --desktop`, `test --desktop`).
|
|
33
|
+
*
|
|
34
|
+
* While a `lecodes dev` of the project runs, it holds the database: the other commands talk to it
|
|
35
|
+
* instead (`devRunBackend`, the dev server's `forwardedBackend` — devLink.ts finds it).
|
|
25
36
|
*/
|
|
26
37
|
|
|
27
38
|
export type ServerBundle = Awaited<ReturnType<typeof compileServer>>
|
|
@@ -35,18 +46,20 @@ export type ProjectBackendOptions = {
|
|
|
35
46
|
command: string
|
|
36
47
|
}
|
|
37
48
|
|
|
38
|
-
export type ProjectBackend = {
|
|
49
|
+
export type ProjectBackend<B = ServerBundle> = {
|
|
39
50
|
/** The server bundle of these entries; null when the project has no server files. */
|
|
40
|
-
compile: (entries: CompileEntry[]) => Promise<
|
|
51
|
+
compile: (entries: CompileEntry[]) => Promise<B | null>
|
|
41
52
|
/** Run this bundle from now on (null = the project has no server side any more: stop). Rejects
|
|
42
53
|
* when it does not load; resolves to what to tell the developer. */
|
|
43
|
-
run: (bundle:
|
|
54
|
+
run: (bundle: B | null) => Promise<string[]>
|
|
44
55
|
/** A request under the backend's base; `route` is the path after it ("/api/<id>"). */
|
|
45
56
|
handle: (req: Request, route: string, ip: string | undefined) => Promise<Response>
|
|
46
57
|
/** The channel socket (`<base>/ws`). */
|
|
47
58
|
socket: (req: Request, ip: string | undefined) => (conn: Socket) => SocketHandlers
|
|
48
|
-
/** Start again on an EMPTY database (a test's case). */
|
|
59
|
+
/** Start again on an EMPTY database (a test's case), on the machine's clock. */
|
|
49
60
|
reset: () => Promise<void>
|
|
61
|
+
/** The server's clock reads `offsetMs` ahead of the machine's (a test's case clock; sdk/server `setServerClock`). */
|
|
62
|
+
setClock: (offsetMs: number) => void
|
|
50
63
|
/** Resolves once the database is closed. */
|
|
51
64
|
close: () => Promise<void>
|
|
52
65
|
}
|
|
@@ -59,6 +72,24 @@ const CORS = {
|
|
|
59
72
|
}
|
|
60
73
|
const bearerOf = (req: Request): string | null => /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? null
|
|
61
74
|
const BODY_LIMIT = 8 * 1024 * 1024
|
|
75
|
+
/** How long a request that came before the first compile waits for it (see `known` below). */
|
|
76
|
+
const FIRST_RUN_WAIT_MS = 30_000
|
|
77
|
+
|
|
78
|
+
/** A peer on this machine: the admin routes answer it alone, and the `x-forwarded-*` of a dev server that
|
|
79
|
+
* forwards to this one (`forwardedBackend`) are believed from it alone. */
|
|
80
|
+
export const isLoopback = (ip: string | undefined): boolean =>
|
|
81
|
+
!!ip && (ip === "::1" || ip.startsWith("127.") || ip.startsWith("::ffff:127."))
|
|
82
|
+
|
|
83
|
+
/** Where a request came in: its own origin, or — from a dev server of this machine that forwards to this
|
|
84
|
+
* one — the origin the device used there (a stored file's url must be one the device reaches). */
|
|
85
|
+
const originOf = (req: Request, ip: string | undefined): string => {
|
|
86
|
+
const host = req.headers.get("x-forwarded-host")
|
|
87
|
+
if (host && isLoopback(ip)) return `${req.headers.get("x-forwarded-proto") === "https" ? "https" : "http"}://${host}`
|
|
88
|
+
return new URL(req.url).origin
|
|
89
|
+
}
|
|
90
|
+
/** The caller's address: a forwarding dev server's word for it, from this machine only. */
|
|
91
|
+
const ipOf = (req: Request, ip: string | undefined): string | undefined =>
|
|
92
|
+
(isLoopback(ip) ? req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() : undefined) || ip
|
|
62
93
|
|
|
63
94
|
export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
|
|
64
95
|
const { root, log } = options
|
|
@@ -66,13 +97,27 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
66
97
|
const filesDir = filesDirOf(dbDir)
|
|
67
98
|
let backend: LocalBackend | null = null
|
|
68
99
|
let running: string | null = null
|
|
100
|
+
/** the server's clock offset (`setClock`): a backend started again keeps it */
|
|
101
|
+
let clock = 0
|
|
102
|
+
// Before the first `run` nobody knows yet whether the project has a backend: a device that reconnects to a
|
|
103
|
+
// dev server still compiling (a restart of `lecodes dev`) is answered once it knows — a "no server files" then
|
|
104
|
+
// would be a refusal the device keeps. Bounded: a first compile that fails answers after FIRST_RUN_WAIT_MS.
|
|
105
|
+
let ran = false
|
|
106
|
+
let firstRan!: () => void
|
|
107
|
+
const firstRun = new Promise<void>((r) => { firstRan = r })
|
|
108
|
+
const known = (): Promise<void> => ran ? Promise.resolve() : Promise.race([firstRun, new Promise<void>((r) => { setTimeout(r, FIRST_RUN_WAIT_MS).unref?.() })])
|
|
69
109
|
|
|
70
110
|
// The channel socket is the SDK's hub — the one the runner serves too.
|
|
71
111
|
const hub = createChannelHub<Socket>({
|
|
72
112
|
send: (conn, frame) => conn.send(frame),
|
|
73
|
-
subscribe: (_scope, ch, args, ctx) =>
|
|
74
|
-
|
|
75
|
-
|
|
113
|
+
subscribe: async (_scope, ch, args, ctx, sub) => {
|
|
114
|
+
await known()
|
|
115
|
+
return backend
|
|
116
|
+
? backend.subscribe(ch, args, ctx, sub)
|
|
117
|
+
: { ok: false as const, status: 404, message: "The project has no server files" }
|
|
118
|
+
},
|
|
119
|
+
unsubscribe: (_scope, sub) => backend?.unsubscribe(sub),
|
|
120
|
+
presenceChanged: (_scope, id, key) => backend?.presenceChanged(id, key),
|
|
76
121
|
})
|
|
77
122
|
const sockets = new Set<Socket>()
|
|
78
123
|
// `pushChannel.send` reaches the platform's sender through the developer's own login when the checkout is linked —
|
|
@@ -80,9 +125,59 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
80
125
|
const push = (() => {
|
|
81
126
|
try { const { apiUrl, token } = session(); return { apiUrl, token, uuid: readManifest(root).uuid } } catch { return null }
|
|
82
127
|
})()
|
|
83
|
-
const start = () =>
|
|
128
|
+
const start = () => {
|
|
129
|
+
const b = startLocalBackend({
|
|
130
|
+
root, dbDir, log, push,
|
|
131
|
+
onPublish: (ch, group, data) => hub.publish("", ch, group, data),
|
|
132
|
+
onStart: () => { void hub.rerun("") },
|
|
133
|
+
presence: (id, key) => hub.presence("", id, key),
|
|
134
|
+
})
|
|
135
|
+
if (clock !== 0) b.setClock(clock)
|
|
136
|
+
return b
|
|
137
|
+
}
|
|
84
138
|
|
|
85
139
|
const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
|
|
140
|
+
/** an endpoint's calls by one caller: the runner's limits */
|
|
141
|
+
const calls = createRateLimiter(RATE_LIMITS.call)
|
|
142
|
+
|
|
143
|
+
const reset = async () => {
|
|
144
|
+
clock = 0
|
|
145
|
+
if (!backend || running === null) return
|
|
146
|
+
await backend.close()
|
|
147
|
+
rmSync(dbDir, { recursive: true, force: true })
|
|
148
|
+
rmSync(filesDir, { recursive: true, force: true })
|
|
149
|
+
backend = start()
|
|
150
|
+
await backend.deploy(running, readAppEnv(root))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const runBundle = async (bundle: ServerBundle | null): Promise<string[]> => {
|
|
154
|
+
if (!bundle) {
|
|
155
|
+
await backend?.close()
|
|
156
|
+
backend = null
|
|
157
|
+
running = null
|
|
158
|
+
return []
|
|
159
|
+
}
|
|
160
|
+
// the embedded database is a peer, installed the first time a project defines models
|
|
161
|
+
if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
|
|
162
|
+
await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
|
|
163
|
+
// …and so is the codec of its pictures, the first time a model has a t.image() field
|
|
164
|
+
if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
|
|
165
|
+
}
|
|
166
|
+
backend ??= start()
|
|
167
|
+
// the project's keys come from its .env, read on every run: a changed key is a deploy by itself
|
|
168
|
+
const env = readAppEnv(root)
|
|
169
|
+
const deployed = await backend.deploy(bundle.code, env)
|
|
170
|
+
running = bundle.code
|
|
171
|
+
if (!deployed.changed) return []
|
|
172
|
+
const d = deployed.describe
|
|
173
|
+
const models = Object.keys(d.models).length
|
|
174
|
+
const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
|
|
175
|
+
const keys = Object.keys(env).sort()
|
|
176
|
+
// a computed is an endpoint too (its one-shot call): counted apart
|
|
177
|
+
const computeds = d.computeds.length
|
|
178
|
+
log(`server: ${count(d.endpoints.length - computeds, "endpoint")}, ${computeds} computed, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}${keys.length ? `, env ${keys.join(", ")} (.env)` : ""}`)
|
|
179
|
+
return deployed.notes.map((n) => `server: ${n}`)
|
|
180
|
+
}
|
|
86
181
|
|
|
87
182
|
return {
|
|
88
183
|
compile: async (entries) => {
|
|
@@ -91,39 +186,27 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
91
186
|
const app = readAppConfigAt(root)
|
|
92
187
|
return compileServer({ entries, root, app: app ? { name: app.name } : undefined })
|
|
93
188
|
},
|
|
94
|
-
run:
|
|
95
|
-
if (!bundle) {
|
|
96
|
-
await backend?.close()
|
|
97
|
-
backend = null
|
|
98
|
-
running = null
|
|
99
|
-
return []
|
|
100
|
-
}
|
|
101
|
-
// the embedded database is a peer, installed the first time a project defines models
|
|
102
|
-
if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
|
|
103
|
-
await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
|
|
104
|
-
// …and so is the codec of its pictures, the first time a model has a t.image() field
|
|
105
|
-
if (/\bimage\s*\(/.test(bundle.code)) await loadPeer("sharp", undefined, { for: `${options.command} (the project's t.image() fields)` })
|
|
106
|
-
}
|
|
107
|
-
backend ??= start()
|
|
108
|
-
// the project's keys come from its .env, read on every run: a changed key is a deploy by itself
|
|
109
|
-
const env = readAppEnv(root)
|
|
110
|
-
const deployed = await backend.deploy(bundle.code, env)
|
|
111
|
-
running = bundle.code
|
|
112
|
-
if (!deployed.changed) return []
|
|
113
|
-
const d = deployed.describe
|
|
114
|
-
const models = Object.keys(d.models).length
|
|
115
|
-
const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
|
|
116
|
-
const keys = Object.keys(env).sort()
|
|
117
|
-
log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}${keys.length ? `, env ${keys.join(", ")} (.env)` : ""}`)
|
|
118
|
-
return deployed.notes.map((n) => `server: ${n}`)
|
|
119
|
-
},
|
|
189
|
+
run: (bundle) => runBundle(bundle).finally(() => { ran = true; firstRan() }),
|
|
120
190
|
handle: async (req, route, ip) => {
|
|
121
191
|
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
|
|
122
192
|
if (route.startsWith("/files/") && (req.method === "GET" || req.method === "HEAD")) {
|
|
123
193
|
return fileResponse(filesDir, route.slice("/files/".length), req, { "access-control-allow-origin": "*" })
|
|
124
194
|
}
|
|
195
|
+
// the admin routes: the other commands of this machine (devLink.ts)
|
|
196
|
+
if (route === "/status" || route === "/reset") {
|
|
197
|
+
if (!isLoopback(ip)) return envelope({ ok: false, status: 403, message: "from this machine only" })
|
|
198
|
+
if (route === "/status" && req.method === "GET") return envelope({ ok: true, root, running: running !== null })
|
|
199
|
+
if (route === "/reset" && req.method === "POST") {
|
|
200
|
+
await reset()
|
|
201
|
+
log("server: the database was emptied (a run asked for a fresh one: render --fresh, call --reset)")
|
|
202
|
+
return envelope({ ok: true })
|
|
203
|
+
}
|
|
204
|
+
}
|
|
125
205
|
const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
|
|
126
206
|
if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
|
|
207
|
+
const caller = ipOf(req, ip)
|
|
208
|
+
if (!calls.take(`${callerKey(bearerOf(req), caller)}|${id}`)) return envelope(TOO_MANY_CALLS)
|
|
209
|
+
await known()
|
|
127
210
|
if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
|
|
128
211
|
let args: unknown
|
|
129
212
|
let uploads: Upload[] = []
|
|
@@ -143,25 +226,24 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
143
226
|
if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
|
|
144
227
|
// a stored file's url starts where this very request found the backend (a phone's address of this machine, a tunnel)
|
|
145
228
|
const url = new URL(req.url)
|
|
146
|
-
const filesBase =
|
|
147
|
-
return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined, uploads, filesBase }))
|
|
229
|
+
const filesBase = originOf(req, ip) + url.pathname.slice(0, url.pathname.length - route.length)
|
|
230
|
+
return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip: caller, sessionToken: bearerOf(req) ?? undefined, uploads, filesBase }))
|
|
148
231
|
},
|
|
149
232
|
socket: (req, ip) => (conn) => {
|
|
150
|
-
|
|
233
|
+
// a stored file's url, in what a computed answers: where this socket found the backend
|
|
234
|
+
const url = new URL(req.url)
|
|
235
|
+
const c = hub.connect(conn, { ip: ipOf(req, ip), session: bearerOf(req), filesBase: originOf(req, ip) + url.pathname.replace(/\/ws$/, "") })
|
|
151
236
|
sockets.add(conn)
|
|
152
237
|
return {
|
|
153
238
|
onMessage: (raw) => { void c.message(raw) },
|
|
154
239
|
onClose: () => { sockets.delete(conn); c.close() },
|
|
155
240
|
}
|
|
156
241
|
},
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
rmSync(dbDir, { recursive: true, force: true })
|
|
161
|
-
rmSync(filesDir, { recursive: true, force: true })
|
|
162
|
-
backend = start()
|
|
163
|
-
await backend.deploy(running, readAppEnv(root))
|
|
242
|
+
setClock: (offsetMs) => {
|
|
243
|
+
clock = offsetMs
|
|
244
|
+
backend?.setClock(offsetMs)
|
|
164
245
|
},
|
|
246
|
+
reset,
|
|
165
247
|
close: async () => {
|
|
166
248
|
for (const conn of [...sockets]) conn.close()
|
|
167
249
|
await backend?.close()
|
|
@@ -170,15 +252,109 @@ export const createProjectBackend = (options: ProjectBackendOptions): ProjectBac
|
|
|
170
252
|
}
|
|
171
253
|
}
|
|
172
254
|
|
|
255
|
+
// ───────────────────────── the backend another dev server of this machine runs ─────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The backend of a dev server whose project's backend ANOTHER dev server of this machine runs (a `web dev`
|
|
259
|
+
* started beside a `lecodes dev` — devLink.ts finds it): its routes are forwarded there — calls, stored files,
|
|
260
|
+
* the channel socket — with the origin and the address the device used (`x-forwarded-*`, believed from this
|
|
261
|
+
* machine only), so what the owner answers (a stored file's url) is one the device reaches. Nothing runs
|
|
262
|
+
* here: the owner compiles and runs the server files itself, on every save.
|
|
263
|
+
*/
|
|
264
|
+
export const forwardedBackend = (target: string, log: (msg: string) => void): ProjectBackend<true> => {
|
|
265
|
+
const sockets = new Set<Socket>()
|
|
266
|
+
let silent = false
|
|
267
|
+
const unreachable = (e: unknown) => {
|
|
268
|
+
if (!silent) log(`[server:error] this project's backend runs in another dev server (${target}), which does not answer: ${(e as Error)?.message ?? e} — start it again, or restart this one to run the backend here`)
|
|
269
|
+
silent = true
|
|
270
|
+
}
|
|
271
|
+
const forwarded = (req: Request, ip: string | undefined): Record<string, string> => {
|
|
272
|
+
const url = new URL(req.url)
|
|
273
|
+
return {
|
|
274
|
+
"x-forwarded-host": req.headers.get("host") ?? url.host,
|
|
275
|
+
"x-forwarded-proto": url.protocol === "https:" ? "https" : "http",
|
|
276
|
+
...(ip ? { "x-forwarded-for": ip } : {}),
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
compile: async (entries) => (hasServerFiles(entries) ? true : null),
|
|
281
|
+
run: async () => [],
|
|
282
|
+
handle: async (req, route, ip) => {
|
|
283
|
+
// the owner answers its admin routes to this machine — and so does this one, never on a device's behalf
|
|
284
|
+
if ((route === "/status" || route === "/reset") && !isLoopback(ip)) return new Response(JSON.stringify({ ok: false, status: 403, message: "from this machine only" }), { status: 200, headers: { "content-type": "application/json", ...CORS } })
|
|
285
|
+
const headers = new Headers(req.headers)
|
|
286
|
+
for (const h of ["host", "connection", "content-length", "x-forwarded-host", "x-forwarded-proto", "x-forwarded-for"]) headers.delete(h)
|
|
287
|
+
for (const [k, v] of Object.entries(forwarded(req, ip))) headers.set(k, v)
|
|
288
|
+
const bodyless = req.method === "GET" || req.method === "HEAD" || req.method === "OPTIONS"
|
|
289
|
+
try {
|
|
290
|
+
const res = await fetch(target + route + new URL(req.url).search, {
|
|
291
|
+
method: req.method, headers, redirect: "manual",
|
|
292
|
+
...(bodyless ? {} : { body: req.body, duplex: "half" }),
|
|
293
|
+
} as RequestInit)
|
|
294
|
+
silent = false
|
|
295
|
+
// the body is passed on as it came (fetch already undid any transfer coding)
|
|
296
|
+
const out = new Headers(res.headers)
|
|
297
|
+
for (const h of ["content-encoding", "content-length", "transfer-encoding", "connection"]) out.delete(h)
|
|
298
|
+
return new Response(res.body, { status: res.status, headers: out })
|
|
299
|
+
} catch (e) {
|
|
300
|
+
unreachable(e)
|
|
301
|
+
return new Response(JSON.stringify({ ok: false, status: 503, message: "The project's backend (another lecodes dev on this machine) does not answer" }), { status: 200, headers: { "content-type": "application/json", ...CORS } })
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
socket: (req, ip) => (conn) => {
|
|
305
|
+
// a client socket is Bun's or Node's (22 on): an older Node has none
|
|
306
|
+
if (typeof WebSocket === "undefined") {
|
|
307
|
+
unreachable(new Error("forwarding the channel socket needs Bun or Node 22+ — run this server's backend here (stop the other dev server) or update Node"))
|
|
308
|
+
conn.close()
|
|
309
|
+
return { onMessage: () => {}, onClose: () => {} }
|
|
310
|
+
}
|
|
311
|
+
const auth = req.headers.get("authorization")
|
|
312
|
+
// Bun's and Node's WebSocket take headers beside the protocols (not the browser's)
|
|
313
|
+
const Ws = WebSocket as unknown as new (url: string, init: { headers: Record<string, string> }) => WebSocket
|
|
314
|
+
const up = new Ws(target.replace(/^http/, "ws") + "/ws", { headers: { ...forwarded(req, ip), ...(auth ? { authorization: auth } : {}) } })
|
|
315
|
+
const queue: string[] = []
|
|
316
|
+
let open = false
|
|
317
|
+
let ended = false
|
|
318
|
+
const end = () => {
|
|
319
|
+
if (ended) return
|
|
320
|
+
ended = true
|
|
321
|
+
sockets.delete(conn)
|
|
322
|
+
try { up.close() } catch { /* not open yet */ }
|
|
323
|
+
conn.close()
|
|
324
|
+
}
|
|
325
|
+
up.addEventListener("open", () => { open = true; silent = false; for (const m of queue.splice(0)) up.send(m) })
|
|
326
|
+
up.addEventListener("message", (ev) => { conn.send(typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data as ArrayBuffer)) })
|
|
327
|
+
up.addEventListener("close", end)
|
|
328
|
+
up.addEventListener("error", (e) => { if (!open) unreachable(new Error("the socket did not open")); void e; end() })
|
|
329
|
+
sockets.add(conn)
|
|
330
|
+
return {
|
|
331
|
+
onMessage: (raw) => { if (open) up.send(raw); else queue.push(raw) },
|
|
332
|
+
onClose: () => { ended = true; sockets.delete(conn); try { up.close() } catch { /* already going */ } },
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
// the owner's database and clock: never this server's to change
|
|
336
|
+
reset: async () => {},
|
|
337
|
+
setClock: () => {},
|
|
338
|
+
close: async () => { for (const conn of [...sockets]) conn.close() },
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
173
342
|
// ───────────────────────────── a backend for one run of a bundle ─────────────────────────────
|
|
174
343
|
|
|
175
344
|
export type RunBackend = {
|
|
176
345
|
/** Called by the compile, once it has the project's entries: the backend is started when there
|
|
177
346
|
* are server files, and its url — what the app's stubs call — is the answer. */
|
|
178
347
|
start: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<string | undefined>
|
|
179
|
-
/** Start again on an empty database. */
|
|
348
|
+
/** Start again on an empty database, on the machine's clock. */
|
|
180
349
|
reset: () => Promise<void>
|
|
350
|
+
/** The server's clock reads `offsetMs` ahead of the machine's — a test's case clock (`lecodes test`: a flow's
|
|
351
|
+
* `clock` and `wait` steps move the server with the apps). */
|
|
352
|
+
setClock: (offsetMs: number) => void
|
|
181
353
|
close: () => Promise<void>
|
|
354
|
+
/** Set when this run talks to a running `lecodes dev` of the project (devLink.ts `devRunBackend`): its backend
|
|
355
|
+
* is the one the developer's devices use, on the machine's clock — a run on it keeps real time
|
|
356
|
+
* (`setClock` refuses) and never empties it unasked. */
|
|
357
|
+
dev?: { port: number, url: string }
|
|
182
358
|
}
|
|
183
359
|
|
|
184
360
|
/** A port that stays the same for a project from run to run: the app keeps its session under the
|
|
@@ -194,10 +370,11 @@ const stablePort = (root: string): number => {
|
|
|
194
370
|
* 127.0.0.1. `test`: a database of its own (`.lecodes/test-db`, emptied by `reset`) and any free
|
|
195
371
|
* port — a test never touches what `lecodes dev` and `desktop run` keep in `.lecodes/db`.
|
|
196
372
|
*/
|
|
197
|
-
export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
|
|
373
|
+
export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean, keepTestDb?: boolean }): RunBackend => {
|
|
198
374
|
const { root } = options
|
|
199
375
|
const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
|
|
200
|
-
|
|
376
|
+
// `keepTestDb`: the test database as the last run left it (`lecodes call`; `lecodes test` empties it)
|
|
377
|
+
if (options.test && !options.keepTestDb) { rmSync(dbDir, { recursive: true, force: true }); rmSync(filesDirOf(dbDir), { recursive: true, force: true }) }
|
|
201
378
|
const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
|
|
202
379
|
let server: Served | null = null
|
|
203
380
|
|
|
@@ -222,6 +399,7 @@ export const runBackend = (options: { root: string, command: string, log: (msg:
|
|
|
222
399
|
return `http://127.0.0.1:${server.port}/server`
|
|
223
400
|
},
|
|
224
401
|
reset: () => backend.reset(),
|
|
402
|
+
setClock: (offsetMs) => backend.setClock(offsetMs),
|
|
225
403
|
close: async () => {
|
|
226
404
|
server?.close()
|
|
227
405
|
server = null
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
2
|
-
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
|
|
2
|
+
import { constants as fsConstants, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
|
|
3
3
|
import { homedir, tmpdir } from "node:os"
|
|
4
4
|
import { delimiter, join, resolve } from "node:path"
|
|
5
5
|
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
@@ -573,8 +573,14 @@ const stageBundle = (js: string, resources: { name: string, absPath?: string }[]
|
|
|
573
573
|
const st = statSync(r.absPath)
|
|
574
574
|
if (!st.isFile()) continue
|
|
575
575
|
const dst = join(runDir, r.name)
|
|
576
|
+
// NEVER ONTO AN EXISTING FILE: what is already there may be a hard link to another project file, and a copy
|
|
577
|
+
// over it rewrites that file IN THE PROJECT (a case-only name clash on Windows did that to a GLB, 2026-11-04).
|
|
578
|
+
// The copy is the fallback for a link across volumes, and it refuses to replace anything.
|
|
576
579
|
try { linkSync(r.absPath, dst) }
|
|
577
|
-
catch
|
|
580
|
+
catch (e) {
|
|
581
|
+
if ((e as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`two resources stage as ${r.name} (names that differ only in case?): ${r.absPath}`)
|
|
582
|
+
copyFileSync(r.absPath, dst, fsConstants.COPYFILE_EXCL); copied++
|
|
583
|
+
}
|
|
578
584
|
files++
|
|
579
585
|
bytes += st.size
|
|
580
586
|
}
|
package/src/hosts/liteRunner.ts
CHANGED
|
@@ -150,6 +150,7 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
|
|
|
150
150
|
const server: DevServer = await startDevServer({
|
|
151
151
|
root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
|
|
152
152
|
origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry, dbDir,
|
|
153
|
+
ambiguousEntry: "error",
|
|
153
154
|
})
|
|
154
155
|
const compiled = await server.recompile()
|
|
155
156
|
if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
|
package/src/plugin/generate.ts
CHANGED
|
@@ -52,8 +52,10 @@ export const findPlugins = (path: string): PluginDir[] => {
|
|
|
52
52
|
.filter(p => statSync(p.dir).isDirectory() && isPluginDir(p.dir))
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
* (looked up from its directory upwards), else
|
|
55
|
+
/** A contract is read with the `typescript` package (5.x: 6 is ESM-only and `require` gets a stub
|
|
56
|
+
* without `createSourceFile`): the plugin's own (looked up from its directory upwards), else the
|
|
57
|
+
* one next to the CLI's install — a dependency of lecodes-cli after 2.4.9, so a bun / npm install
|
|
58
|
+
* of the CLI brings it; the standalone binaries carry none and read the plugin's. */
|
|
57
59
|
export const loadTypescript = (dir: string): typeof tsModule => {
|
|
58
60
|
for (const from of [join(dir, "package.json"), import.meta.url]) {
|
|
59
61
|
try {
|
|
@@ -62,7 +64,7 @@ export const loadTypescript = (dir: string): typeof tsModule => {
|
|
|
62
64
|
if (ts?.createSourceFile) return ts
|
|
63
65
|
} catch { /* the next place */ }
|
|
64
66
|
}
|
|
65
|
-
throw new CliError(`Reading a contract needs
|
|
67
|
+
throw new CliError(`Reading a contract needs the typescript package 5.x — next to the CLI (bun add -g "typescript@^5.8.3" for a bun-installed lecodes-cli) or in the plugin's repo (npm install --save-dev "typescript@^5.8.3"); TypeScript 6 is not read`)
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export type Generated = { file: string, text: string }
|
|
Binary file
|