lecodes-cli 2.6.0 → 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 +20616 -19049
- package/package.json +15 -18
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/player.js +16 -16
- package/runtime/web-lite/lite.js +1 -1
- 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/liteRunner.ts +1 -0
- package/src/plugin/generate.ts +5 -3
package/src/dev/backendWorker.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { tmpdir } from "node:os"
|
|
|
4
4
|
import { join } from "node:path"
|
|
5
5
|
import { format } from "node:util"
|
|
6
6
|
import {
|
|
7
|
-
createAuthHost, createFilesHost, createInvoker, createPushHost, createSubscribeGuard, describeServer,
|
|
8
|
-
|
|
7
|
+
createAuthHost, createComputedHost, createFakeWatchSource, createFilesHost, createInvoker, createPushHost, createSubscribeGuard, describeServer,
|
|
8
|
+
embeddedWatchSource, isReadOp, loadServerModules, outsideRequests, runSeed, setChannelPublisher, setDbTransport, setEnv, setPresenceProvider, setServerClock,
|
|
9
|
+
type AuthHost, type ComputedHost, type EmbeddedWatchHandle, type Heic, type LoadedServer, type MarciOp, type PushRelay, type Sharp,
|
|
9
10
|
} from "lecodes-sdk/server"
|
|
10
11
|
import { loadPeer } from "../hosts/peerInstall"
|
|
11
12
|
import { ApiError, sendPush } from "../platform/api"
|
|
@@ -21,9 +22,21 @@ import type { FromWorker, LogLevel, PushSender, ToWorker } from "./localBackend"
|
|
|
21
22
|
* directory the parent names. The schema is synced on every load, more freely than the runner does
|
|
22
23
|
* (this is the developer's own machine): what the models no longer have is dropped; a change the
|
|
23
24
|
* engine can't migrate in place moves the old database aside and starts an empty one.
|
|
25
|
+
*
|
|
26
|
+
* A computed's reads are watched by the embedded engine (sdk/server `embeddedWatchSource` over the
|
|
27
|
+
* handle's `watch`, marcidb-embedded 0.18 on; an older package refuses a subscription to a computed that
|
|
28
|
+
* reads the database, with that reason), and the bundle's own `op.watch` is the same handle's. LECODES_WATCH=fake
|
|
29
|
+
* (a test's knob) puts a stand-in in the computeds' place: every write this process sends signals the
|
|
30
|
+
* watches of the model it wrote — the one process that holds the database sees every write.
|
|
31
|
+
*
|
|
32
|
+
* Presence (`watchers`, `members`) is the parent's hub's table: a read is a `presence` message, the answer
|
|
33
|
+
* a `presenceResult`; a `presenceChanged` runs the computeds that read that key again.
|
|
34
|
+
*
|
|
35
|
+
* Under `lecodes test` a case has one clock: the parent's `clock` message moves this process's `Date.now()`
|
|
36
|
+
* (sdk/server `setServerClock`) with the apps' virtual clocks; `load` carries the offset a process starts with.
|
|
24
37
|
*/
|
|
25
38
|
|
|
26
|
-
type Embedded = {
|
|
39
|
+
type Embedded = EmbeddedWatchHandle & {
|
|
27
40
|
exec(op: unknown): Promise<any>
|
|
28
41
|
batch(ops: unknown[]): Promise<any[]>
|
|
29
42
|
$sync(schema: string): Promise<null>
|
|
@@ -45,9 +58,15 @@ const DEV_CODE = "000000"
|
|
|
45
58
|
let db: Embedded | null = null
|
|
46
59
|
let invoke: ReturnType<typeof createInvoker> | null = null
|
|
47
60
|
let subscribe: ReturnType<typeof createSubscribeGuard> | null = null
|
|
61
|
+
let computed: ComputedHost | null = null
|
|
48
62
|
let authHost: AuthHost | null = null
|
|
63
|
+
const fakeWatch = process.env.LECODES_WATCH === "fake" ? createFakeWatchSource() : null
|
|
64
|
+
/** presence reads on their way to the parent's hub, by their `req` */
|
|
65
|
+
const asks = new Map<number, (sessions: number[]) => void>()
|
|
66
|
+
let nextAsk = 1
|
|
49
67
|
|
|
50
68
|
const quit = () => {
|
|
69
|
+
try { computed?.close() } catch { /* nothing to let go */ }
|
|
51
70
|
try { db?.close() } catch { /* already closed */ }
|
|
52
71
|
process.exit(0)
|
|
53
72
|
}
|
|
@@ -69,7 +88,8 @@ const openDb = async (dir: string, filesDir: string, schema: string): Promise<st
|
|
|
69
88
|
db = openDatabase(dir)
|
|
70
89
|
} catch (e) {
|
|
71
90
|
// one process holds a database: the engine's lock is the sign of a second server on this project
|
|
72
|
-
|
|
91
|
+
// ("is in use" is what the commands that run the app match on: dev/devLink.ts says who could hold it)
|
|
92
|
+
if (/Locked/i.test((e as Error).message)) throw new Error(`The project's database (${dir}) is in use — another process of this project holds it (a \`lecodes dev\`, a \`desktop run\`, a \`render\`). Stop it first.`)
|
|
73
93
|
throw e
|
|
74
94
|
}
|
|
75
95
|
try {
|
|
@@ -114,10 +134,28 @@ const pushRelay = (sender: PushSender): PushRelay => async (to, message) => {
|
|
|
114
134
|
|
|
115
135
|
const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
116
136
|
const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
|
|
117
|
-
|
|
137
|
+
// the stand-in watch source hears of every write that went through (a test's knob, above)
|
|
138
|
+
const wrote = (ops: MarciOp[]) => { for (const op of ops) if (!isReadOp(op)) fakeWatch?.fireModel(op.model) }
|
|
139
|
+
setDbTransport({
|
|
140
|
+
exec: async (op) => { const r = await (db ?? noDb()).exec(op); wrote([op]); return r },
|
|
141
|
+
batch: async (ops) => { const r = await (db ?? noDb()).batch(ops); wrote(ops); return r },
|
|
142
|
+
// the bundle's `op.watch`: the handle's poll, made outside the request that asked (its callbacks outlive it)
|
|
143
|
+
watch: (op, listener) => {
|
|
144
|
+
const handle = db ?? noDb()
|
|
145
|
+
if (typeof handle.watch !== "function") throw new Error("db: this marcidb-embedded has no watch (it came with marcidb-embedded 0.18) — update it")
|
|
146
|
+
return outsideRequests(() => handle.watch!(op, listener))
|
|
147
|
+
},
|
|
148
|
+
})
|
|
118
149
|
setChannelPublisher((ch, group, data) => post({ t: "publish", ch, group, data }))
|
|
119
|
-
//
|
|
150
|
+
// who is subscribed where is the parent's hub's: `watchers` / `members` ask it
|
|
151
|
+
setPresenceProvider((id, key) => new Promise((resolve) => {
|
|
152
|
+
const req = nextAsk++
|
|
153
|
+
asks.set(req, resolve)
|
|
154
|
+
post({ t: "presence", req, id, key })
|
|
155
|
+
}))
|
|
156
|
+
// The project's keys (its .env), before any module-level code can read one; the clock a test runs on, too
|
|
120
157
|
setEnv(m.env)
|
|
158
|
+
setServerClock(m.clock ?? 0)
|
|
121
159
|
// The bundle is a plain script (chisel esm output with only side effects): registers globalThis.__lecodesServer
|
|
122
160
|
;(0, eval)(m.code)
|
|
123
161
|
const server: LoadedServer = loadServerModules()
|
|
@@ -164,6 +202,7 @@ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
|
|
|
164
202
|
}
|
|
165
203
|
invoke = createInvoker(server, { auth, files, onError })
|
|
166
204
|
subscribe = createSubscribeGuard(server, { auth, onError })
|
|
205
|
+
computed = createComputedHost(server, { auth, onError, watch: fakeWatch ?? embeddedWatchSource(() => db) })
|
|
167
206
|
post({ t: "loaded", describe, notes })
|
|
168
207
|
}
|
|
169
208
|
|
|
@@ -177,9 +216,23 @@ process.on("message", async (m: ToWorker) => {
|
|
|
177
216
|
return post({ t: "result", req: m.req, result })
|
|
178
217
|
}
|
|
179
218
|
case "subscribe": {
|
|
180
|
-
const result = subscribe
|
|
219
|
+
const result = !subscribe || !computed ? { ok: false as const, status: 503, message: "server not loaded" }
|
|
220
|
+
: m.sub === undefined ? await subscribe(m.ch, m.args, m.ctx) : await computed.start(m.ch, m.args, m.ctx, m.sub)
|
|
181
221
|
return post({ t: "result", req: m.req, result })
|
|
182
222
|
}
|
|
223
|
+
case "unsubscribe":
|
|
224
|
+
computed?.end(m.sub)
|
|
225
|
+
return
|
|
226
|
+
case "presenceResult":
|
|
227
|
+
asks.get(m.req)?.(m.sessions)
|
|
228
|
+
asks.delete(m.req)
|
|
229
|
+
return
|
|
230
|
+
case "presenceChanged":
|
|
231
|
+
computed?.presenceChanged(m.id, m.key)
|
|
232
|
+
return
|
|
233
|
+
case "clock":
|
|
234
|
+
setServerClock(m.offsetMs)
|
|
235
|
+
return
|
|
183
236
|
case "stop":
|
|
184
237
|
return quit()
|
|
185
238
|
}
|
package/src/dev/bots.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from "node:fs"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import { compileHeadlessBundle } from "../compile/headlessBundle"
|
|
4
|
+
import { fileScenarioIO, parseNativeViews, requireRenderer, resolveSafeAreaChoice } from "../commands/render"
|
|
5
|
+
import { isServerCase, readFlowFile, slug, type ClientEnvelope, type FlowCase } from "../commands/test"
|
|
6
|
+
import { CliError, c } from "../cli"
|
|
7
|
+
import { callEndpoint } from "./backendCall"
|
|
8
|
+
import { devRunBackend, type RunningDev } from "./devLink"
|
|
9
|
+
|
|
10
|
+
/*
|
|
11
|
+
* `lecodes dev --bots <flow.json>` — the players a developer does not have at hand. The phone (any device
|
|
12
|
+
* on the dev url) is a live player; the flow's clients are more of them: headless sessions in this
|
|
13
|
+
* process, each with its own storage and session, on the SAME backend as the phone — the dev server's —
|
|
14
|
+
* driven by the case runner of `lecodes test` (lecodes-headless runCase) on REAL time:
|
|
15
|
+
*
|
|
16
|
+
* - the backend's clock is the phone's: a `clock` step is refused, a `wait` waits, and every bot's
|
|
17
|
+
* clock follows the machine's (its deadlines come when the phone's do);
|
|
18
|
+
* - an `expect` with a long `timeout` is how a bot waits for the human
|
|
19
|
+
* (`{ "clients": "*", "expect": { "text": "Фаза 1" }, "timeout": 600000 }`);
|
|
20
|
+
* - a failing step is told here (`[bots] ✗ …`, its frame under the flow's .artifacts) and the next one
|
|
21
|
+
* runs — the human is mid-game;
|
|
22
|
+
* - the flow runs once; then the bots stay in the game (subscribed, their timers alive) until Ctrl+C —
|
|
23
|
+
* unless the flow ends them: `{ "clients": "*", "close": true }`;
|
|
24
|
+
* - `--var code=4821` gives `${code}` before the flow runs (the room the human made on the phone).
|
|
25
|
+
*
|
|
26
|
+
* The bots run the app as it was compiled when they started (a save reloads the phone, not them).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export type BotsPlan = {
|
|
30
|
+
kase: FlowCase
|
|
31
|
+
/** The flow's steps, checked (lecodes-headless parseScenarioSteps, on real time). */
|
|
32
|
+
steps: import("lecodes-headless/headless").ScenarioStep[]
|
|
33
|
+
vars: Record<string, string>
|
|
34
|
+
/** Where a failing step's frame goes. */
|
|
35
|
+
artifacts: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type Bots = {
|
|
39
|
+
/** The bots leave (their apps disposed, their sockets closed); resolves once they have. */
|
|
40
|
+
stop: () => Promise<void>
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** `code=4821,name=Боб` → { code: "4821", name: "Боб" } (a repeated flag keeps the last value: commas list them). */
|
|
44
|
+
export const parseBotVars = (raw: string | undefined): Record<string, string> => {
|
|
45
|
+
const vars: Record<string, string> = {}
|
|
46
|
+
if (raw === undefined || raw === "") return vars
|
|
47
|
+
for (const part of raw.split(",")) {
|
|
48
|
+
const eq = part.indexOf("=")
|
|
49
|
+
const name = (eq < 0 ? part : part.slice(0, eq)).trim()
|
|
50
|
+
if (!name || eq < 0) throw new CliError(`--var wants name=value (several: name=value,other=value) — got "${part}".`)
|
|
51
|
+
vars[name] = part.slice(eq + 1).trim()
|
|
52
|
+
}
|
|
53
|
+
return vars
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Read and check the flow before the dev server starts: a typo is an error now, not after the first compile. */
|
|
57
|
+
export const loadBotsFlow = async (root: string, given: string, vars: Record<string, string>): Promise<BotsPlan> => {
|
|
58
|
+
const testsDir = resolve(root, "tests")
|
|
59
|
+
// a path from here, or from the project's root; a name under its tests folder
|
|
60
|
+
const abs = [resolve(given), resolve(root, given), resolve(testsDir, given), resolve(testsDir, `${given}.flow.json`)].find((p) => existsSync(p))
|
|
61
|
+
if (!abs) throw new CliError(`--bots: no such flow "${given}" (looked here and in ${testsDir}).`)
|
|
62
|
+
const kase = readFlowFile(abs, given)
|
|
63
|
+
// the names the flow says its run gives (`"vars": ["code"]`) must be given
|
|
64
|
+
const missing = (kase.vars ?? []).filter((name) => !(name in vars))
|
|
65
|
+
if (missing.length > 0) throw new CliError(`--bots ${given}: the flow takes ${missing.map((n) => `--var ${n}=…`).join(", ")} (its "vars").`)
|
|
66
|
+
const renderer = await requireRenderer()
|
|
67
|
+
let steps
|
|
68
|
+
try {
|
|
69
|
+
steps = renderer.parseScenarioSteps(kase.steps, isServerCase(kase)
|
|
70
|
+
? { server: true, vars: Object.keys(vars), realTime: true }
|
|
71
|
+
: { clients: kase.clients ? Object.keys(kase.clients) : undefined, vars: Object.keys(vars), realTime: true })
|
|
72
|
+
} catch (e) {
|
|
73
|
+
throw new CliError(`--bots ${given}: ${(e as Error).message}`)
|
|
74
|
+
}
|
|
75
|
+
return { kase, steps, vars, artifacts: resolve(dirname(abs), ".artifacts", slug(kase.name)) }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Start the bots of `plan` on the dev backend `dev` (the dev server's own url, on this machine). */
|
|
79
|
+
export const startBots = async (plan: BotsPlan, dev: RunningDev, say: (line: string) => void): Promise<Bots> => {
|
|
80
|
+
const { kase, steps } = plan
|
|
81
|
+
const tell = (line: string) => say(`${c.cyan("[bots]")} ${line}`)
|
|
82
|
+
const renderer = await requireRenderer()
|
|
83
|
+
const headless = await compileHeadlessBundle({ backend: devRunBackend(dev) })
|
|
84
|
+
for (const w of headless.warnings) tell(c.dim(w))
|
|
85
|
+
const serverUrl = headless.serverUrl
|
|
86
|
+
const names = kase.clients ? Object.keys(kase.clients) : isServerCase(kase) ? [] : ["app"]
|
|
87
|
+
tell(names.length > 0 ? `${names.join(", ")} join${names.length === 1 ? "s" : ""} the game (${steps.length} step${steps.length === 1 ? "" : "s"}, on real time)` : `${steps.length} call step${steps.length === 1 ? "" : "s"}, on real time`)
|
|
88
|
+
|
|
89
|
+
const sessionOptions = (name: string, own: ClientEnvelope = {}) => ({
|
|
90
|
+
width: own.width || kase.width || 390,
|
|
91
|
+
height: own.height || kase.height || 844,
|
|
92
|
+
settleMs: kase.settle,
|
|
93
|
+
backend: serverUrl,
|
|
94
|
+
localAssets: headless.useLocalAssets,
|
|
95
|
+
fixtures: kase.fixtures,
|
|
96
|
+
...resolveSafeAreaChoice(own.safeArea ?? kase.safeArea, own.device ?? kase.device),
|
|
97
|
+
nativeViews: parseNativeViews(undefined),
|
|
98
|
+
// the app's own errors are worth seeing; its chatter is not
|
|
99
|
+
onConsole: (level: string, text: string) => { if (level === "error" || level === "warn") tell(`${c.dim(`${name}:${level}`)} ${text}`) },
|
|
100
|
+
})
|
|
101
|
+
const clients = names.map((name) => ({
|
|
102
|
+
name,
|
|
103
|
+
start: (env: import("lecodes-headless/headless").ClientStart) => renderer.createSession(headless.js, { ...sessionOptions(name, kase.clients?.[name]), ...env }),
|
|
104
|
+
}))
|
|
105
|
+
|
|
106
|
+
let release = () => {}
|
|
107
|
+
const released = new Promise<void>((r) => { release = r })
|
|
108
|
+
const long = (i: number) => (steps[i]?.timeout ?? kase.timeout ?? 0) > 10_000
|
|
109
|
+
const done = renderer.runCase({
|
|
110
|
+
clients, single: !kase.clients, steps, io: fileScenarioIO(plan.artifacts), timeoutMs: kase.timeout,
|
|
111
|
+
realTime: true, keepGoing: true, vars: plan.vars,
|
|
112
|
+
backend: serverUrl ? { url: serverUrl, call: (id, args, device) => callEndpoint(serverUrl, id, args, device) } : undefined,
|
|
113
|
+
// a step that may wait long for the human says so when it begins
|
|
114
|
+
onStepStart: (i, desc) => { if (long(i)) tell(c.dim(`… ${desc}`)) },
|
|
115
|
+
onStep: (o) => {
|
|
116
|
+
if (o.ok) tell(`${c.green("✓")} ${o.desc}`)
|
|
117
|
+
else tell(`${c.red("✗")} ${o.desc} — ${o.error ?? "failed"}${o.index >= 0 ? c.dim(` (its frame: ${plan.artifacts})`) : ""}`)
|
|
118
|
+
},
|
|
119
|
+
linger: async (outcome, open) => {
|
|
120
|
+
const failed = outcome.steps.filter((s) => !s.ok).length
|
|
121
|
+
const ran = `the flow ran: ${outcome.steps.length - failed} of ${outcome.steps.length} steps passed`
|
|
122
|
+
tell(open.length > 0 ? `${ran} — ${open.join(", ")} stay${open.length === 1 ? "s" : ""} in the game until Ctrl+C` : `${ran}; every bot has left`)
|
|
123
|
+
await released
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
done.catch((e: unknown) => tell(`${c.red("✗")} ${(e as Error)?.message ?? e}`))
|
|
127
|
+
return {
|
|
128
|
+
stop: async () => {
|
|
129
|
+
release()
|
|
130
|
+
await done.catch(() => {})
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { hasServerFiles } from "../compile/projectCompile"
|
|
4
|
+
import { LECODES_DIR } from "../project/manifest"
|
|
5
|
+
import { CliError } from "../cli"
|
|
6
|
+
import { runBackend, type RunBackend } from "./projectBackend"
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
* ONE backend per project on this machine: while a `lecodes dev` of the project runs, it holds the
|
|
10
|
+
* project's database (`.lecodes/db` — the embedded engine locks it for one process), and every other
|
|
11
|
+
* command of the project talks to ITS backend over HTTP instead of opening the database:
|
|
12
|
+
*
|
|
13
|
+
* render (headless, --desktop, --sim), desktop run the app's stubs call the dev backend's url
|
|
14
|
+
* call the call goes there (else: the test backend)
|
|
15
|
+
* web dev, a second lecodes dev the dev server forwards its /server routes there
|
|
16
|
+
* dev --bots the bots are clients of it, as the phone is
|
|
17
|
+
*
|
|
18
|
+
* `lecodes test` never does: a test runs on its own database (`.lecodes/test-db`) and must not touch the
|
|
19
|
+
* data a developer plays with.
|
|
20
|
+
*
|
|
21
|
+
* How a running dev is found: `.lecodes/dev.json` has the project's dev token (the url's capability) and the
|
|
22
|
+
* port the dev server that runs the backend listens on (`recordDevPort`, written when it starts; 4499 when the
|
|
23
|
+
* file names none); that server answers `GET /t/<token>/server/status` — to this machine only — with the
|
|
24
|
+
* project root it serves, and a root that is not this project's is not this project's dev. No answer: the
|
|
25
|
+
* command runs the backend itself, on `.lecodes/db`, as before; and when the database is still locked then,
|
|
26
|
+
* the error says who could hold it.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const DEV_DEFAULT_PORT = 4499
|
|
30
|
+
|
|
31
|
+
/** A dev server of this project that runs its backend, answering on this machine. */
|
|
32
|
+
export type RunningDev = {
|
|
33
|
+
port: number
|
|
34
|
+
token: string
|
|
35
|
+
/** The backend's url on this machine (`http://127.0.0.1:<port>/t/<token>/server`): what the stubs call. */
|
|
36
|
+
url: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type DevFile = { token?: string, port?: number }
|
|
40
|
+
|
|
41
|
+
const devFile = (root: string) => join(root, LECODES_DIR, "dev.json")
|
|
42
|
+
|
|
43
|
+
export const readDevFile = (root: string): DevFile => {
|
|
44
|
+
try {
|
|
45
|
+
const raw = JSON.parse(readFileSync(devFile(root), "utf8")) as DevFile
|
|
46
|
+
return raw && typeof raw === "object" ? raw : {}
|
|
47
|
+
} catch {
|
|
48
|
+
return {}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The dev server that runs the project's backend listens on `port`: the other commands look for it there. */
|
|
53
|
+
export const recordDevPort = (root: string, port: number): void => {
|
|
54
|
+
const file = readDevFile(root)
|
|
55
|
+
if (file.port === port) return
|
|
56
|
+
mkdirSync(join(root, LECODES_DIR), { recursive: true })
|
|
57
|
+
writeFileSync(devFile(root), JSON.stringify({ ...file, port }, null, 2) + "\n")
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const sameDir = (a: string, b: string): boolean => {
|
|
61
|
+
const real = (p: string) => { try { return realpathSync(p) } catch { return p } }
|
|
62
|
+
return real(a) === real(b)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The running dev of this project, or null (nothing answers where dev.json says, or it serves another folder). */
|
|
66
|
+
export const findRunningDev = async (root: string, opts: { timeoutMs?: number } = {}): Promise<RunningDev | null> => {
|
|
67
|
+
const file = readDevFile(root)
|
|
68
|
+
if (!file.token || !/^[a-z0-9]{8,}$/.test(file.token)) return null
|
|
69
|
+
const port = typeof file.port === "number" && file.port > 0 ? file.port : DEV_DEFAULT_PORT
|
|
70
|
+
const url = `http://127.0.0.1:${port}/t/${file.token}/server`
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetch(`${url}/status`, { signal: AbortSignal.timeout(opts.timeoutMs ?? 1500) })
|
|
73
|
+
const body = await res.json() as { ok?: boolean, root?: unknown }
|
|
74
|
+
if (body?.ok === true && typeof body.root === "string" && sameDir(body.root, root)) return { port, token: file.token, url }
|
|
75
|
+
} catch { /* nothing there, or not a dev server */ }
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The port a command expected a running dev on (for the message of a database that is locked). */
|
|
80
|
+
export const expectedDevPort = (root: string): number => {
|
|
81
|
+
const port = readDevFile(root).port
|
|
82
|
+
return typeof port === "number" && port > 0 ? port : DEV_DEFAULT_PORT
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A run's backend that is a running `lecodes dev`'s: nothing is started — the url is the dev backend's —
|
|
87
|
+
* `reset` empties ITS database (the admin route; a run asks for it explicitly: `render --fresh`), and the
|
|
88
|
+
* clock is the devices' own: it is never moved.
|
|
89
|
+
*/
|
|
90
|
+
export const devRunBackend = (dev: RunningDev): RunBackend => ({
|
|
91
|
+
dev: { port: dev.port, url: dev.url },
|
|
92
|
+
start: async (entries) => (hasServerFiles(entries) ? dev.url : undefined),
|
|
93
|
+
reset: async () => {
|
|
94
|
+
const body = await fetch(`${dev.url}/reset`, { method: "POST" }).then((r) => r.json() as Promise<{ ok?: boolean, message?: string } | null>, (e: Error) => {
|
|
95
|
+
throw new CliError(`The running lecodes dev (port ${dev.port}) did not empty its database: ${e.message}`)
|
|
96
|
+
})
|
|
97
|
+
if (body?.ok !== true) throw new CliError(`The running lecodes dev (port ${dev.port}) did not empty its database: ${body?.message ?? "no answer"}`)
|
|
98
|
+
},
|
|
99
|
+
setClock: () => { throw new Error("the backend of a running lecodes dev keeps the devices' clock: a run on it moves in real time") },
|
|
100
|
+
close: async () => {},
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
/** What the commands say when the database is locked and no dev of the project answers. */
|
|
104
|
+
const lockedMessage = (root: string): string => {
|
|
105
|
+
const port = expectedDevPort(root)
|
|
106
|
+
return `The project's database (.lecodes/db) is held by another process, and no lecodes dev of this project answers on port ${port} (.lecodes/dev.json). ` +
|
|
107
|
+
`It is a dev backend that does not answer there (a lecodes dev of an older CLI, or one that hangs), or another run of the project (desktop run, render) — stop it and run this again.`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The backend of a command that runs the app on this machine (`render`, `desktop run`): a running
|
|
112
|
+
* `lecodes dev`'s when one answers, else one started for the run on `.lecodes/db` (runBackend) — whose
|
|
113
|
+
* locked database is a clear error, never a run without a backend.
|
|
114
|
+
*/
|
|
115
|
+
export const projectRunBackend = async (options: { root: string, command: string, log: (msg: string) => void, note?: (msg: string) => void }): Promise<RunBackend> => {
|
|
116
|
+
const dev = await findRunningDev(options.root)
|
|
117
|
+
if (dev) {
|
|
118
|
+
const b = devRunBackend(dev)
|
|
119
|
+
return {
|
|
120
|
+
...b,
|
|
121
|
+
start: async (entries, onWarning) => {
|
|
122
|
+
const url = await b.start(entries, onWarning)
|
|
123
|
+
if (url) options.note?.(`The backend: the running lecodes dev's (port ${dev.port}) — its database, its data.`)
|
|
124
|
+
return url
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const local = runBackend({ root: options.root, command: options.command, log: options.log })
|
|
129
|
+
return {
|
|
130
|
+
...local,
|
|
131
|
+
start: (entries, onWarning) => local.start(entries, onWarning).catch((e: Error) => {
|
|
132
|
+
if (/is in use/.test(e?.message ?? "")) throw new CliError(lockedMessage(options.root))
|
|
133
|
+
throw e
|
|
134
|
+
}),
|
|
135
|
+
}
|
|
136
|
+
}
|
package/src/dev/devServer.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { Manifest } from "../project/manifest"
|
|
|
7
7
|
import { resolveLocalPath } from "../project/localFiles"
|
|
8
8
|
import { collectLocalEntries } from "../compile/collectLocal"
|
|
9
9
|
import { applyLocalShaders, isShaderArtifactName, shadersCacheDir } from "../compile/shaders"
|
|
10
|
-
import { ambiguousEntryWarning, compileProject } from "../compile/projectCompile"
|
|
10
|
+
import { ambiguousEntryWarning, compileProject, requireUnambiguousEntry, type AmbiguousEntryPolicy } from "../compile/projectCompile"
|
|
11
11
|
import { applyProjectFonts } from "../compile/fonts"
|
|
12
12
|
import { applyAppAssetIcons } from "../compile/assetIcons"
|
|
13
13
|
import { renderDevScreen, renderPrelude } from "./clientTemplates"
|
|
@@ -15,7 +15,7 @@ import { serve, type Socket, type SocketHandlers } from "./serve"
|
|
|
15
15
|
import { toKey } from "../project/paths"
|
|
16
16
|
import { makeStackDemangler } from "../compile/nativeStack"
|
|
17
17
|
import type { WebRunner } from "./webRunner"
|
|
18
|
-
import { createProjectBackend } from "./projectBackend"
|
|
18
|
+
import { createProjectBackend, forwardedBackend, type ProjectBackend } from "./projectBackend"
|
|
19
19
|
import { buildHalf, halfModule, projectHalves, type ProjectHalf } from "../web/halves"
|
|
20
20
|
|
|
21
21
|
/*
|
|
@@ -46,7 +46,9 @@ import { buildHalf, halfModule, projectHalves, type ProjectHalf } from "../web/h
|
|
|
46
46
|
* first-party half of the id: its fallback)
|
|
47
47
|
* POST /t/<tok>/server/api/<id> the project's backend (its `*.server.ts` files, run on this
|
|
48
48
|
* WS /t/<tok>/server/ws machine — projectBackend.ts): the wire of sdk/src/runtime/rpc.ts,
|
|
49
|
-
* the one the runner serves on le.codes
|
|
49
|
+
* the one the runner serves on le.codes. With `backendUrl` (another dev
|
|
50
|
+
* server of the project runs its backend: devLink.ts) they are
|
|
51
|
+
* forwarded there instead, and nothing is run here
|
|
50
52
|
*
|
|
51
53
|
* Compile is eager: the command calls recompile() on every file change; a successful compile bumps
|
|
52
54
|
* `v` and broadcasts {type:"reload", v} to connected sockets (polling clients see /version move).
|
|
@@ -81,6 +83,12 @@ export type DevServerOptions = {
|
|
|
81
83
|
log?: (msg: string) => void
|
|
82
84
|
/** The folder of the backend's database (default `.lecodes/db`; the browser tests keep their own). */
|
|
83
85
|
dbDir?: string
|
|
86
|
+
/** The project's backend is another dev server's (a `lecodes dev` of it already runs it — devLink.ts
|
|
87
|
+
* `findRunningDev`): the /server routes are forwarded to this url, no backend runs here. */
|
|
88
|
+
backendUrl?: string
|
|
89
|
+
/** A guessed entry (several import-graph roots, no main.ts): `warn` (default, `dev` / `web dev`) or
|
|
90
|
+
* `error`, which fails the compile (`test --lite|--web`). */
|
|
91
|
+
ambiguousEntry?: AmbiguousEntryPolicy
|
|
84
92
|
}
|
|
85
93
|
|
|
86
94
|
export type RecompileResult =
|
|
@@ -194,7 +202,8 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
194
202
|
// assetIcon() macro: resolve from the user-level cache (~/.lecodes/cache/icons) and inline —
|
|
195
203
|
// one registry fetch per icon ever, so hot recompiles stay LAN/offline friendly.
|
|
196
204
|
const iconsHeader = await applyAppAssetIcons(entries)
|
|
197
|
-
if (
|
|
205
|
+
if (options.ambiguousEntry === "error") requireUnambiguousEntry(entries, entryOverride)
|
|
206
|
+
else if (!entryOverride) {
|
|
198
207
|
const ambiguous = ambiguousEntryWarning(entries)
|
|
199
208
|
if (ambiguous) warnings.push(ambiguous)
|
|
200
209
|
}
|
|
@@ -248,8 +257,12 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
|
|
|
248
257
|
return table
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
// ---- the project's backend (projectBackend.ts), started with the first compile that has server files
|
|
252
|
-
|
|
260
|
+
// ---- the project's backend (projectBackend.ts), started with the first compile that has server files —
|
|
261
|
+
// or another dev server's, forwarded to (`backendUrl`). Two shapes of bundle: the compile's answer goes
|
|
262
|
+
// back to the same backend's `run`.
|
|
263
|
+
const backend: ProjectBackend<any> = options.backendUrl
|
|
264
|
+
? forwardedBackend(options.backendUrl, log)
|
|
265
|
+
: createProjectBackend({ root, log, dbDir: options.dbDir, command: "lecodes dev" })
|
|
253
266
|
/** What the app's stubs call. Baked with the advertised origin; a bundle is served with the origin
|
|
254
267
|
* its request came through instead (see `rebased`). */
|
|
255
268
|
const serverUrl = `${origin}${base}/server`
|
package/src/dev/localBackend.ts
CHANGED
|
@@ -16,6 +16,12 @@ import { LECODES_DIR } from "../project/manifest"
|
|
|
16
16
|
* devices' reload channel with it, and a deploy starts from nothing. A deploy stops the running
|
|
17
17
|
* process FIRST (one process holds the database), then starts the new bundle; when that one fails
|
|
18
18
|
* to load, the bundle that ran before is started again and the failure is the deploy's error.
|
|
19
|
+
*
|
|
20
|
+
* Computed subscriptions (sdk/server `createComputedHost`) live in that process and in the hub
|
|
21
|
+
* (projectBackend.ts): every process that starts is announced (`onStart`), and the hub runs the live
|
|
22
|
+
* ones there again — a deploy, a restart after a crash or a timeout lose none. Presence (`watchers`,
|
|
23
|
+
* `members`) is the hub's table, here in the parent: the process asks it (`presence`), and the hub's word
|
|
24
|
+
* that a key's sessions changed goes to the process (`presenceChanged`).
|
|
19
25
|
*/
|
|
20
26
|
|
|
21
27
|
/** argv[2] of the CLI when it is started as the backend's process (src/index.ts). */
|
|
@@ -25,9 +31,19 @@ export type LogLevel = "log" | "info" | "warn" | "error" | "debug"
|
|
|
25
31
|
type Ctx = Omit<RequestContext, "id">
|
|
26
32
|
|
|
27
33
|
export type ToWorker =
|
|
28
|
-
|
|
34
|
+
/** `clock`: the server's clock offset, ms (`setClock` — a test's case clock; 0 = the machine's) */
|
|
35
|
+
| { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null, env: Record<string, string>, clock: number }
|
|
29
36
|
| { t: "invoke", req: number, id: string, args: unknown[], ctx: Ctx }
|
|
30
|
-
|
|
37
|
+
/** `sub`: a computed subscription (the hub's number) — the value is the result, and the process keeps it fresh */
|
|
38
|
+
| { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx, sub?: number }
|
|
39
|
+
/** a computed subscription is over (`null` = every one) */
|
|
40
|
+
| { t: "unsubscribe", sub: number | null }
|
|
41
|
+
/** the answer to the process's `presence` (its `req`): the sessions subscribed there now */
|
|
42
|
+
| { t: "presenceResult", req: number, sessions: number[] }
|
|
43
|
+
/** the sessions subscribed to (`id`, `key`) changed: the computeds that read them run again */
|
|
44
|
+
| { t: "presenceChanged", id: string, key: string }
|
|
45
|
+
/** the server's `Date.now()` reads this many ms ahead of the machine's from now on (sdk/server `setServerClock`) */
|
|
46
|
+
| { t: "clock", offsetMs: number }
|
|
31
47
|
| { t: "stop" }
|
|
32
48
|
|
|
33
49
|
export type FromWorker =
|
|
@@ -36,6 +52,8 @@ export type FromWorker =
|
|
|
36
52
|
| { t: "result", req: number, result: InvokeResult }
|
|
37
53
|
| { t: "publish", ch: string, group: ChannelGroup | null, data: unknown }
|
|
38
54
|
| { t: "log", level: LogLevel, text: string }
|
|
55
|
+
/** `watchers` / `members`: who is subscribed to export `id` under `key` now — the hub answers (`presenceResult`) */
|
|
56
|
+
| { t: "presence", req: number, id: string, key: string }
|
|
39
57
|
|
|
40
58
|
/** How `pushChannel.send` reaches the platform's sender from this machine: the linked project and the developer's
|
|
41
59
|
* own login (what `lecodes pn send` sends with). `null` = not linked or not logged in: sends answer 0, said once. */
|
|
@@ -50,6 +68,10 @@ export type LocalBackendOptions = {
|
|
|
50
68
|
filesDir?: string
|
|
51
69
|
log: (msg: string) => void
|
|
52
70
|
onPublish: (ch: string, group: ChannelGroup | null, data: unknown) => void
|
|
71
|
+
/** A process took over (a deploy, a start after a crash): the hub runs the live computed subscriptions there. */
|
|
72
|
+
onStart?: () => void
|
|
73
|
+
/** Who is subscribed to (`id`, `key`) now: the hub's answer to the process's `watchers` / `members`. */
|
|
74
|
+
presence?: (id: string, key: string) => number[]
|
|
53
75
|
invokeTimeoutMs?: number
|
|
54
76
|
loadTimeoutMs?: number
|
|
55
77
|
}
|
|
@@ -61,8 +83,17 @@ export type LocalBackend = {
|
|
|
61
83
|
* A changed key alone is a deploy too. Rejects with the reason when it does not load. */
|
|
62
84
|
deploy: (code: string, env: Record<string, string>) => Promise<DeployResult>
|
|
63
85
|
invoke: (id: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
|
|
64
|
-
/** A channel subscription: its hooks → the subscriber's group (the envelope's `result`).
|
|
65
|
-
|
|
86
|
+
/** A channel subscription: its hooks → the subscriber's group (the envelope's `result`). With `sub`, a
|
|
87
|
+
* computed subscription: its first run → its value. */
|
|
88
|
+
subscribe: (ch: string, args: unknown[], ctx: Ctx, sub?: number) => Promise<InvokeResult>
|
|
89
|
+
/** A computed subscription is over. A process that is not running holds none: nothing is started for it. */
|
|
90
|
+
unsubscribe: (sub: number) => void
|
|
91
|
+
/** The sessions subscribed to (`id`, `key`) changed: the process runs the computeds that read them again.
|
|
92
|
+
* None running, none read them: nothing is started for it. */
|
|
93
|
+
presenceChanged: (id: string, key: string) => void
|
|
94
|
+
/** The server's clock reads `offsetMs` ahead of the machine's — in the running process at once (before any
|
|
95
|
+
* call sent after this), and in every process started from now on (a crash, a deploy). A test's case clock. */
|
|
96
|
+
setClock: (offsetMs: number) => void
|
|
66
97
|
close: () => Promise<void>
|
|
67
98
|
}
|
|
68
99
|
|
|
@@ -104,6 +135,8 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
104
135
|
let chain: Promise<unknown> = Promise.resolve()
|
|
105
136
|
let nextReq = 1
|
|
106
137
|
let closed = false
|
|
138
|
+
/** the server's clock offset: what every process is started with (`setClock`) */
|
|
139
|
+
let clock = 0
|
|
107
140
|
|
|
108
141
|
// However this process ends, the server's process goes with it (it holds the database).
|
|
109
142
|
process.once("exit", () => { live?.child.kill() })
|
|
@@ -146,6 +179,9 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
146
179
|
}
|
|
147
180
|
case "publish":
|
|
148
181
|
return options.onPublish(m.ch, m.group, m.data)
|
|
182
|
+
case "presence":
|
|
183
|
+
try { child.send({ t: "presenceResult", req: m.req, sessions: options.presence?.(m.id, m.key) ?? [] } satisfies ToWorker) } catch { /* the process is going */ }
|
|
184
|
+
return
|
|
149
185
|
case "log":
|
|
150
186
|
return log(`[server:${m.level}] ${m.text}`)
|
|
151
187
|
}
|
|
@@ -160,7 +196,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
160
196
|
if (!closed) log(`[server:error] the server process exited (${status ?? "killed"}) — it starts again with the next call`)
|
|
161
197
|
}
|
|
162
198
|
})
|
|
163
|
-
child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null, env: keys } satisfies ToWorker)
|
|
199
|
+
child.send({ t: "load", code: bundle, dbDir, filesDir, root: options.root, push: options.push ?? null, env: keys, clock } satisfies ToWorker)
|
|
164
200
|
})
|
|
165
201
|
|
|
166
202
|
const stop = async (l: Live) => {
|
|
@@ -177,7 +213,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
177
213
|
const ensure = async (): Promise<Live | null> => {
|
|
178
214
|
await chain.catch(() => {})
|
|
179
215
|
if (live || code === null || closed) return live
|
|
180
|
-
starting ??= start(code, env).then((r) => { live = r.live; return r.live }).finally(() => { starting = null })
|
|
216
|
+
starting ??= start(code, env).then((r) => { live = r.live; options.onStart?.(); return r.live }).finally(() => { starting = null })
|
|
181
217
|
try { return await starting } catch (e) { log(`[server:error] ${(e as Error).message}`); return null }
|
|
182
218
|
}
|
|
183
219
|
|
|
@@ -188,7 +224,7 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
188
224
|
const req = nextReq++
|
|
189
225
|
const timer = setTimeout(() => {
|
|
190
226
|
if (!l.pending.delete(req)) return
|
|
191
|
-
const what = msg.t === "invoke" ? `endpoint ${msg.id}` : `subscription to ${msg.ch}`
|
|
227
|
+
const what = msg.t === "invoke" ? `endpoint ${msg.id}` : msg.sub !== undefined ? `computed ${msg.ch}` : `subscription to ${msg.ch}`
|
|
192
228
|
log(`[server:error] ${what} exceeded ${invokeTimeoutMs} ms — the server process is restarted`)
|
|
193
229
|
resolve({ ok: false, status: 504, message: `Endpoint timed out after ${invokeTimeoutMs} ms` })
|
|
194
230
|
if (live === l) live = null
|
|
@@ -209,10 +245,11 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
209
245
|
live = next.live
|
|
210
246
|
code = bundle
|
|
211
247
|
env = keys
|
|
248
|
+
options.onStart?.()
|
|
212
249
|
return { changed: true, describe: next.live.describe, notes: next.notes }
|
|
213
250
|
} catch (e) {
|
|
214
251
|
// the bundle that ran before answers the devices again (they still run its client)
|
|
215
|
-
if (before !== null) await start(before, beforeEnv).then((r) => { live = r.live }, () => {})
|
|
252
|
+
if (before !== null) await start(before, beforeEnv).then((r) => { live = r.live; options.onStart?.() }, () => {})
|
|
216
253
|
throw e
|
|
217
254
|
}
|
|
218
255
|
}
|
|
@@ -224,7 +261,18 @@ export const startLocalBackend = (options: LocalBackendOptions): LocalBackend =>
|
|
|
224
261
|
return run
|
|
225
262
|
},
|
|
226
263
|
invoke: (id, args, ctx) => roundTrip({ t: "invoke", req: 0, id, args, ctx }),
|
|
227
|
-
subscribe: (ch, args, ctx) => roundTrip({ t: "subscribe", req: 0, ch, args, ctx }),
|
|
264
|
+
subscribe: (ch, args, ctx, sub) => roundTrip({ t: "subscribe", req: 0, ch, args, ctx, ...(sub !== undefined ? { sub } : {}) }),
|
|
265
|
+
unsubscribe: (sub) => {
|
|
266
|
+
try { if (live?.child.connected) live.child.send({ t: "unsubscribe", sub } satisfies ToWorker) } catch { /* the process is going: it holds nothing then */ }
|
|
267
|
+
},
|
|
268
|
+
presenceChanged: (id, key) => {
|
|
269
|
+
try { if (live?.child.connected) live.child.send({ t: "presenceChanged", id, key } satisfies ToWorker) } catch { /* the process is going: a new one reads presence afresh */ }
|
|
270
|
+
},
|
|
271
|
+
// the same channel as the calls: a call sent after this reads the new clock
|
|
272
|
+
setClock: (offsetMs) => {
|
|
273
|
+
clock = offsetMs
|
|
274
|
+
try { if (live?.child.connected) live.child.send({ t: "clock", offsetMs } satisfies ToWorker) } catch { /* the process is going: the next one starts with it */ }
|
|
275
|
+
},
|
|
228
276
|
close: async () => {
|
|
229
277
|
closed = true
|
|
230
278
|
await chain.catch(() => {})
|