lecodes-cli 2.0.4 → 2.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/index.js +2521 -860
  2. package/package.json +16 -11
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
  6. package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
  7. package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
  8. package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
  9. package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
  10. package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
  11. package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
  12. package/runtime/web/player.js +18 -18
  13. package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  14. package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
  15. package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
  16. package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
  17. package/runtime/web-lite/assets/{canvas-C6G_Pb2Q.js → canvas-WxiusXCB.js} +1 -1
  18. package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
  19. package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
  20. package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
  21. package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
  22. package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
  23. package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
  24. package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
  25. package/runtime/web-lite/assets/{pager-O712dZv9.js → pager-Db5BY7FB.js} +1 -1
  26. package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
  27. package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
  28. package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
  29. package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
  30. package/runtime/web-lite/assets/{vlist-gEB8Sn0S.js → vlist-DrV2Q7P5.js} +1 -1
  31. package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
  32. package/runtime/web-lite/features.json +87 -4
  33. package/runtime/web-lite/lite.js +39 -10
  34. package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
  35. package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
  36. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
  37. package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
  38. package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
  39. package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
  40. package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
  41. package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
  42. package/runtime/web-lite/plugins.json +8 -0
  43. package/src/commands/compile.ts +7 -1
  44. package/src/commands/desktop.ts +7 -4
  45. package/src/commands/dev.ts +4 -14
  46. package/src/commands/render.ts +6 -2
  47. package/src/commands/test.ts +25 -9
  48. package/src/compile/projectCompile.ts +47 -1
  49. package/src/dev/androidDev.ts +1 -1
  50. package/src/dev/backendWorker.ts +138 -0
  51. package/src/dev/devServer.ts +119 -115
  52. package/src/dev/localBackend.ts +214 -0
  53. package/src/dev/projectBackend.ts +199 -0
  54. package/src/dev/serve.ts +164 -0
  55. package/src/hosts/cmgenTool.ts +6 -9
  56. package/src/hosts/desktopRenderer.ts +6 -5
  57. package/src/hosts/liteRunner.ts +52 -9
  58. package/src/hosts/matcTool.ts +5 -22
  59. package/src/hosts/peers.ts +1 -0
  60. package/src/hosts/releases.ts +6 -0
  61. package/src/index.ts +5 -1
  62. package/src/plugin/generate.ts +10 -6
  63. package/src/web/build.ts +20 -3
  64. package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
  65. package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
  66. package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
  67. package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
  68. package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
  69. package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
  70. package/runtime/web-lite/assets/tween-t_XDy3Nd.js +0 -1
@@ -0,0 +1 @@
1
+ const h={present:e=>e!=null,f64:e=>typeof e=="number"&&Number.isFinite(e)?e:void 0,i32:e=>typeof e=="number"&&Number.isInteger(e)&&e>=-2147483648&&e<=2147483647?e:void 0,bool:e=>typeof e=="boolean"?e:void 0,string:e=>typeof e=="string"?e:void 0,json:e=>e,bytes:e=>Object.prototype.toString.call(e)==="[object Uint8Array]"?e.slice():void 0,object:e=>typeof e=="object"&&e!==null&&!Array.isArray(e)?e:void 0,array:(e,r)=>Array.isArray(e)&&(r===void 0||e.length===r)?e:void 0,list:(e,r)=>{if(!Array.isArray(e))return;const n=[];for(const c of e){const o=r(c);if(o===void 0)return;n.push(o)}return n},map:(e,r)=>{if(typeof e!="object"||e===null||Array.isArray(e))return;const n={};for(const[c,o]of Object.entries(e)){const s=r(o);if(s===void 0)return;n[c]=s}return n}},j=e=>!h.present(e)||h.object(e)?{}:void 0,v={name:"qrScanner",version:1},A=(e,r)=>{e.registerView(v.name,(n,c,o)=>{const s={scan:i=>c.emit("scan",i)},a=r(j(n)??{},s,o),t=async(i,p)=>{throw new Error(`Unknown method: ${i}`)};return{el:a.el,call:t,destroy:()=>{var i;return(i=a.destroy)==null?void 0:i.call(a)}}},v.version)},S=150,k=10,T=e=>{var n;const r=globalThis.BarcodeDetector;r&&(typeof navigator>"u"||typeof((n=navigator.mediaDevices)==null?void 0:n.getUserMedia)!="function"||A(e,(c,o,s)=>{const a=s.createElement("div");a.style.cssText="background:#000;overflow:hidden";const t=s.createElement("video");t.autoplay=!0,t.muted=!0,t.playsInline=!0,t.setAttribute("playsinline",""),t.style.cssText="position:absolute;inset:0;width:100%;height:100%;object-fit:cover",a.appendChild(t);const i=new r({formats:["qr_code"]}),p=s.defaultView??globalThis;let u=null,b=null,f=!1,m=!1,y=null,g=0;const w=async()=>{var l;if(!(m||f||t.videoWidth===0)){m=!0;try{const d=((l=(await i.detect(t))[0])==null?void 0:l.rawValue)??null;if(f)return;d!==null?(g=0,d!==y&&(y=d,o.scan({data:d}))):y!==null&&++g>=k&&(y=null,o.scan({data:null}))}catch{}finally{m=!1}}};return navigator.mediaDevices.getUserMedia({video:{facingMode:"environment"},audio:!1}).then(l=>{if(f){for(const d of l.getTracks())d.stop();return}u=l,t.srcObject=l,t.play().catch(()=>{}),b=p.setInterval(()=>{w()},S)},()=>{}),{el:a,destroy(){if(f=!0,b!==null&&p.clearInterval(b),u)for(const l of u.getTracks())l.stop();u=null,t.srcObject=null}}}))};export{T as register};
@@ -0,0 +1,8 @@
1
+ {
2
+ "plugins": {
3
+ "camera": "plugins/camera/index-5XOM8eHl.js",
4
+ "geolocation": "plugins/geolocation/index-BjTKN9_P.js",
5
+ "map": "plugins/map/index-BTBHQ264.js",
6
+ "qr-scanner": "plugins/qr-scanner/index-CK_cYoJR.js"
7
+ }
8
+ }
@@ -13,6 +13,7 @@ import { buildGlobMatcher } from "../project/ignore"
13
13
  import { readAppConfigAt } from "./app/shared"
14
14
  import { defineCommand, isNullSink, note, warnErr } from "../cli"
15
15
  import { bundleFlags, bundleOptions, outFlag, session, type BundleOptions } from "./shared"
16
+ import type { RunBackend } from "../dev/projectBackend"
16
17
 
17
18
  /*
18
19
  * `lecodes compile` — the local project as one JS bundle (the server's pipeline), printed to stdout
@@ -81,6 +82,10 @@ export type BuildBundleOptions = {
81
82
  entryOverride?: string
82
83
  /** Shell mode (`lecodes app sync`) — see above. */
83
84
  shell?: ShellCompile
85
+ /** The bundle is about to RUN on this machine: a project with server files gets its backend
86
+ * started here (dev/projectBackend.ts) and that backend's url in its stubs. Without it the stubs
87
+ * of a `*.server.ts` have no server to call. */
88
+ backend?: RunBackend
84
89
  }
85
90
 
86
91
  export default defineCommand({
@@ -213,8 +218,9 @@ export const buildBundle = async (bundle: BundleOptions, opts: BuildBundleOption
213
218
  const ambiguous = ambiguousEntryWarning(entries)
214
219
  if (ambiguous) warnErr(ambiguous)
215
220
  }
221
+ const serverUrl = await opts.backend?.start(entries, (w) => warnings.push(w))
216
222
  const js = await compileProject({
217
- entries, name, publicUrl, entryOverride,
223
+ entries, name, publicUrl, entryOverride, serverUrl,
218
224
  header: bundle.header ?? true,
219
225
  localAssets, fontsHeader, iconsHeader,
220
226
  minify: bundle.minify,
@@ -1,5 +1,5 @@
1
1
  import { basename } from "node:path"
2
- import { CliError, bool, c, defineCommand, log, note, num, str } from "../cli"
2
+ import { CliError, bool, c, defineCommand, log, logErr, note, num, str } from "../cli"
3
3
  import {
4
4
  DESKTOP_RENDERER_VERSION, fetchDesktopManifest, findMacHostExe, installedDesktopVersions, runDesktopApp,
5
5
  selectedDesktopVersion, semverNewer, updateDesktop,
@@ -7,6 +7,7 @@ import {
7
7
  import { desktopBuild } from "./app/desktop"
8
8
  import { desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
9
9
  import { buildBundle } from "./compile"
10
+ import { runBackend } from "../dev/projectBackend"
10
11
  import { bundleFlags, bundleOptions, localProject } from "./shared"
11
12
 
12
13
  /*
@@ -40,7 +41,7 @@ export const resolveRenderScale = (flag: string | undefined, cfg: number | undef
40
41
  const run = defineCommand({
41
42
  name: "run",
42
43
  summary: "Compile the current project and open it in a desktop window",
43
- description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
44
+ description: "Window size / min size / title default from app.json's desktop block; asset() resources load from the working tree. A project with *.server.ts files gets its backend run on this machine for as long as the window is open (the database of `lecodes dev`: .lecodes/db). Multiplayer: --server starts the host as a dedicated server, --connect joins one — both only set Net.launch (the app decides).",
44
45
  flags: {
45
46
  width: num("window width (logical px)"),
46
47
  height: num("window height (logical px)"),
@@ -68,7 +69,9 @@ const run = defineCommand({
68
69
  // ONE renderer decision per run: it picks the host binary AND which .filamat gets staged beside
69
70
  // the bundle. Reading the config twice is how a Vulkan exe ends up next to an OpenGL shader.
70
71
  const renderer = resolveDesktopRenderer(root, flags.renderer)
71
- const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer })
72
+ // The project's backend (its *.server.ts files), when it has one: run here while the window is open.
73
+ const backend = runBackend({ root, command: "lecodes desktop run", log: logErr })
74
+ const { js, resources } = await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer: renderer, backend })
72
75
  // Native plugins: the project's plugins/ dir + declared plugin libraries, scanned by the host.
73
76
  const { dirs: pluginDirs, files: pluginFiles } = desktopPluginLibraries(root)
74
77
  if (pluginFiles.length > 0) note(`Native plugins: ${pluginFiles.map((f) => basename(f)).join(", ")}`)
@@ -101,7 +104,7 @@ const run = defineCommand({
101
104
  net: flags.server
102
105
  ? { role: "server", port: flags.port, maxClients: flags["max-clients"] }
103
106
  : flags.connect ? { role: "client", address: flags.connect } : undefined,
104
- })
107
+ }).finally(() => backend.close())
105
108
  },
106
109
  })
107
110
 
@@ -22,7 +22,8 @@ import { bundleFlags } from "./shared"
22
22
  * working tree. The device opens the QR URL like any project URL; every save recompiles and
23
23
  * hot-swaps the running app (`_creatorApp.run` — fresh context, rollback on error). Device
24
24
  * console output and errors stream back to this terminal. Fully local: no login, no le.codes
25
- * backend. Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
25
+ * backend — a project's own backend (its `*.server.ts` files) runs here too, its database in
26
+ * .lecodes/db (dev/localBackend.ts). Custom shaders compile locally too (matc, downloaded on first use — matcTool.ts);
26
27
  * only when matc can't be fetched do they fall back to server-compiled artifacts (push once).
27
28
  *
28
29
  * `--web` opens the same project in the browser: the dev server also serves, under /web/, the
@@ -145,16 +146,6 @@ export default defineCommand({
145
146
  }
146
147
  const name = manifest?.name ?? basename(root)
147
148
 
148
- // Bun's node:http can't complete a websocket upgrade (writes to the upgraded socket are dropped),
149
- // so the reload push would silently never reach devices: a phone that scanned a websocket url
150
- // sat on "Connected — loading the project…" forever. Under Bun every device url — the QR, the
151
- // printed one, what --ios / --android open — is the POLL form (/dev-poll.js: a path, since the
152
- // launcher only fetches urls that end in .js). Node is the shipped runtime.
153
- const underBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined"
154
- if (underBun) {
155
- warnErr("Running under Bun: no websocket reload channel here — devices poll instead (the QR points at /dev-poll.js). Node is the shipped runtime.")
156
- }
157
-
158
149
  const port = flags.port || DEFAULT_PORT
159
150
  const candidates = lanCandidates()
160
151
  const host = flags.host ?? candidates[0]?.address
@@ -203,15 +194,14 @@ export default defineCommand({
203
194
  log("")
204
195
  log(`${c.bold("LeCodes Dev")} — ${name}`)
205
196
  log("")
206
- // The url a device opens: the websocket form, or the poll form under Bun (see above).
207
- const deviceUrl = underBun ? server.url.replace(/\/dev\.js$/, "/dev-poll.js") : server.url
197
+ const deviceUrl = server.url
208
198
  if (!flags["no-qr"]) await printQr(deviceUrl)
209
199
  log(`Scan the QR with the LeCodes app, or open: ${c.bold(deviceUrl)}`)
210
200
  // The browser runner: localhost is a secure context (camera works there), the LAN address is
211
201
  // what a phone's browser can reach.
212
202
  const webLocal = server.webUrl ? server.webUrl.replace(`http://${host}:`, "http://localhost:") : null
213
203
  if (server.webUrl && webLocal) log(`In the browser: ${c.bold(webLocal)} (LAN: ${server.webUrl})`)
214
- if (!underBun) note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
204
+ note(`Older desktop builds (no websocket bridge): ${server.url}?t=poll`)
215
205
  if (alternatives.length > 0) {
216
206
  note(`Device can't reach it? This machine also has ${alternatives.map((a) => `${a.address} (${a.iface})`).join(", ")} — retry with --host <ip>.`)
217
207
  }
@@ -4,6 +4,7 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { screenTargetFrom } from "../compile/screenEntry"
5
5
  import { findProjectRoot } from "../project/manifest"
6
6
  import { buildBundle } from "./compile"
7
+ import { runBackend } from "../dev/projectBackend"
7
8
  import { desktopDefaultViewport, desktopPluginLibraries, readAppConfigAt, resolveDesktopRenderer } from "./app/shared"
8
9
  import { runDesktopRender } from "../hosts/desktopRenderer"
9
10
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
@@ -285,8 +286,10 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
285
286
  const desktopRenderer = resolveDesktopRenderer(root, flags.renderer)
286
287
  // Native plugins (project plugins/ dir + declared libraries) — the host scans CREATOR_PLUGIN_DIRS.
287
288
  const { dirs: pluginDirs } = desktopPluginLibraries(root)
289
+ // The project's backend, when it has one (the database of `lecodes dev`).
290
+ const backend = runBackend({ root, command: "lecodes render", log: (msg) => { if (logs || msg.includes("[server:error]")) logErr(msg) } })
288
291
  const { js, resources } = await buildBundle(bundleOptions(flags), {
289
- localAssets: true, desktopRenderer,
292
+ localAssets: true, desktopRenderer, backend,
290
293
  extraEntries: screen ? [screen.entry] : undefined, entryOverride: screen?.entryPath,
291
294
  })
292
295
  // The host writes PNGs but never creates folders — make every output dir up front.
@@ -305,6 +308,7 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
305
308
  renderer: desktopRenderer, host: sim ? "server" : "desktop", pluginDirs,
306
309
  width, height, frames: plan.frameCap, outPng: pngFlag, logs, timeoutMs, script: plan.text, fixedDtMs,
307
310
  })
311
+ await backend.close()
308
312
  const result = evaluateDesktopRun(plan, output)
309
313
  if (!result.loaded) {
310
314
  throw new CliError("This desktop host doesn't support scripted input (CREATOR_SCRIPT) — it ran the frames and ignored the steps. Update it: lecodes desktop update (or point LECODES_DESKTOP_EXE at a newer build).")
@@ -326,7 +330,7 @@ const renderDesktop = async (flags: RenderFlags, file: string | undefined): Prom
326
330
  await runDesktopRender(js, resources, {
327
331
  renderer: desktopRenderer, pluginDirs,
328
332
  width, height, frames, outPng: pngFlag, logs, timeoutMs, fixedDtMs, camera, view,
329
- })
333
+ }).finally(() => backend.close())
330
334
  note(`Wrote ${pngFlag} (${width}x${height}, native render${view ? `, ${view.split(";")[0]} view` : camera ? ", custom camera" : ""})`)
331
335
  }
332
336
 
@@ -4,11 +4,12 @@ import { compileHeadlessBundle } from "../compile/headlessBundle"
4
4
  import { desktopDefaultViewport, desktopPluginLibraries, resolveDesktopRenderer } from "./app/shared"
5
5
  import { desktopRenderFlags, fileScenarioIO, requireRenderer, resolveFixtures, resolveSafeAreaChoice, viewportFlags } from "./render"
6
6
  import { buildBundle } from "./compile"
7
+ import { runBackend } from "../dev/projectBackend"
7
8
  import { findProjectRoot } from "../project/manifest"
8
9
  import { runDesktopRender } from "../hosts/desktopRenderer"
9
10
  import { evaluateDesktopRun, translateScenario } from "../hosts/desktopScript"
10
11
  import { startLiteSession, type LiteSession } from "../hosts/liteRunner"
11
- import { resolveLiteRunner } from "../dev/webRunner"
12
+ import { resolveLiteRunner, resolveWebRunner } from "../dev/webRunner"
12
13
  import { readManifest, type Manifest } from "../project/manifest"
13
14
  import { CliError, bool, c, defineCommand, log, logErr, note, num, str, warnErr, type FlagValues } from "../cli"
14
15
  import { bundleFlags, bundleOptions } from "./shared"
@@ -157,6 +158,7 @@ const testFlags = {
157
158
  ...bundleFlags,
158
159
  ...desktopRenderFlags,
159
160
  lite: bool("run the file scenarios on Web Lite, in a browser (needs Playwright): the same steps, targets and expects; the clock is the browser's, so a step that expects a log line waits for it; design flows stay headless"),
161
+ web: bool("run the file scenarios on the web player, in a browser (needs Playwright): as --lite, on the runtime as wasm; no `fill` step (tap a field and `type`)"),
160
162
  desktop: bool("run the file scenarios through the NATIVE host: the same steps, targets and expects as headless (expect.audio and the json output excepted); design flows stay headless. --frames / --fixed-dt as in render --desktop"),
161
163
  }
162
164
  // (`sim` comes with desktopRenderFlags: the file scenarios through the WINDOWLESS host — no GPU, no
@@ -168,7 +170,7 @@ export default defineCommand({
168
170
  name: "test",
169
171
  summary: "Run the flow tests (tests/*.flow.json + design-derived flows)",
170
172
  usage: "[name…]",
171
- 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>/.\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).",
173
+ 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>/. On --desktop / --lite / --web 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. The headless renderer has no backend, and --sim does not wait for one: its frames are not tied to real time.\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).",
172
174
  flags: testFlags,
173
175
  examples: ["lecodes test", "lecodes test hero --logs", "lecodes test --flows --json", "lecodes test --desktop"],
174
176
  run: ({ args, flags }) => runTests(args, flags),
@@ -177,9 +179,12 @@ export default defineCommand({
177
179
  const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
178
180
  // --sim is the native path on the windowless host: everything --desktop does, minus pixels.
179
181
  const sim = flags.sim
180
- const lite = flags.lite
181
- if (lite && (flags.desktop || sim)) throw new CliError("--lite is a host of its own — drop --desktop / --sim.")
182
- if (lite && flags.flows) throw new CliError("--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --lite.")
182
+ // --lite / --web: a browser run, on Web Lite or on the web player — one runner, two pages.
183
+ if (flags.lite && flags.web) throw new CliError("--lite and --web are two hosts — pick one.")
184
+ const lite = flags.lite || flags.web
185
+ const browserFlag = flags.web ? "--web" : "--lite"
186
+ if (lite && (flags.desktop || sim)) throw new CliError(`${browserFlag} is a host of its own — drop --desktop / --sim.`)
187
+ if (lite && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop ${browserFlag}.`)
183
188
  const desktop = flags.desktop || sim
184
189
  if (desktop && flags.flows) throw new CliError(`--flows needs the headless renderer (design flows read expect.screen and fill inputs) — drop --${sim ? "sim" : "desktop"}.`)
185
190
  // --desktop: ONE renderer decision (host binary + the staged .filamat variant). The root is
@@ -187,7 +192,9 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
187
192
  let desktopRoot: string
188
193
  try { desktopRoot = findProjectRoot(process.cwd()) } catch { desktopRoot = process.cwd() }
189
194
  const desktopRenderer = desktop ? resolveDesktopRenderer(desktopRoot, flags.renderer) : undefined
190
- const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer }) : null
195
+ // The project's backend, when it has one: on a database of its own, emptied before every case.
196
+ const backend = desktop ? runBackend({ root: desktopRoot, command: "lecodes test", test: true, log: (msg) => { if (flags.logs || msg.includes("[server:error]")) logErr(msg) } }) : null
197
+ const native = desktop ? await buildBundle(bundleOptions(flags), { localAssets: true, desktopRenderer, backend: backend! }) : null
191
198
  const headless = desktop || lite ? null : await compileHeadlessBundle({ entry: flags.entry, publicUrl: flags["public-url"], remoteAssets: flags["remote-assets"], waitNetwork: flags["wait-network"] })
192
199
  const warnings = headless?.warnings ?? []
193
200
  const useLocalAssets = headless?.useLocalAssets ?? false
@@ -338,11 +345,17 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
338
345
  // Web Lite: one compile, one browser; a page of its own per case.
339
346
  let liteSession: LiteSession | null = null
340
347
  if (lite) {
341
- const page = resolveLiteRunner(note)
342
- if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
343
348
  let manifest: Manifest | null = null
344
349
  try { manifest = readManifest(root) } catch { /* a folder standing alone */ }
345
- liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
350
+ if (flags.web) {
351
+ const web = resolveWebRunner(note)
352
+ if (!web) throw new CliError("No web player found: this CLI has no runtime/web (a monorepo checkout needs `bun run build` in hosts/web-player).")
353
+ liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", web, entry: flags.entry })
354
+ } else {
355
+ const page = resolveLiteRunner(note)
356
+ if (!page) throw new CliError("No Web Lite page found: this CLI has no runtime/web-lite (a monorepo checkout needs `bun run build` in hosts/web-lite).")
357
+ liteSession = await startLiteSession({ root, manifest, name: manifest?.name ?? "app", lite: page, entry: flags.entry })
358
+ }
346
359
  }
347
360
  const runLiteCase = async (kase: FlowCase): Promise<CaseResult> => {
348
361
  const caseDir = resolve(artifactsRoot, slug(kase.name))
@@ -364,6 +377,8 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
364
377
 
365
378
  for (const kase of cases) {
366
379
  let result: CaseResult
380
+ // every case starts on an empty database, like it starts in a fresh host
381
+ if (backend && results.length > 0) await backend.reset()
367
382
  if (lite) {
368
383
  try {
369
384
  result = await runLiteCase(kase)
@@ -446,6 +461,7 @@ const runTests = async (args: string[], flags: TestFlags): Promise<void> => {
446
461
  }
447
462
 
448
463
  await liteSession?.close()
464
+ await backend?.close()
449
465
  clearTimeout(killer)
450
466
  process.exit(failed > 0 ? 1 : 0)
451
467
  }
@@ -3,7 +3,10 @@ import { createRequire } from "node:module"
3
3
  import { dirname, join } from "node:path"
4
4
  import type { FileKind } from "../project/manifest"
5
5
  import { distRoot } from "../hosts/distRoot"
6
- import { compileProject as sdkCompileProject, detectEntry, scanModulesChisel, localAssetNames, ENTRY_CONVENTION } from "lecodes-sdk/compile"
6
+ import {
7
+ compileProject as sdkCompileProject, compileServerBundle, detectEntry, isServerPath, scanModulesChisel, localAssetNames, ENTRY_CONVENTION,
8
+ type CompileServerOptions,
9
+ } from "lecodes-sdk/compile"
7
10
 
8
11
  // Every project compiles through the SDK's one import-free compiler, which bundles with chisel (the
9
12
  // Rust method-granular bundler, @letary/chisel) — the only bundler. The SDK resolves the chisel
@@ -84,6 +87,9 @@ export type CompileOptions = {
84
87
  /** What the compile has to say that is not an error (a plugin the app uses and app.json does not
85
88
  * list). */
86
89
  onWarning?: (message: string) => void
90
+ /** Base URL of the project's backend, baked into the stubs of its `*.server.ts` modules; absent =
91
+ * every call to the server rejects (status 0). */
92
+ serverUrl?: string
87
93
  }
88
94
 
89
95
  /** A warning when auto-detection has several import-graph roots and no main.ts to settle it —
@@ -110,8 +116,48 @@ export const compileProject = (opts: CompileOptions): Promise<string> =>
110
116
  sourcemap: opts.sourcemap,
111
117
  editor: opts.editor,
112
118
  onWarning: opts.onWarning,
119
+ serverUrl: opts.serverUrl,
113
120
  })
114
121
 
122
+ /** Does the project have a server side (`*.server.ts` files)? */
123
+ export const hasServerFiles = (entries: CompileEntry[]): boolean =>
124
+ entries.some((e) => e.type === "text" && isServerPath(e.path))
125
+
126
+ /** TypeScript with its lib files on disk — the endpoint validators are derived by its checker. The
127
+ * project's own, else what this CLI's install can see; null when there is none. */
128
+ const findTypescript = (root: string): CompileServerOptions["typescript"] | null => {
129
+ for (const from of [join(root, "package.json"), import.meta.url]) {
130
+ try {
131
+ const mod = createRequire(from)("typescript")
132
+ const ts = mod?.createProgram ? mod : mod?.default
133
+ if (ts?.createProgram) return ts
134
+ } catch { /* the next place */ }
135
+ }
136
+ return null
137
+ }
138
+
139
+ /**
140
+ * The project's server bundle (its `*.server.ts` files and what they import), or null when it has
141
+ * none. `app` = app.json's name (the sign-in mail). Without a TypeScript to derive them with the
142
+ * bundle carries no argument validators — said through `onWarning`, since the deployed backend
143
+ * (the le.codes build) does check them.
144
+ */
145
+ export const compileServer = async (opts: { entries: CompileEntry[], root: string, app?: CompileServerOptions["app"], onWarning?: (message: string) => void }) => {
146
+ const typescript = findTypescript(opts.root)
147
+ if (!typescript) {
148
+ // Sign-in rests on a check only the compiler makes: `db.auth.requireUser()` is a query, and one that is
149
+ // not awaited guards nothing. A server with sign-in is not built without it.
150
+ const signIn = opts.entries.find((e) => e.type === "text" && isServerPath(e.path) && /\.withAuth\s*\(/.test(e.text ?? ""))
151
+ if (signIn) throw new Error(`${signIn.path} uses sign-in (withAuth): its server code is checked with TypeScript, and none was found — npm install --save-dev typescript in the project.`)
152
+ opts.onWarning?.("Endpoint arguments are not checked against their types here: no TypeScript found (npm install --save-dev typescript in the project).")
153
+ }
154
+ return compileServerBundle({
155
+ entries: opts.entries.map((e) => ({ path: e.path, type: e.type as any, text: e.text })),
156
+ app: opts.app,
157
+ ...(typescript ? { typescript } : { validate: false }),
158
+ })
159
+ }
160
+
115
161
  /** Re-exported so the CALLERS that stage files next to the bundle name them the way the bundle asks
116
162
  * for them (see localAssetNames in the SDK compile — two packs shipping SM_Sign_01.glb). */
117
163
  export { localAssetNames }
@@ -12,7 +12,7 @@ import { CliError, note, warnErr } from "../cli"
12
12
  * app's VIEW filter is pinned to le.codes/qr, and an explicit start bypasses filter matching — with
13
13
  * the plain dev.js url: the app's initial.js only fetches a url that ends in ".js", anything else is
14
14
  * sent to le.codes/api/code, which answers 400 for a dev bundle (the "Произошла ошибка при открытии
15
- * проекта" screen). That is also why the poll transport is a PATH (/dev-poll.js), never `?t=poll`.
15
+ * проекта" screen).
16
16
  *
17
17
  * A device run has no frame cap (unlike the desktop host), so every exit path force-stops the app:
18
18
  * a 3D scene left rendering is a sustained GPU load on a charging phone.
@@ -0,0 +1,138 @@
1
+ import { mkdirSync, mkdtempSync, renameSync, rmSync } from "node:fs"
2
+ import { tmpdir } from "node:os"
3
+ import { join } from "node:path"
4
+ import { format } from "node:util"
5
+ import {
6
+ createAuthHost, createInvoker, createSubscribeGuard, describeServer, loadServerModules,
7
+ setChannelPublisher, setDbTransport, type AuthHost, type LoadedServer,
8
+ } from "lecodes-sdk/server"
9
+ import { loadPeer } from "../hosts/peerInstall"
10
+ import type { FromWorker, LogLevel, ToWorker } from "./localBackend"
11
+
12
+ /*
13
+ * The process a project's server bundle runs in under `lecodes dev` — the local twin of the runner's
14
+ * worker (tools/runner/src/worker.ts), started by localBackend.ts as this same CLI with
15
+ * BACKEND_WORKER_ARG and spoken to over the IPC channel. One process per loaded bundle: a reload is
16
+ * a new process, so nothing of the old code (its timers, its module state) outlives it.
17
+ *
18
+ * The database is marcidb's embedded engine (the peer marcidb-embedded), opened here, in the
19
+ * directory the parent names. The schema is synced on every load, the way the runner syncs a draft
20
+ * slot: what the models no longer have is dropped; a change the engine can't migrate in place moves
21
+ * the old database aside and starts an empty one.
22
+ */
23
+
24
+ type Embedded = {
25
+ exec(op: unknown): Promise<any>
26
+ batch(ops: unknown[]): Promise<any[]>
27
+ $sync(schema: string): Promise<null>
28
+ close(): void
29
+ }
30
+ type EmbeddedModule = { openDatabase(dir: string, options?: { disableFsync?: boolean }): Embedded }
31
+
32
+ const post = (m: FromWorker) => { process.send?.(m) }
33
+
34
+ // ── console → the parent (the dev terminal) ──
35
+ const hook = (level: LogLevel) => (...args: unknown[]) => post({ t: "log", level, text: format(...args) })
36
+ console.log = hook("log"); console.info = hook("info"); console.warn = hook("warn"); console.error = hook("error"); console.debug = hook("debug")
37
+ process.on("unhandledRejection", (e) => post({ t: "log", level: "error", text: `Unhandled rejection: ${(e as Error)?.stack ?? e}` }))
38
+ process.on("uncaughtException", (e) => post({ t: "log", level: "error", text: `Uncaught exception: ${(e as Error)?.stack ?? e}` }))
39
+
40
+ /** The code `db.auth.signInWithCode` takes for any email under `lecodes dev` (no `sendCode` needed). */
41
+ const DEV_CODE = "000000"
42
+
43
+ let db: Embedded | null = null
44
+ let invoke: ReturnType<typeof createInvoker> | null = null
45
+ let subscribe: ReturnType<typeof createSubscribeGuard> | null = null
46
+ let authHost: AuthHost | null = null
47
+
48
+ const quit = () => {
49
+ try { db?.close() } catch { /* already closed */ }
50
+ process.exit(0)
51
+ }
52
+ // the dev server went away (Ctrl+C, a crash): nothing is left to serve
53
+ process.on("disconnect", quit)
54
+ // …and when it was killed outright the channel may never say so: a process left behind keeps the
55
+ // database locked, and the next `lecodes dev` of the project could not open it.
56
+ const parent = process.ppid
57
+ setInterval(() => { try { process.kill(parent, 0) } catch { quit() } }, 1000).unref?.()
58
+ process.on("SIGINT", quit)
59
+ process.on("SIGTERM", quit)
60
+
61
+ /** Open the project's database and bring it to `schema`. Returns what the developer should be told. */
62
+ const openDb = async (dir: string, schema: string): Promise<string[]> => {
63
+ const { openDatabase } = await loadPeer<EmbeddedModule>("marcidb-embedded", undefined, { for: "lecodes dev", install: false })
64
+ ?? (() => { throw new Error("marcidb-embedded is not installed") })()
65
+ mkdirSync(dir, { recursive: true })
66
+ try {
67
+ db = openDatabase(dir)
68
+ } catch (e) {
69
+ // one process holds a database: the engine's lock is the sign of a second server on this project
70
+ if (/Locked/i.test((e as Error).message)) throw new Error(`The project's database (${dir}) is in use — this project's backend is already running (another \`lecodes dev\` or \`lecodes desktop run\`). Stop it first.`)
71
+ throw e
72
+ }
73
+ try {
74
+ await db.$sync(schema)
75
+ return []
76
+ } catch (e) {
77
+ if ((e as { kind?: string }).kind !== "bad_request") throw e
78
+ const reason = (e as Error).message
79
+ // Not migratable in place — or not a schema at all. An empty database tells the two apart, so a
80
+ // broken schema never costs the data.
81
+ const probeDir = mkdtempSync(join(tmpdir(), "lecodes-db-"))
82
+ const probe = openDatabase(probeDir, { disableFsync: true })
83
+ try { await probe.$sync(schema) } finally { probe.close(); rmSync(probeDir, { recursive: true, force: true }) }
84
+ db.close()
85
+ const aside = `${dir}-old`
86
+ rmSync(aside, { recursive: true, force: true })
87
+ renameSync(dir, aside)
88
+ mkdirSync(dir, { recursive: true })
89
+ db = openDatabase(dir)
90
+ await db.$sync(schema)
91
+ return [`database started over — the schema change couldn't be migrated (${reason}); the previous data is in ${aside}`]
92
+ }
93
+ }
94
+
95
+ const load = async (m: Extract<ToWorker, { t: "load" }>) => {
96
+ const noDb = () => { throw new Error("db: the database is not open yet — query it inside an endpoint, not while the module loads") }
97
+ setDbTransport({ exec: (op) => (db ?? noDb()).exec(op), batch: (ops) => (db ?? noDb()).batch(ops) })
98
+ setChannelPublisher((ch, group, data) => post({ t: "publish", ch, group, data }))
99
+ // The bundle is a plain script (chisel esm output with only side effects): registers globalThis.__lecodesServer
100
+ ;(0, eval)(m.code)
101
+ const server: LoadedServer = loadServerModules()
102
+ const describe = describeServer(server)
103
+ const notes = describe.marci ? await openDb(m.dbDir, describe.marci) : []
104
+ // Sessions, passwords and email codes over the project's own db, as on the runner. A code that was
105
+ // asked for is printed here, and DEV_CODE signs any email in without one — the developer is the
106
+ // only user.
107
+ authHost = createAuthHost({
108
+ db: server.dbs.find(d => d.$auth) ?? null,
109
+ appName: server.manifest.app?.name,
110
+ echoCodes: true,
111
+ devCode: DEV_CODE,
112
+ log: (level, text) => post({ t: "log", level, text }),
113
+ })
114
+ authHost.install()
115
+ const auth = authHost.enabled ? authHost : null
116
+ const onError = (id: string, e: unknown) => post({ t: "log", level: "error", text: `${id}: ${(e as Error)?.stack ?? e}` })
117
+ invoke = createInvoker(server, { auth, onError })
118
+ subscribe = createSubscribeGuard(server, { auth, onError })
119
+ post({ t: "loaded", describe, notes })
120
+ }
121
+
122
+ process.on("message", async (m: ToWorker) => {
123
+ switch (m.t) {
124
+ case "load":
125
+ try { await load(m) } catch (e) { post({ t: "loadError", message: (e as Error)?.stack ?? String(e) }) }
126
+ return
127
+ case "invoke": {
128
+ const result = invoke ? await invoke(m.id, m.args, m.ctx) : { ok: false as const, status: 503, message: "server not loaded" }
129
+ return post({ t: "result", req: m.req, result })
130
+ }
131
+ case "subscribe": {
132
+ const result = subscribe ? await subscribe(m.ch, m.args, m.ctx) : { ok: false as const, status: 503, message: "server not loaded" }
133
+ return post({ t: "result", req: m.req, result })
134
+ }
135
+ case "stop":
136
+ return quit()
137
+ }
138
+ })