lecodes-cli 2.6.0 → 2.8.1
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/materials/foliage-cards.mat +2 -0
- package/runtime/materials/foliage-impostor.mat +2 -0
- package/runtime/materials/foliage.mat +2 -0
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/materials-world-B9HLXjfb.bin +0 -0
- package/runtime/web/player.js +16 -16
- package/runtime/web-lite/lite.js +4 -4
- 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/runtime/web/assets/materials-world-CGNyYz_-.bin +0 -0
package/src/commands/test.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer
|
|
|
5
5
|
import { desktopRenderFlags, fileScenarioIO, parseNativeViews, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
|
|
6
6
|
import { buildBundle } from "./compile"
|
|
7
7
|
import { runBackend } from "../dev/projectBackend"
|
|
8
|
+
import { callEndpoint } from "../dev/backendCall"
|
|
8
9
|
import { resolvePicks } from "../hosts/flowPick"
|
|
9
10
|
import { findProjectRoot } from "../project/manifest"
|
|
10
11
|
import { runDesktopRender } from "../hosts/desktopRenderer"
|
|
@@ -33,6 +34,13 @@ import { bundleFlags, bundleOptions } from "./shared"
|
|
|
33
34
|
* A failing step stops its flow and dumps the failing frame (JSON + PNG when @napi-rs/canvas is
|
|
34
35
|
* installed) under tests/.artifacts/<flow>/ so a red test explains itself without a rerun.
|
|
35
36
|
*
|
|
37
|
+
* A file case may run several clients (`"clients": { "ann": {}, "bob": { "height": 640 } }` in its
|
|
38
|
+
* envelope): the app started once per client, each a headless session of its own in this process (its
|
|
39
|
+
* own storage, clock reader, fetch and socket), all against the case's backend; a step names its clients
|
|
40
|
+
* and the case keeps one clock (runCase in lecodes-headless — the backend's clock follows it over the dev
|
|
41
|
+
* backend's IPC, `setClock`). A case with only `call` / `clock` steps and no `clients` starts no app: a
|
|
42
|
+
* server test. Artifacts of a multi-client case go per client: tests/.artifacts/<flow>/<client>/.
|
|
43
|
+
*
|
|
36
44
|
* `--desktop` runs the file scenarios through the NATIVE host (desktopScript.ts — targets by name or
|
|
37
45
|
* coordinates, expect.log + expect.paint checked, the semantic expects reported skipped). The
|
|
38
46
|
* design-derived flows need `expect.screen` and `fill`, which the host can't answer, so they stay
|
|
@@ -46,7 +54,7 @@ import { bundleFlags, bundleOptions } from "./shared"
|
|
|
46
54
|
type DesignEdge = { from: string, to: string, label?: string, activator?: string }
|
|
47
55
|
type DesignMeta = { screens?: Record<string, unknown>, edges?: DesignEdge[] }
|
|
48
56
|
|
|
49
|
-
type FlowCase = {
|
|
57
|
+
export type FlowCase = {
|
|
50
58
|
name: string
|
|
51
59
|
kind: "file" | "flow"
|
|
52
60
|
steps: unknown
|
|
@@ -59,6 +67,37 @@ type FlowCase = {
|
|
|
59
67
|
settle?: number
|
|
60
68
|
device?: string
|
|
61
69
|
safeArea?: string
|
|
70
|
+
/** Several clients: name → its own viewport / device (file scenarios only). */
|
|
71
|
+
clients?: Record<string, ClientEnvelope>
|
|
72
|
+
/** How long an expect waits by default in this case, ms. */
|
|
73
|
+
timeout?: number
|
|
74
|
+
/** The `${name}`s the run must give (`"vars": ["code"]`): a flow for `lecodes dev --bots … --var code=4821`,
|
|
75
|
+
* played beside a human — `lecodes test` does not run it. */
|
|
76
|
+
vars?: string[]
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What a client of a multi-client case may set for itself (else the case's own). */
|
|
80
|
+
export type ClientEnvelope = { width?: number, height?: number, device?: string, safeArea?: string }
|
|
81
|
+
const CLIENT_KEYS = new Set(["width", "height", "device", "safeArea"])
|
|
82
|
+
|
|
83
|
+
/** The step kinds of the headless case runner (runCase) the native and browser runners don't take yet. */
|
|
84
|
+
const CASE_STEP_KEYS = ["call", "clock", "save", "offline", "restart", "close"]
|
|
85
|
+
|
|
86
|
+
/** A case with no `clients` whose steps are all `call` / `clock` (or `wait`): a server test — no app is started. */
|
|
87
|
+
export const isServerCase = (kase: FlowCase): boolean =>
|
|
88
|
+
kase.kind === "file" && (kase.clients ? Object.keys(kase.clients).length === 0
|
|
89
|
+
: Array.isArray(kase.steps) && kase.steps.length > 0 && kase.steps.every((s) => typeof s === "object" && s !== null && ((s as Record<string, unknown>).call !== undefined || (s as Record<string, unknown>).clock !== undefined)))
|
|
90
|
+
|
|
91
|
+
/** The envelope's `clients`: names (they are folder names too) → per-client overrides. */
|
|
92
|
+
const readClients = (raw: unknown, displayName: string): Record<string, ClientEnvelope> | undefined => {
|
|
93
|
+
if (raw === undefined) return undefined
|
|
94
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) throw new CliError(`"${displayName}": "clients" maps names to their settings: { "ann": {}, "bob": { "height": 640 } }.`)
|
|
95
|
+
for (const [name, o] of Object.entries(raw)) {
|
|
96
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new CliError(`"${displayName}": client name "${name}" — letters, digits, _ and - only (it names the client's artifact folder).`)
|
|
97
|
+
if (typeof o !== "object" || o === null || Array.isArray(o)) throw new CliError(`"${displayName}": clients.${name} must be an object ({} for the case's own settings).`)
|
|
98
|
+
for (const k of Object.keys(o)) if (!CLIENT_KEYS.has(k)) throw new CliError(`"${displayName}": clients.${name}.${k} — a client sets ${[...CLIENT_KEYS].join(", ")}.`)
|
|
99
|
+
}
|
|
100
|
+
return raw as Record<string, ClientEnvelope>
|
|
62
101
|
}
|
|
63
102
|
|
|
64
103
|
type SkippedFlow = { name: string, reason: string }
|
|
@@ -124,10 +163,10 @@ export const deriveDesignFlows = (meta: DesignMeta, entryScreen: string): { flow
|
|
|
124
163
|
return { flows, skipped }
|
|
125
164
|
}
|
|
126
165
|
|
|
127
|
-
const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
|
|
166
|
+
export const slug = (s: string): string => s.replace(/[^a-zA-Z0-9-_]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "case"
|
|
128
167
|
|
|
129
168
|
/** Read one tests/*.flow.json (envelope + steps, or a bare step array). */
|
|
130
|
-
const readFlowFile = (abs: string, displayName: string): FlowCase => {
|
|
169
|
+
export const readFlowFile = (abs: string, displayName: string): FlowCase => {
|
|
131
170
|
let raw: unknown
|
|
132
171
|
try {
|
|
133
172
|
raw = JSON.parse(readFileSync(abs, "utf8"))
|
|
@@ -138,11 +177,15 @@ const readFlowFile = (abs: string, displayName: string): FlowCase => {
|
|
|
138
177
|
if (typeof raw !== "object" || raw === null || (raw as { steps?: unknown }).steps === undefined) {
|
|
139
178
|
throw new CliError(`"${displayName}" must be a JSON object with "steps" (or a bare step array).`)
|
|
140
179
|
}
|
|
141
|
-
const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, steps: unknown }
|
|
180
|
+
const file = raw as { name?: string, width?: number, height?: number, settle?: number, device?: string, safeArea?: string, fixtures?: Record<string, unknown> | string, clients?: unknown, timeout?: unknown, vars?: unknown, steps: unknown }
|
|
181
|
+
if (file.timeout !== undefined && (typeof file.timeout !== "number" || file.timeout < 0)) throw new CliError(`"${displayName}": "timeout" is how long an expect waits, ms.`)
|
|
182
|
+
if (file.vars !== undefined && !(Array.isArray(file.vars) && file.vars.every((v) => typeof v === "string" && v !== ""))) throw new CliError(`"${displayName}": "vars" lists the names a run gives (--var name=value): ["code"].`)
|
|
142
183
|
return {
|
|
143
184
|
name: file.name ?? displayName, kind: "file", steps: resolvePicks(file.steps, dirname(abs)),
|
|
144
185
|
fixtures: resolveFixtures(file.fixtures, dirname(abs)),
|
|
145
186
|
width: file.width, height: file.height, settle: file.settle, device: file.device, safeArea: file.safeArea,
|
|
187
|
+
clients: readClients(file.clients, displayName), timeout: file.timeout as number | undefined,
|
|
188
|
+
vars: file.vars as string[] | undefined,
|
|
146
189
|
}
|
|
147
190
|
}
|
|
148
191
|
|
|
@@ -171,7 +214,7 @@ export default defineCommand({
|
|
|
171
214
|
name: "test",
|
|
172
215
|
summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
|
|
173
216
|
usage: "[name…]",
|
|
174
|
-
description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/. A project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case; a step waits for the calls it started.\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
|
|
217
|
+
description: "Headless flow tests: tests/*.flow.json scenarios, plus flows derived from the design graph (design/meta.json activator edges — checks the built app against the designed navigation; needs named elements + screen roots). Failing steps dump the frame (JSON + PNG) under tests/.artifacts/<flow>/. A project with *.server.ts files gets its backend run for the tests, on a database of its own (.lecodes/test-db) that is emptied before every case; a step waits for the calls it started.\n\nA flow file may run several clients against that backend (\"clients\": { \"ann\": {}, \"bob\": {} } in its envelope; every step then names its \"client\" / \"clients\"), call server functions directly (\"call\" + \"as\"), keep values for later steps (\"save\", then ${name}), jump the case's one clock — the apps' and the server's (\"clock\": \"+92s\"), take a client offline, restart or close it (\"offline\", \"restart\", \"close\"). A file whose envelope lists \"vars\" (the ${name}s a run gives: a flow for `lecodes dev --bots … --var`) is not run. An expect waits until it holds (\"timeout\", default 5000 ms). A file of only call / clock steps starts no app: a server test. These run headless only.\n\nThe backend of a test is always its own, never a running `lecodes dev`'s: a test does not see or change the data the developer plays with (the other commands talk to a running dev — `lecodes dev --bots` runs a flow there).\n\nDesign flows read tests/fixtures.json automatically (canned fetch responses) so backend-gated transitions run headless; file scenarios declare their own via the \"fixtures\" envelope. [name…] runs only those files (a path, or a name under the tests folder).",
|
|
175
218
|
flags: testFlags,
|
|
176
219
|
examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
|
|
177
220
|
run: ({ args, flags }) => runTests(args, flags),
|
|
@@ -196,7 +239,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
196
239
|
// The project's backend, when it has one: on a database of its own, emptied before every case.
|
|
197
240
|
// (a browser run starts its own, in its session)
|
|
198
241
|
const backend = lite ? null : runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } })
|
|
199
|
-
const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend
|
|
242
|
+
const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend!, ambiguousEntry: "error" }) : null
|
|
200
243
|
const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"], backend: backend! })
|
|
201
244
|
const warnings = headless?.warnings ?? []
|
|
202
245
|
const useLocalAssets = headless?.useLocalAssets ?? false
|
|
@@ -275,6 +318,12 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
275
318
|
throw new CliError(`--flows needs a design graph (${designMetaPath} not found).`)
|
|
276
319
|
}
|
|
277
320
|
|
|
321
|
+
// a flow that takes values from its run (`"vars"`) is played beside a human (lecodes dev --bots), not a test
|
|
322
|
+
for (const k of cases.filter((k) => k.kind === "file" && k.vars?.length)) {
|
|
323
|
+
note(`${k.name}: a flow for lecodes dev --bots (it takes --var ${k.vars!.join(", ")}) — not run by lecodes test.`)
|
|
324
|
+
}
|
|
325
|
+
for (let i = cases.length - 1; i >= 0; i--) if (cases[i].kind === "file" && cases[i].vars?.length) cases.splice(i, 1)
|
|
326
|
+
|
|
278
327
|
if (cases.length === 0) {
|
|
279
328
|
for (const w of warnings) warnErr(w)
|
|
280
329
|
throw new CliError(`No tests found. Write tests/*.flow.json scenarios, or draw activator edges in the design board (design/meta.json).`)
|
|
@@ -282,7 +331,9 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
282
331
|
|
|
283
332
|
// Hard timeout across the whole run (each case is bounded by settles, but a pathological app can
|
|
284
333
|
// still spin) — generous per-case allowance, overridable.
|
|
285
|
-
|
|
334
|
+
// (an expect waits in real time, on every client of its case: a case's share grows with both)
|
|
335
|
+
const caseShare = (k: FlowCase) => 10000 + (Array.isArray(k.steps) ? k.steps.length : 0) * 1000 * Math.max(1, Object.keys(k.clients ?? {}).length)
|
|
336
|
+
const timeoutMs = flags.timeout || (desktop ? 60000 + cases.length * 60000 : 20000 + cases.reduce((n, k) => n + caseShare(k), 0))
|
|
286
337
|
const killer = setTimeout(() => {
|
|
287
338
|
warnErr(`Test run timed out after ${timeoutMs}ms (raise --timeout or lower --settle).`)
|
|
288
339
|
process.exit(1)
|
|
@@ -382,6 +433,14 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
382
433
|
let result: CaseResult
|
|
383
434
|
// every case starts on an empty database, like it starts in a fresh host
|
|
384
435
|
if (backend && results.length > 0) await backend.reset()
|
|
436
|
+
const caseOnly = (desktop || lite) && Array.isArray(kase.steps) ? CASE_STEP_KEYS.find((k) => (kase.steps as Record<string, unknown>[]).some((st) => st !== null && typeof st === "object" && k in st)) : undefined
|
|
437
|
+
if ((desktop || lite) && (kase.clients || isServerCase(kase) || caseOnly)) {
|
|
438
|
+
const what = kase.clients ? "several clients" : isServerCase(kase) ? "a server test" : `"${caseOnly}" steps`
|
|
439
|
+
result = { name: kase.name, kind: kase.kind, ok: false, steps: [], error: `${what} — headless only for now: run it with \`lecodes test\` (no --desktop / --lite / --web)`, artifacts: [] }
|
|
440
|
+
results.push(result)
|
|
441
|
+
reportCase(result)
|
|
442
|
+
continue
|
|
443
|
+
}
|
|
385
444
|
if (lite) {
|
|
386
445
|
try {
|
|
387
446
|
result = await runLiteCase(kase)
|
|
@@ -404,30 +463,37 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
|
|
|
404
463
|
continue
|
|
405
464
|
}
|
|
406
465
|
try {
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
466
|
+
const server = isServerCase(kase)
|
|
467
|
+
const names = kase.clients ? Object.keys(kase.clients) : undefined
|
|
468
|
+
const steps = renderer!.parseScenarioSteps(kase.steps, server ? { server: true } : { clients: names })
|
|
469
|
+
// Every case runs in FRESH sessions — no state bleeds between flows. Several clients: one each,
|
|
470
|
+
// with what the envelope says of each; the app's console is told under the client's name.
|
|
471
|
+
const sessionOptions = (name: string | undefined, own: ClientEnvelope = {}) => ({
|
|
472
|
+
width: flags.width || own.width || kase.width || projectViewport?.width || 390,
|
|
473
|
+
height: flags.height || own.height || kase.height || projectViewport?.height || 844,
|
|
412
474
|
settleMs: flags.settle ?? kase.settle,
|
|
413
475
|
waitNetwork: flags["wait-network"],
|
|
414
476
|
backend: headless!.serverUrl,
|
|
415
477
|
localAssets: useLocalAssets,
|
|
416
478
|
fixtures: kase.fixtures,
|
|
417
|
-
...resolveSafeAreaChoice(flags["safe-area"] ?? kase.safeArea, flags.device ?? kase.device),
|
|
479
|
+
...resolveSafeAreaChoice(flags["safe-area"] ?? own.safeArea ?? kase.safeArea, flags.device ?? own.device ?? kase.device),
|
|
418
480
|
nativeViews: parseNativeViews(flags["native-view"]),
|
|
419
|
-
onConsole,
|
|
481
|
+
onConsole: onConsole && name ? (level: string, text: string) => onConsole(level, `${c.dim(`[${name}]`)} ${text}`) : onConsole,
|
|
420
482
|
})
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
483
|
+
const clients = server ? []
|
|
484
|
+
: kase.clients ? Object.entries(kase.clients).map(([name, own]) => ({ name, start: (env: import("lecodes-headless/headless").ClientStart) => renderer!.createSession(js, { ...sessionOptions(name, own), ...env }) }))
|
|
485
|
+
: [{ name: "app", start: (env: import("lecodes-headless/headless").ClientStart) => renderer!.createSession(js, { ...sessionOptions(undefined), ...env }) }]
|
|
486
|
+
const serverUrl = headless!.serverUrl
|
|
487
|
+
const io = fileScenarioIO(resolve(artifactsRoot, slug(kase.name)))
|
|
488
|
+
const outcome = await renderer!.runCase({
|
|
489
|
+
clients, single: !kase.clients, steps, io, timeoutMs: kase.timeout,
|
|
490
|
+
// the case's backend: `call` steps reach it as a device would, and its clock is the case's
|
|
491
|
+
backend: serverUrl && backend ? { url: serverUrl, call: (id, args, device) => callEndpoint(serverUrl, id, args, device), setClock: (ms) => backend.setClock(ms) } : undefined,
|
|
492
|
+
})
|
|
493
|
+
result = {
|
|
494
|
+
name: kase.name, kind: kase.kind, ok: outcome.ok,
|
|
495
|
+
steps: outcome.steps.map((s) => ({ desc: s.desc, ok: s.ok, error: s.error })),
|
|
496
|
+
artifacts: outcome.failureArtifacts.map((p) => resolve(artifactsRoot, slug(kase.name), p)),
|
|
431
497
|
}
|
|
432
498
|
} catch (e) {
|
|
433
499
|
// A malformed scenario / session crash is a failed case, not a dead run.
|
package/src/commands/web.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { startPreview } from "../web/preview"
|
|
|
10
10
|
import { bundleFlags } from "./shared"
|
|
11
11
|
import { lanCandidates, loadDevToken } from "./dev"
|
|
12
12
|
import { devCertificate } from "../dev/https"
|
|
13
|
+
import { findRunningDev, recordDevPort } from "../dev/devLink"
|
|
13
14
|
|
|
14
15
|
/*
|
|
15
16
|
* `lecodes web` — the project as a WEB PAGE (Web Lite: DOM and CSS, the browser's layout, text and
|
|
@@ -21,7 +22,9 @@ import { devCertificate } from "../dev/https"
|
|
|
21
22
|
* build a folder any static hosting serves (web/build.ts)
|
|
22
23
|
* preview serve a built folder, as a hosting would
|
|
23
24
|
*
|
|
24
|
-
* Fully local: no login, no le.codes backend.
|
|
25
|
+
* Fully local: no login, no le.codes backend. The project's backend is the one of a running
|
|
26
|
+
* `lecodes dev` of it when there is one (dev/devLink.ts: the page's /server routes are forwarded there);
|
|
27
|
+
* else `web dev` runs it, and is the one the other commands find.
|
|
25
28
|
*/
|
|
26
29
|
|
|
27
30
|
const DEFAULT_PORT = 4488
|
|
@@ -51,7 +54,7 @@ const kb = (bytes: number): string => bytes < 1024 ? `${bytes} B` : bytes < 1024
|
|
|
51
54
|
const dev = defineCommand({
|
|
52
55
|
name: "dev",
|
|
53
56
|
summary: "Open the project as a web page, reloaded on every save",
|
|
54
|
-
description: "Serves the project's bundle and assets with the page of Web Lite around them; a save recompiles and runs the app again, the page's console output and errors stream back here. The address's hash is the route the app is opened with (#/product/42 → app.launchUrl). A web half of the project's own (app.json `\"ar\": { \"web\": \"./plugins/ar-8thwall\" }`) is built with every compile and served beside the page, its `web.static` folder from the project.\n\n`--https`: the page over https, for a phone's camera and motion sensors (a browser gives them to a secure page only) — a self-signed certificate for localhost and this machine's LAN addresses, made once and kept in ~/.lecodes/dev-https/. The phone's browser warns about it: accept it once.",
|
|
57
|
+
description: "Serves the project's bundle and assets with the page of Web Lite around them; a save recompiles and runs the app again, the page's console output and errors stream back here. The project's backend is a running `lecodes dev`'s when there is one (its database and data; the page's calls are forwarded there), else it runs here. The address's hash is the route the app is opened with (#/product/42 → app.launchUrl). A web half of the project's own (app.json `\"ar\": { \"web\": \"./plugins/ar-8thwall\" }`) is built with every compile and served beside the page, its `web.static` folder from the project.\n\n`--https`: the page over https, for a phone's camera and motion sensors (a browser gives them to a secure page only) — a self-signed certificate for localhost and this machine's LAN addresses, made once and kept in ~/.lecodes/dev-https/. The phone's browser warns about it: accept it once.",
|
|
55
58
|
flags: {
|
|
56
59
|
port: num("server port", { default: DEFAULT_PORT }),
|
|
57
60
|
entry: bundleFlags.entry,
|
|
@@ -70,17 +73,23 @@ const dev = defineCommand({
|
|
|
70
73
|
const scheme = flags.https ? "https" : "http"
|
|
71
74
|
const origin = `${scheme}://localhost:${port}`
|
|
72
75
|
const tls = flags.https ? await devCertificate(lans) : undefined
|
|
76
|
+
const token = loadDevToken(root)
|
|
77
|
+
// one backend per project on this machine: a running `lecodes dev` of it runs it — this page's calls go there
|
|
78
|
+
const owner = await findRunningDev(root)
|
|
73
79
|
const server = await startDevServer({
|
|
74
|
-
root, manifest, name, origin, port,
|
|
75
|
-
token: loadDevToken(root),
|
|
80
|
+
root, manifest, name, origin, port, token,
|
|
76
81
|
entryOverride: flags.entry,
|
|
77
82
|
lite,
|
|
78
83
|
tls,
|
|
79
84
|
log: note,
|
|
85
|
+
backendUrl: owner?.url,
|
|
80
86
|
}).catch((e: unknown) => {
|
|
81
87
|
const msg = e instanceof Error ? e.message : String(e)
|
|
82
88
|
throw new CliError(msg.includes("EADDRINUSE") ? `Port ${port} is already in use — pass --port <n>.` : msg)
|
|
83
89
|
})
|
|
90
|
+
if (owner) note(`The project's backend: the running lecodes dev's (port ${owner.port}) — its database, its data.`)
|
|
91
|
+
// the backend runs here: the other commands find it (not over https: they speak plain http to this machine)
|
|
92
|
+
else if (!tls) recordDevPort(root, server.port)
|
|
84
93
|
|
|
85
94
|
note("Compiling…")
|
|
86
95
|
const first = await server.recompile()
|
|
@@ -4,7 +4,7 @@ import { loadConfig, requireApiUrl, requireToken } from "../platform/config"
|
|
|
4
4
|
import { findProjectRoot, readManifest, type Manifest } from "../project/manifest"
|
|
5
5
|
import { collectEntries } from "./collect"
|
|
6
6
|
import { collectLocalEntries } from "./collectLocal"
|
|
7
|
-
import {
|
|
7
|
+
import { compileProject, requireUnambiguousEntry } from "./projectCompile"
|
|
8
8
|
import { applyProjectFonts } from "./fonts"
|
|
9
9
|
import { applyAppAssetIcons } from "./assetIcons"
|
|
10
10
|
import type { ScreenTarget } from "./screenEntry"
|
|
@@ -112,12 +112,11 @@ export const compileHeadlessBundle = async (opts: HeadlessOptions = {}): Promise
|
|
|
112
112
|
// headless renders stay deterministic and offline after the first fetch (like local assets).
|
|
113
113
|
await applyAppAssetIcons(compileEntries)
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
// A guessed entry fails here, before the compile: render / test would otherwise run the wrong file
|
|
116
|
+
// and report only its symptoms ("Nothing was rendered", "got an unnamed screen").
|
|
116
117
|
const entryFlag = opts.screen?.entryPath ?? opts.entry
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
if (ambiguous) warnings.push(ambiguous)
|
|
120
|
-
}
|
|
118
|
+
requireUnambiguousEntry(compileEntries, entryFlag)
|
|
119
|
+
note("Compiling…")
|
|
121
120
|
const serverUrl = await opts.backend?.start(compileEntries, (w) => warnings.push(w))
|
|
122
121
|
const js = await compileProject({
|
|
123
122
|
entries: compileEntries,
|
|
@@ -5,6 +5,7 @@ import type { FileKind } from "../project/manifest"
|
|
|
5
5
|
import { distRoot } from "../hosts/distRoot"
|
|
6
6
|
import { installPeers } from "../hosts/peerInstall"
|
|
7
7
|
import { peerDirInPrefix } from "../hosts/peers"
|
|
8
|
+
import { CliError } from "../cli"
|
|
8
9
|
import {
|
|
9
10
|
compileProjectWithMap as sdkCompileProjectWithMap, compileServerBundle, detectEntry, inlineSourceMapComment, isServerPath, scanModulesChisel,
|
|
10
11
|
localAssetNames, ENTRY_CONVENTION, findEnvLeaks, EnvLeakError, type BundleBake, type CompileServerOptions,
|
|
@@ -101,15 +102,44 @@ export type CompileOptions = {
|
|
|
101
102
|
env?: Record<string, string>
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
type EntryFiles = { path: string, type?: string, text?: string }[]
|
|
106
|
+
|
|
107
|
+
/** An auto-detected entry that is a guess: several import-graph roots and no main.ts among them to
|
|
108
|
+
* settle it, so the pick goes by size. Null when the detection is not a guess. */
|
|
109
|
+
const guessedEntry = (entries: EntryFiles): { picked: string, roots: string[] } | null => {
|
|
108
110
|
const { entry, roots } = detectEntry(entries, scanModulesChisel)
|
|
109
111
|
if (!entry || roots.length < 2 || roots.includes(ENTRY_CONVENTION)) return null
|
|
110
|
-
return
|
|
112
|
+
return { picked: entry, roots }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** `compile` / `dev` / `web`: a guessed entry is a warning — the pick still works, but the developer
|
|
116
|
+
* should make the entry explicit. Null when unambiguous. Callers skip this when an explicit
|
|
117
|
+
* --entry / entryOverride is in play. */
|
|
118
|
+
export const ambiguousEntryWarning = (entries: EntryFiles): string | null => {
|
|
119
|
+
const guess = guessedEntry(entries)
|
|
120
|
+
if (!guess) return null
|
|
121
|
+
return `Ambiguous entrypoint: nothing imports ${guess.roots.join(", ")} — picked ${guess.picked}. Rename your entry to main.ts (or pass --entry).`
|
|
111
122
|
}
|
|
112
123
|
|
|
124
|
+
/** `render` / `test`: a guessed entry is an error, thrown before the compile. A stray unimported file
|
|
125
|
+
* picked as the app otherwise surfaces as "Nothing was rendered" / every case "got an unnamed
|
|
126
|
+
* screen", with the cause printed last. An explicit entry (`--entry`, a screen's generated entry)
|
|
127
|
+
* settles it: the detection is not consulted. */
|
|
128
|
+
export const requireUnambiguousEntry = (entries: EntryFiles, explicitEntry: string | undefined): void => {
|
|
129
|
+
if (explicitEntry) return
|
|
130
|
+
const guess = guessedEntry(entries)
|
|
131
|
+
if (!guess) return
|
|
132
|
+
throw new CliError(
|
|
133
|
+
`Ambiguous entrypoint: nothing imports ${guess.roots.join(", ")}. ` +
|
|
134
|
+
`The guess would be ${guess.picked}. ` +
|
|
135
|
+
`Name the app's entry main.ts, or pass --entry <path>. ` +
|
|
136
|
+
`An unimported helper is an entry candidate: import it, or move it out of the project.`,
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** How a builder treats a guessed entry (see the two helpers above). Default `warn`. */
|
|
141
|
+
export type AmbiguousEntryPolicy = "warn" | "error"
|
|
142
|
+
|
|
113
143
|
/** A file of the compile by its root path (`/assets/lightmap/level.bake`) — how `bake()` reads the manifest it names:
|
|
114
144
|
* the entries' own files on disk. */
|
|
115
145
|
const resourceReader = (entries: CompileEntry[]) => {
|
|
@@ -58,6 +58,12 @@ declare module "lecodes-headless/headless" {
|
|
|
58
58
|
raster?: boolean | "auto"
|
|
59
59
|
/** Device-pixel scale for png() (default 2). */
|
|
60
60
|
scale?: number
|
|
61
|
+
/** The app's storage — a map the caller keeps survives the session (a flow's `restart`). */
|
|
62
|
+
storage?: Map<string, string>
|
|
63
|
+
/** What the app's `Date.now()` reads at clock 0 (default: the wall clock at launch). */
|
|
64
|
+
epochMs?: number
|
|
65
|
+
/** Before every step of the virtual clock: the time it steps to (ms since launch). */
|
|
66
|
+
onTime?: (clockMs: number) => void
|
|
61
67
|
}
|
|
62
68
|
export type Session = {
|
|
63
69
|
warnings: string[]
|
|
@@ -99,6 +105,12 @@ declare module "lecodes-headless/headless" {
|
|
|
99
105
|
}
|
|
100
106
|
export type ScenarioStep = {
|
|
101
107
|
name?: string
|
|
108
|
+
client?: string
|
|
109
|
+
clients?: string[] | "*"
|
|
110
|
+
/** How long this step's expect (and target) waits, ms. */
|
|
111
|
+
timeout?: number
|
|
112
|
+
/** End the step's clients (`restart` opens them again). */
|
|
113
|
+
close?: true
|
|
102
114
|
tap?: SessionTarget
|
|
103
115
|
type?: string
|
|
104
116
|
text?: string
|
|
@@ -128,9 +140,46 @@ declare module "lecodes-headless/headless" {
|
|
|
128
140
|
export type ScenarioIO = { write: (relPath: string, data: string | Uint8Array) => void }
|
|
129
141
|
export type StepOutcome = { index: number, desc: string, ok: boolean, error?: string }
|
|
130
142
|
export type ScenarioOutcome = { ok: boolean, steps: StepOutcome[], failureArtifacts: string[] }
|
|
131
|
-
|
|
143
|
+
/** What a step list is parsed for: a case's clients (absent: one), or a server test (no UI client); the
|
|
144
|
+
* variables a run is given (`vars`), and a case on real time (`realTime`: a `clock` step is an error). */
|
|
145
|
+
export type ScenarioShape = { clients?: string[], server?: boolean, vars?: string[], realTime?: boolean }
|
|
146
|
+
export function parseScenarioSteps(raw: unknown, shape?: ScenarioShape): ScenarioStep[]
|
|
132
147
|
export function describeStep(step: ScenarioStep): string
|
|
148
|
+
/** `"+92s"` / `"+1500ms"` / `"+2m"` → ms (undefined: not a jump forward). */
|
|
149
|
+
export function parseClockJump(v: unknown): number | undefined
|
|
150
|
+
|
|
151
|
+
// ---- a case: several clients, one clock, a backend (lecodes test) ----
|
|
152
|
+
export type ClientStart = { storage: Map<string, string>, epochMs: number, onTime: (clockMs: number) => void }
|
|
153
|
+
export type CaseClient = { name: string, start: (env: ClientStart) => Promise<Session> }
|
|
154
|
+
export type CallOutcome = { ok: true, result: unknown } | { ok: false, status: number, message: string }
|
|
155
|
+
export type CallDevice = { token: () => string | null, store: (token: string | null) => void }
|
|
156
|
+
export type CaseBackend = {
|
|
157
|
+
url: string
|
|
158
|
+
call?: (id: string, args: unknown[], device: CallDevice) => Promise<CallOutcome>
|
|
159
|
+
setClock?: (offsetMs: number) => void
|
|
160
|
+
}
|
|
161
|
+
export type CaseOptions = {
|
|
162
|
+
clients: CaseClient[]
|
|
163
|
+
single?: boolean
|
|
164
|
+
steps: ScenarioStep[]
|
|
165
|
+
io: ScenarioIO
|
|
166
|
+
backend?: CaseBackend
|
|
167
|
+
timeoutMs?: number
|
|
168
|
+
artifactPrefix?: string
|
|
169
|
+
keepOpen?: boolean
|
|
170
|
+
/** The case runs on the machine's clock (a backend other devices use): clients follow real time, `wait` waits, `clock` fails, `setClock` is never called. */
|
|
171
|
+
realTime?: boolean
|
|
172
|
+
/** A failing step does not end the case. */
|
|
173
|
+
keepGoing?: boolean
|
|
174
|
+
/** `${name}` values known from the start. */
|
|
175
|
+
vars?: Record<string, unknown>
|
|
176
|
+
onStepStart?: (index: number, desc: string) => void
|
|
177
|
+
onStep?: (outcome: StepOutcome) => void
|
|
178
|
+
/** (realTime) After the steps (their outcome, the clients still open): those stay, on real time, until what this returns settles. */
|
|
179
|
+
linger?: (outcome: ScenarioOutcome, open: string[]) => Promise<unknown>
|
|
180
|
+
}
|
|
181
|
+
export function runCase(opts: CaseOptions): Promise<ScenarioOutcome>
|
|
133
182
|
export function runScenario(
|
|
134
|
-
session: Session, steps: ScenarioStep[], io: ScenarioIO, opts?: { artifactPrefix?: string },
|
|
183
|
+
session: Session, steps: ScenarioStep[], io: ScenarioIO, opts?: { artifactPrefix?: string, backend?: CaseBackend, timeoutMs?: number },
|
|
135
184
|
): Promise<ScenarioOutcome>
|
|
136
185
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { firstCallGate, type FirstCallGate } from "lecodes-sdk/server"
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* An endpoint call made from the CLI as one device would make it — a flow's `call` step (`lecodes test`)
|
|
5
|
+
* and `lecodes call`. The wire is the client transport's (sdk/src/runtime/rpc.ts): `POST <url>/api/<id>`
|
|
6
|
+
* with `{"args":[…]}` and the device's token as the bearer, the envelope always HTTP 200; a `session` in
|
|
7
|
+
* the envelope is the device's token from now on. And its first-call rule (sdk/src/runtime/firstCall.ts):
|
|
8
|
+
* a device with no token sends its first call alone, so a backend that mints one on first contact
|
|
9
|
+
* (`request.session`, a guest session) mints ONE for the device however many calls it starts at once.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Who calls: the token it holds (null = none yet) and where a new one goes. */
|
|
13
|
+
export type CallDevice = { token: () => string | null, store: (token: string | null) => void }
|
|
14
|
+
|
|
15
|
+
/** What the call answered: the result, or the failure the app would see as an `RpcError`
|
|
16
|
+
* (status 0 = the backend was not reached). */
|
|
17
|
+
export type CallOutcome = { ok: true, result: unknown } | { ok: false, status: number, message: string }
|
|
18
|
+
|
|
19
|
+
const gates = new WeakMap<CallDevice, FirstCallGate>()
|
|
20
|
+
|
|
21
|
+
/** Call endpoint `id` (`<file>#<export>`) of the backend at `url` as `device`. Never throws. */
|
|
22
|
+
export const callEndpoint = (url: string, id: string, args: unknown[], device: CallDevice): Promise<CallOutcome> => {
|
|
23
|
+
let gate = gates.get(device)
|
|
24
|
+
if (!gate) { gate = firstCallGate(); gates.set(device, gate) }
|
|
25
|
+
return gate(() => !!device.token(), () => post(url, id, args, device))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const post = async (url: string, id: string, args: unknown[], device: CallDevice): Promise<CallOutcome> => {
|
|
29
|
+
const headers: Record<string, string> = { "content-type": "application/json" }
|
|
30
|
+
const token = device.token()
|
|
31
|
+
if (token) headers.authorization = `Bearer ${token}`
|
|
32
|
+
let res: Response
|
|
33
|
+
try {
|
|
34
|
+
res = await fetch(`${url.replace(/\/+$/, "")}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: JSON.stringify({ args }) })
|
|
35
|
+
} catch (e) {
|
|
36
|
+
return { ok: false, status: 0, message: `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})` }
|
|
37
|
+
}
|
|
38
|
+
let body: any = null
|
|
39
|
+
try { body = await res.json() } catch { /* not an envelope */ }
|
|
40
|
+
if (body && typeof body === "object" && "session" in body) device.store(body.session ?? null)
|
|
41
|
+
if (body?.ok === true) return { ok: true, result: body.result ?? null }
|
|
42
|
+
if (body?.ok === false) return { ok: false, status: body.status ?? res.status, message: body.message ?? "" }
|
|
43
|
+
return { ok: false, status: res.status, message: `Endpoint ${id}: unexpected response (HTTP ${res.status})` }
|
|
44
|
+
}
|
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
|
}
|