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.
Files changed (36) hide show
  1. package/dist/index.js +20618 -19047
  2. package/package.json +15 -18
  3. package/runtime/materials/foliage-cards.mat +16 -2
  4. package/runtime/materials/foliage-impostor.mat +103 -26
  5. package/runtime/materials/foliage.mat +12 -1
  6. package/runtime/materials/grass.mat +6 -3
  7. package/runtime/sdk-types.json +1 -1
  8. package/runtime/web/VERSION.json +3 -3
  9. package/runtime/web/assets/{materials-Cs_z1zLi.bin → materials-DE8QZ7Hn.bin} +0 -0
  10. package/runtime/web/assets/materials-world-CGNyYz_-.bin +0 -0
  11. package/runtime/web/player.js +16 -16
  12. package/runtime/web-lite/lite.js +2 -2
  13. package/src/commands/call.ts +96 -0
  14. package/src/commands/compile.ts +8 -4
  15. package/src/commands/desktop.ts +5 -4
  16. package/src/commands/dev.ts +42 -3
  17. package/src/commands/index.ts +2 -0
  18. package/src/commands/init/templates.ts +67 -3
  19. package/src/commands/plugin.ts +1 -1
  20. package/src/commands/render.ts +31 -22
  21. package/src/commands/test.ts +90 -24
  22. package/src/commands/web.ts +13 -4
  23. package/src/compile/headlessBundle.ts +5 -6
  24. package/src/compile/projectCompile.ts +35 -5
  25. package/src/declarations/lecodes-headless.d.ts +51 -2
  26. package/src/dev/backendCall.ts +44 -0
  27. package/src/dev/backendWorker.ts +60 -7
  28. package/src/dev/bots.ts +133 -0
  29. package/src/dev/devLink.ts +136 -0
  30. package/src/dev/devServer.ts +19 -6
  31. package/src/dev/localBackend.ts +57 -9
  32. package/src/dev/projectBackend.ts +227 -49
  33. package/src/hosts/desktopRenderer.ts +8 -2
  34. package/src/hosts/liteRunner.ts +1 -0
  35. package/src/plugin/generate.ts +5 -3
  36. package/runtime/web/assets/materials-world-BtoAfznA.bin +0 -0
@@ -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
+ }
@@ -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 (!entryOverride) {
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
- const backend = createProjectBackend({ root, log, dbDir: options.dbDir, command: "lecodes dev" })
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`
@@ -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
- | { t: "load", code: string, dbDir: string, filesDir: string, root: string, push: PushSender | null, env: Record<string, string> }
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
- | { t: "subscribe", req: number, ch: string, args: unknown[], ctx: Ctx }
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
- subscribe: (ch: string, args: unknown[], ctx: Ctx) => Promise<InvokeResult>
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(() => {})