lecodes-cli 2.4.1 → 2.4.3

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 (54) hide show
  1. package/dist/index.js +36134 -11203
  2. package/package.json +10 -9
  3. package/runtime/sdk-types.json +1 -1
  4. package/runtime/web/VERSION.json +3 -3
  5. package/runtime/web/assets/ar-D6oDL4pw.js +1 -0
  6. package/runtime/web/assets/ar-mVXX3kRQ.js +2 -0
  7. package/runtime/web/assets/qrAnchorWorker-Cb5E8cF-.js +1 -0
  8. package/runtime/web/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  9. package/runtime/web/index.html +1 -0
  10. package/runtime/web/player.js +12 -12
  11. package/runtime/web-lite/assets/{index-B2zEJlWS.js → index-oPsDD47E.js} +1 -1
  12. package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +1105 -0
  13. package/runtime/web-lite/features.json +2 -2
  14. package/runtime/web-lite/index.html +2 -1
  15. package/runtime/web-lite/lite.js +9 -9
  16. package/runtime/web-lite/plugins/ar/_virtual_lecodes-half-resources-CNYmVR88.js +1 -0
  17. package/runtime/web-lite/plugins/ar/assets/qrAnchorWorker-DcxVKAqo.js +1 -0
  18. package/runtime/web-lite/plugins/ar/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
  19. package/runtime/web-lite/plugins/ar/index-C2uuuLhz.js +2 -0
  20. package/runtime/web-lite/plugins.json +1 -1
  21. package/src/commands/app/android.ts +68 -19
  22. package/src/commands/app/index.ts +84 -17
  23. package/src/commands/app/shared.ts +157 -24
  24. package/src/commands/app/splash.ts +251 -0
  25. package/src/commands/app/templates/android.ts +148 -25
  26. package/src/commands/app/templates/ios.ts +123 -12
  27. package/src/commands/init/index.ts +195 -195
  28. package/src/commands/plugin.ts +3 -3
  29. package/src/commands/push.ts +2 -2
  30. package/src/commands/shared.ts +79 -79
  31. package/src/commands/thumbs.ts +185 -185
  32. package/src/commands/web.ts +12 -5
  33. package/src/compile/collect.ts +2 -3
  34. package/src/compile/collectLocal.ts +13 -2
  35. package/src/compile/designCompile.ts +2 -3
  36. package/src/compile/projectCompile.ts +3 -0
  37. package/src/compile/screenEntry.ts +123 -123
  38. package/src/dev/devServer.ts +63 -7
  39. package/src/dev/https.ts +50 -0
  40. package/src/dev/serve.ts +15 -9
  41. package/src/dev/webRunner.ts +11 -3
  42. package/src/platform/serverDiff.ts +2 -3
  43. package/src/plugin/emitPackage.ts +18 -5
  44. package/src/plugin/emitWeb.ts +17 -8
  45. package/src/plugin/firstParty.ts +30 -0
  46. package/src/plugin/generate.ts +52 -24
  47. package/src/plugin/manifest.ts +66 -7
  48. package/src/project/ignore.ts +164 -164
  49. package/src/project/localFiles.ts +44 -7
  50. package/src/web/build.ts +33 -9
  51. package/src/web/halves.ts +76 -0
  52. package/runtime/web/assets/ar-CHvaraG4.js +0 -1
  53. package/runtime/web-lite/assets/scene3d-Bew8g7Ws.js +0 -1081
  54. package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +0 -1
@@ -1,123 +1,123 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs"
2
- import { isAbsolute, join, relative, resolve } from "node:path"
3
- import { screenFrame } from "../project/designMeta"
4
- import { CliError } from "../cli"
5
- import { toKey } from "../project/paths"
6
- import type { CompileEntry } from "./projectCompile"
7
-
8
- /*
9
- * Rendering ONE screen module instead of the whole app — `lecodes render <file>` (and
10
- * `--desktop`). A design screen (`design/screens/login.ts`) never boots itself: it default-exports
11
- * a `UIScreen`, or a `(state) => UIScreen` function for its states, and the design board / snapshot
12
- * supply the `.open()`. This builds the same virtual entry for the normal project pipeline, so any
13
- * file written that way — inside design/ or not — renders with the full `lecodes render` flag set
14
- * (--png/--clip/--script/--device/…) against the project's real imports and assets.
15
- *
16
- * Kept in step with compile/designCompile.ts, which does the same wrapping per screen for the board.
17
- */
18
-
19
- /** Extensions a screen module may carry (so `render design/screens/login` works too). */
20
- const SCREEN_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"]
21
-
22
- /** Where the generated entry lives — the project root, so its import specifier is the screen's
23
- * own project key ("./design/screens/login"). Leading dot: never a real project file. */
24
- const SCREEN_ENTRY_PATH = "/.screen-entry.ts"
25
-
26
- /** The design folder a bare screen id (`lecodes render login`) is looked up in. */
27
- const DEFAULT_DESIGN_DIR = "design"
28
-
29
- export type ScreenTarget = {
30
- /** The virtual entry module — hand it to the compiler alongside the project's own entries. */
31
- entry: CompileEntry
32
- /** Path of that entry, for `entryOverride`. */
33
- entryPath: string
34
- /** The board frame from the design folder's meta.json, when this IS a design screen. */
35
- frame?: [number, number]
36
- /** How the screen is named in progress notes ("design/screens/login.ts", "login@empty"). */
37
- label: string
38
- }
39
-
40
- /** `lecodes render <file|screen-id>` — the positional a screen render is asked for by. */
41
-
42
- /** Whether the argument names a path at all (vs a bare screen id). */
43
- const hasSeparator = (file: string) => file.includes("/") || file.includes("\\")
44
-
45
- /** Find the screen file: a path (with or without extension), or a bare id resolved against the
46
- * design folder's screens/ (`lecodes render login`, honoring --dir). */
47
- const findScreenFile = (root: string, file: string, dirFlag: string | undefined): string => {
48
- const candidates = [resolve(process.cwd(), file)]
49
- if (!hasSeparator(file)) {
50
- candidates.push(join(root, (dirFlag ?? DEFAULT_DESIGN_DIR).replace(/[/\\]+$/, ""), "screens", file))
51
- }
52
- for (const base of candidates) {
53
- if (existsSync(base) && statSync(base).isFile()) return base
54
- for (const ext of SCREEN_EXTS) if (existsSync(base + ext)) return base + ext
55
- }
56
- throw new CliError(
57
- `No screen file at "${file}".` +
58
- (hasSeparator(file) ? "" : ` Pass a path, or a screen id that exists in ${dirFlag ?? DEFAULT_DESIGN_DIR}/screens/.`))
59
- }
60
-
61
- /** The design folder a screen file belongs to ("<root>/design" for "/design/screens/login.ts"),
62
- * or null when it isn't laid out as a design screen. */
63
- const designDirOf = (root: string, key: string): { dir: string, id: string } | null => {
64
- const m = /^\/(.*)screens\/([^/]+)\.[^./]+$/.exec(key)
65
- if (!m) return null
66
- const dir = join(root, m[1])
67
- return existsSync(join(dir, "meta.json")) ? { dir, id: m[2] } : null
68
- }
69
-
70
- /**
71
- * Resolve `<file>` into the virtual entry that opens its default export.
72
- * `state` picks the branch of a `(state) => UIScreen` screen (ignored by a value export).
73
- */
74
- const resolveScreenTarget = (
75
- root: string, file: string, opts: { state?: string, dir?: string } = {},
76
- ): ScreenTarget => {
77
- const abs = findScreenFile(root, file, opts.dir)
78
- const rel = relative(root, abs)
79
- if (rel.startsWith("..") || isAbsolute(rel)) {
80
- throw new CliError(
81
- `"${file}" is outside the project (${root}) — a screen renders as part of its project, so its ` +
82
- `imports and asset() files resolve from the same tree.`)
83
- }
84
- const key = toKey(rel)
85
-
86
- // A file with no default export compiles to an empty render; say so here instead.
87
- if (!/^\s*export\s+default\b/m.test(readFileSync(abs, "utf8"))) {
88
- throw new CliError(
89
- `${key.slice(1)} has no default export. A screen file ends with ` +
90
- `\`export default UIScreen([...])\` — or \`export default (state) => UIScreen([...])\` for screen states.`)
91
- }
92
-
93
- // Same wrapper the design board compiles (designCompile.ts): a function export is invoked with the
94
- // chosen state (a bare call uses the parameter's default = the canonical state), a value is used
95
- // as-is, and either way the screen is opened so the runtime has something to render.
96
- const invoke = opts.state !== undefined ? `screen(${JSON.stringify(opts.state)})` : "screen()"
97
- const spec = "." + key.replace(/\.[^./]+$/, "")
98
- const design = designDirOf(root, key)
99
- return {
100
- entryPath: SCREEN_ENTRY_PATH,
101
- entry: {
102
- path: SCREEN_ENTRY_PATH,
103
- type: "text",
104
- text: `import screen from ${JSON.stringify(spec)}\n`
105
- + `const __screen = typeof screen === "function" ? ${invoke} : screen\n`
106
- + `__screen.open()\n`,
107
- },
108
- frame: design ? screenFrame(design.dir, design.id) : undefined,
109
- label: (design ? design.id : key.slice(1)) + (opts.state !== undefined ? `@${opts.state}` : ""),
110
- }
111
- }
112
-
113
- /** The screen target named on the command line (`render <file>`), or undefined when no screen was
114
- * named. Rejects the flag combinations that would fight over the entrypoint. */
115
- export type ScreenArgs = { file?: string, state?: string, entry?: string, dir?: string }
116
- export const screenTargetFrom = (root: string, a: ScreenArgs): ScreenTarget | undefined => {
117
- if (a.file === undefined) {
118
- if (a.state !== undefined) throw new CliError("--state picks a screen's state — name the screen file too: lecodes render <file> --state <name>")
119
- return undefined
120
- }
121
- if (a.entry !== undefined) throw new CliError("Use either a screen file or --entry, not both — each names the module to compile from.")
122
- return resolveScreenTarget(root, a.file, { state: a.state, dir: a.dir })
123
- }
1
+ import { existsSync, readFileSync, statSync } from "node:fs"
2
+ import { isAbsolute, join, relative, resolve } from "node:path"
3
+ import { screenFrame } from "../project/designMeta"
4
+ import { CliError } from "../cli"
5
+ import { toKey } from "../project/paths"
6
+ import type { CompileEntry } from "./projectCompile"
7
+
8
+ /*
9
+ * Rendering ONE screen module instead of the whole app — `lecodes render <file>` (and
10
+ * `--desktop`). A design screen (`design/screens/login.ts`) never boots itself: it default-exports
11
+ * a `UIScreen`, or a `(state) => UIScreen` function for its states, and the design board / snapshot
12
+ * supply the `.open()`. This builds the same virtual entry for the normal project pipeline, so any
13
+ * file written that way — inside design/ or not — renders with the full `lecodes render` flag set
14
+ * (--png/--clip/--script/--device/…) against the project's real imports and assets.
15
+ *
16
+ * Kept in step with compile/designCompile.ts, which does the same wrapping per screen for the board.
17
+ */
18
+
19
+ /** Extensions a screen module may carry (so `render design/screens/login` works too). */
20
+ const SCREEN_EXTS = [".ts", ".tsx", ".js", ".jsx", ".mjs"]
21
+
22
+ /** Where the generated entry lives — the project root, so its import specifier is the screen's
23
+ * own project key ("./design/screens/login"). Leading dot: never a real project file. */
24
+ const SCREEN_ENTRY_PATH = "/.screen-entry.ts"
25
+
26
+ /** The design folder a bare screen id (`lecodes render login`) is looked up in. */
27
+ const DEFAULT_DESIGN_DIR = "design"
28
+
29
+ export type ScreenTarget = {
30
+ /** The virtual entry module — hand it to the compiler alongside the project's own entries. */
31
+ entry: CompileEntry
32
+ /** Path of that entry, for `entryOverride`. */
33
+ entryPath: string
34
+ /** The board frame from the design folder's meta.json, when this IS a design screen. */
35
+ frame?: [number, number]
36
+ /** How the screen is named in progress notes ("design/screens/login.ts", "login@empty"). */
37
+ label: string
38
+ }
39
+
40
+ /** `lecodes render <file|screen-id>` — the positional a screen render is asked for by. */
41
+
42
+ /** Whether the argument names a path at all (vs a bare screen id). */
43
+ const hasSeparator = (file: string) => file.includes("/") || file.includes("\\")
44
+
45
+ /** Find the screen file: a path (with or without extension), or a bare id resolved against the
46
+ * design folder's screens/ (`lecodes render login`, honoring --dir). */
47
+ const findScreenFile = (root: string, file: string, dirFlag: string | undefined): string => {
48
+ const candidates = [resolve(process.cwd(), file)]
49
+ if (!hasSeparator(file)) {
50
+ candidates.push(join(root, (dirFlag ?? DEFAULT_DESIGN_DIR).replace(/[/\\]+$/, ""), "screens", file))
51
+ }
52
+ for (const base of candidates) {
53
+ if (existsSync(base) && statSync(base).isFile()) return base
54
+ for (const ext of SCREEN_EXTS) if (existsSync(base + ext)) return base + ext
55
+ }
56
+ throw new CliError(
57
+ `No screen file at "${file}".` +
58
+ (hasSeparator(file) ? "" : ` Pass a path, or a screen id that exists in ${dirFlag ?? DEFAULT_DESIGN_DIR}/screens/.`))
59
+ }
60
+
61
+ /** The design folder a screen file belongs to ("<root>/design" for "/design/screens/login.ts"),
62
+ * or null when it isn't laid out as a design screen. */
63
+ const designDirOf = (root: string, key: string): { dir: string, id: string } | null => {
64
+ const m = /^\/(.*)screens\/([^/]+)\.[^./]+$/.exec(key)
65
+ if (!m) return null
66
+ const dir = join(root, m[1])
67
+ return existsSync(join(dir, "meta.json")) ? { dir, id: m[2] } : null
68
+ }
69
+
70
+ /**
71
+ * Resolve `<file>` into the virtual entry that opens its default export.
72
+ * `state` picks the branch of a `(state) => UIScreen` screen (ignored by a value export).
73
+ */
74
+ const resolveScreenTarget = (
75
+ root: string, file: string, opts: { state?: string, dir?: string } = {},
76
+ ): ScreenTarget => {
77
+ const abs = findScreenFile(root, file, opts.dir)
78
+ const rel = relative(root, abs)
79
+ if (rel.startsWith("..") || isAbsolute(rel)) {
80
+ throw new CliError(
81
+ `"${file}" is outside the project (${root}) — a screen renders as part of its project, so its ` +
82
+ `imports and asset() files resolve from the same tree.`)
83
+ }
84
+ const key = toKey(rel)
85
+
86
+ // A file with no default export compiles to an empty render; say so here instead.
87
+ if (!/^\s*export\s+default\b/m.test(readFileSync(abs, "utf8"))) {
88
+ throw new CliError(
89
+ `${key.slice(1)} has no default export. A screen file ends with ` +
90
+ `\`export default UIScreen([...])\` — or \`export default (state) => UIScreen([...])\` for screen states.`)
91
+ }
92
+
93
+ // Same wrapper the design board compiles (designCompile.ts): a function export is invoked with the
94
+ // chosen state (a bare call uses the parameter's default = the canonical state), a value is used
95
+ // as-is, and either way the screen is opened so the runtime has something to render.
96
+ const invoke = opts.state !== undefined ? `screen(${JSON.stringify(opts.state)})` : "screen()"
97
+ const spec = "." + key.replace(/\.[^./]+$/, "")
98
+ const design = designDirOf(root, key)
99
+ return {
100
+ entryPath: SCREEN_ENTRY_PATH,
101
+ entry: {
102
+ path: SCREEN_ENTRY_PATH,
103
+ type: "text",
104
+ text: `import screen from ${JSON.stringify(spec)}\n`
105
+ + `const __screen = typeof screen === "function" ? ${invoke} : screen\n`
106
+ + `__screen.open()\n`,
107
+ },
108
+ frame: design ? screenFrame(design.dir, design.id) : undefined,
109
+ label: (design ? design.id : key.slice(1)) + (opts.state !== undefined ? `@${opts.state}` : ""),
110
+ }
111
+ }
112
+
113
+ /** The screen target named on the command line (`render <file>`), or undefined when no screen was
114
+ * named. Rejects the flag combinations that would fight over the entrypoint. */
115
+ export type ScreenArgs = { file?: string, state?: string, entry?: string, dir?: string }
116
+ export const screenTargetFrom = (root: string, a: ScreenArgs): ScreenTarget | undefined => {
117
+ if (a.file === undefined) {
118
+ if (a.state !== undefined) throw new CliError("--state picks a screen's state — name the screen file too: lecodes render <file> --state <name>")
119
+ return undefined
120
+ }
121
+ if (a.entry !== undefined) throw new CliError("Use either a screen file or --entry, not both — each names the module to compile from.")
122
+ return resolveScreenTarget(root, a.file, { state: a.state, dir: a.dir })
123
+ }
@@ -1,4 +1,5 @@
1
- import { createReadStream, statSync } from "node:fs"
1
+ import { createHash } from "node:crypto"
2
+ import { createReadStream, readFileSync, statSync } from "node:fs"
2
3
  import { join } from "node:path"
3
4
  import { Readable } from "node:stream"
4
5
  import type { Manifest } from "../project/manifest"
@@ -14,6 +15,7 @@ import { toKey } from "../project/paths"
14
15
  import { makeStackDemangler } from "../compile/nativeStack"
15
16
  import type { WebRunner } from "./webRunner"
16
17
  import { createProjectBackend } from "./projectBackend"
18
+ import { buildHalf, halfModule, projectHalves, type ProjectHalf } from "../web/halves"
17
19
 
18
20
  /*
19
21
  * The `lecodes dev` LAN server: serves the project to devices and hot-reloads it on change. It
@@ -35,7 +37,12 @@ import { createProjectBackend } from "./projectBackend"
35
37
  * the native prelude; the player owns reload (ws / version
36
38
  * polling) and the log backchannel. A phone is sent here at once
37
39
  * GET /t/<tok>/lite/[file] the page of Web Lite (lecodes-web-lite's dist), opened as
38
- * lite/?dev=.. — the same bundle.js?t=web, the same reload and logs
40
+ * lite/?dev=.. — the same bundle.js?t=web, the same reload and logs;
41
+ * the project's own web halves (web/halves.ts) under it as a build
42
+ * has them — lite/plugins/<folder>/index.js (built with each
43
+ * compile), its `web.static` folder from the project, and
44
+ * lite/plugins.json naming them for their ids (before the
45
+ * first-party half of the id: its fallback)
39
46
  * POST /t/<tok>/server/api/<id> the project's backend (its `*.server.ts` files, run on this
40
47
  * WS /t/<tok>/server/ws machine — projectBackend.ts): the wire of sdk/src/runtime/rpc.ts,
41
48
  * the one the runner serves on le.codes
@@ -63,8 +70,12 @@ export type DevServerOptions = {
63
70
  /** What is served under /web/ (src/dev/webRunner.ts): the player, and the page around it; absent =
64
71
  * no browser route. */
65
72
  web?: WebRunner | null
66
- /** The page of Web Lite (lecodes-web-lite's dist), served under /lite/; absent = no such route. */
73
+ /** The page of Web Lite (lecodes-web-lite's dist), served under /lite/; absent = no such route.
74
+ * With it, every compile builds the project's own web halves too (web/halves.ts). */
67
75
  lite?: string | null
76
+ /** Serve over https with this certificate (`lecodes web dev --https`, dev/https.ts); `origin`
77
+ * says https then. */
78
+ tls?: { cert: string, key: string }
68
79
  /** Diagnostics (compile errors, device logs); defaults to stderr-style silence-safe noop. */
69
80
  log?: (msg: string) => void
70
81
  /** The folder of the backend's database (default `.lecodes/db`; the browser tests keep their own). */
@@ -189,9 +200,12 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
189
200
  const onWarning = (w: string) => { warnings.push(w) }
190
201
  const serverBundle = await backend.compile(entries)
191
202
  const js = await compileProject({ entries, name, publicUrl: origin, entryOverride, header: true, fontsHeader, iconsHeader, onWarning, serverUrl: serverBundle ? serverUrl : undefined })
203
+ // the page's own halves, before anything is told to reload: one that does not build fails the
204
+ // compile, the page keeps the last good ones
205
+ const halvesChanged = lite ? await buildHalves() : false
192
206
  // the backend takes the new bundle once the app compiled, and before devices are told to reload
193
207
  warnings.push(...await backend.run(serverBundle))
194
- const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs)
208
+ const changed = bundleJs === null || stripVolatile(js) !== stripVolatile(bundleJs) || halvesChanged
195
209
  if (changed) {
196
210
  bundleJs = js
197
211
  v++
@@ -203,6 +217,36 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
203
217
  }
204
218
  }
205
219
 
220
+ // ---- the project's own web halves (web/halves.ts), for the page of Web Lite -----------------------
221
+ // Built with every compile; plugins.json names each by its hash (`index.js?h=…`), so a page that
222
+ // imported a half sees it changed and loads again (a half is imported once per document).
223
+ let halves: { half: ProjectHalf, js: string, hash: string }[] = []
224
+ const buildHalves = async (): Promise<boolean> => {
225
+ const next: typeof halves = []
226
+ for (const half of projectHalves(root)) {
227
+ const js = await buildHalf(half, { dev: true })
228
+ next.push({ half, js, hash: createHash("sha256").update(js).digest("hex").slice(0, 10) })
229
+ }
230
+ const key = (list: typeof halves) => list.map((h) => `${h.half.id}=${h.half.folder}@${h.hash}`).join(";")
231
+ const changed = key(next) !== key(halves)
232
+ halves = next
233
+ return changed
234
+ }
235
+ /** The page's plugins.json: Web Lite's first-party halves, the project's BEFORE theirs — a
236
+ * project's half of an id is tried first, the first-party one takes the page where it declines
237
+ * (`"ar": ["plugins/ar-8thwall/index.js?h=…", "plugins/ar/index-….js"]`, as `web build` writes). */
238
+ const liteTable = (): Record<string, string | string[]> => {
239
+ let plugins: Record<string, string> = {}
240
+ try { plugins = (JSON.parse(readFileSync(join(lite!, "plugins.json"), "utf8")) as { plugins?: Record<string, string> }).plugins ?? {} } catch { /* a page without plugins */ }
241
+ const table: Record<string, string | string[]> = { ...plugins }
242
+ for (const { half, hash } of halves) {
243
+ const own = `${halfModule(half)}?h=${hash}`
244
+ const firstParty = plugins[half.id]
245
+ table[half.id] = firstParty !== undefined ? [own, firstParty] : own
246
+ }
247
+ return table
248
+ }
249
+
206
250
  // ---- the project's backend (projectBackend.ts), started with the first compile that has server files
207
251
  const backend = createProjectBackend({ root, log, dbDir: options.dbDir, command: "lecodes dev" })
208
252
  /** What the app's stubs call. Baked with the advertised origin; a bundle is served with the origin
@@ -259,9 +303,11 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
259
303
  const transport = transportOf(url)
260
304
  return transport === "web" ? js : js + "\n" + renderPrelude({ origin: reqOrigin, base, transport, v })
261
305
  }
262
- // The backend's url likewise: the app calls it through the origin the bundle was fetched from.
306
+ // Every url of this server the bundle names likewise — its assets, its shaders, the backend: the
307
+ // app reaches them through the origin the bundle was fetched from. (`web dev` advertises
308
+ // localhost; a phone that opened the page by the LAN address would otherwise ask ITSELF.)
263
309
  const rebased = (js: string, reqOrigin: string) =>
264
- reqOrigin === origin ? js : js.split(serverUrl).join(`${reqOrigin}${base}/server`)
310
+ reqOrigin === origin ? js : js.split(`${origin}${base}/`).join(`${reqOrigin}${base}/`)
265
311
  const web = options.web ?? null
266
312
  const lite = options.lite ?? null
267
313
  /** A file of a built folder; the entry files revalidate — a CLI update swaps them under the same
@@ -280,7 +326,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
280
326
 
281
327
  const handle = async (req: Request, ip: string | undefined): Promise<Response> => {
282
328
  const host = req.headers.get("host")
283
- const reqOrigin = host ? `http://${host}` : origin
329
+ const reqOrigin = host ? `${options.tls ? "https" : "http"}://${host}` : origin
284
330
  const url = new URL(req.url)
285
331
  const path = url.pathname
286
332
  // Browsers ask for it on every page load (the /web/ runner) — keep the console clean.
@@ -317,6 +363,15 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
317
363
  url.searchParams.set("dev", "..")
318
364
  return redirect(`${base}/lite/?${url.searchParams.toString()}${url.hash}`)
319
365
  }
366
+ if (rel === "/plugins.json") return json(200, { plugins: liteTable() })
367
+ const own = halves.find(({ half }) => rel.startsWith(`/plugins/${half.folder}/`))
368
+ if (own) {
369
+ const sub = rel.slice(`/plugins/${own.half.folder}/`.length)
370
+ if (sub === "index.js") return text(200, "text/javascript", own.js)
371
+ const folder = own.half.static
372
+ if (folder && sub.startsWith(`${folder}/`)) return built(own.half.dir, sub)
373
+ return text(404, "text/plain", "Not found")
374
+ }
320
375
  return built(lite, rel === "/" ? "index.html" : rel.slice(1))
321
376
  }
322
377
  if (route === "/version") {
@@ -372,6 +427,7 @@ export const startDevServer = async (options: DevServerOptions): Promise<DevServ
372
427
  const server = await serve({
373
428
  port: options.port,
374
429
  hostname: options.bind,
430
+ tls: options.tls,
375
431
  fetch: (req, peer) => handle(req, peer.ip),
376
432
  websocket: (req, peer) => {
377
433
  const path = new URL(req.url).pathname
@@ -0,0 +1,50 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ /*
6
+ * The certificate of `lecodes web dev --https`. A phone's camera and motion sensors are given to a
7
+ * SECURE page only, and on the LAN that is an https one: a self-signed certificate, made once and
8
+ * kept in ~/.lecodes/dev-https/ — for every address it was asked for (localhost, the machine's LAN
9
+ * addresses), made again when one is new or when it is old. No authority signed it: the browser
10
+ * warns, the person accepts it once per device.
11
+ */
12
+
13
+ const DIR = () => join(homedir(), ".lecodes", "dev-https")
14
+ /** Days a certificate is valid: under the 398 a phone's browser accepts for a server's. */
15
+ const DAYS = 397
16
+ /** Made again after this many: before it runs out. */
17
+ const RENEW_DAYS = 300
18
+
19
+ type Kept = { names: string[], made: number }
20
+
21
+ /** The certificate for `names` (+ localhost); `dir` is where it is kept (a test's own). */
22
+ export const devCertificate = async (names: string[], dir = DIR()): Promise<{ cert: string, key: string }> => {
23
+ const files = { cert: join(dir, "cert.pem"), key: join(dir, "key.pem"), meta: join(dir, "names.json") }
24
+ let kept: Kept | null = null
25
+ try { kept = JSON.parse(readFileSync(files.meta, "utf8")) as Kept } catch { /* none yet */ }
26
+ const wanted = [...new Set(["localhost", "127.0.0.1", ...names])]
27
+ const fresh = kept && Date.now() - kept.made < RENEW_DAYS * 86_400_000 && wanted.every((n) => kept!.names.includes(n))
28
+ if (fresh && existsSync(files.cert) && existsSync(files.key)) {
29
+ return { cert: readFileSync(files.cert, "utf8"), key: readFileSync(files.key, "utf8") }
30
+ }
31
+ // (only here: the one command that asks for it loads it)
32
+ const { generate } = await import("selfsigned")
33
+ const all = [...new Set([...(kept?.names ?? []), ...wanted])]
34
+ const ip = /^\d{1,3}(\.\d{1,3}){3}$/
35
+ const made = generate([{ name: "commonName", value: "LeCodes dev" }, { name: "organizationName", value: "LeCodes dev server" }], {
36
+ days: DAYS, keySize: 2048, algorithm: "sha256",
37
+ extensions: [
38
+ { name: "basicConstraints", cA: false },
39
+ { name: "keyUsage", digitalSignature: true, keyEncipherment: true },
40
+ { name: "extKeyUsage", serverAuth: true },
41
+ // altName types of X.509: 2 = a DNS name, 7 = an IP address
42
+ { name: "subjectAltName", altNames: all.map((n) => (ip.test(n) ? { type: 7, ip: n } : { type: 2, value: n })) },
43
+ ],
44
+ })
45
+ mkdirSync(dir, { recursive: true })
46
+ writeFileSync(files.cert, made.cert)
47
+ writeFileSync(files.key, made.private)
48
+ writeFileSync(files.meta, JSON.stringify({ names: all, made: Date.now() } satisfies Kept))
49
+ return { cert: made.cert, key: made.private }
50
+ }
package/src/dev/serve.ts CHANGED
@@ -1,5 +1,6 @@
1
- import { createServer, type IncomingMessage } from "node:http"
2
- import { Readable } from "node:stream"
1
+ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"
2
+ import { createServer as createHttpsServer } from "node:https"
3
+ import { Readable, type Duplex } from "node:stream"
3
4
  import { upgradeWebSocket } from "./wsServer"
4
5
 
5
6
  /*
@@ -35,6 +36,8 @@ export type ServeOptions = {
35
36
  port: number
36
37
  /** Bind address (default 0.0.0.0). */
37
38
  hostname?: string
39
+ /** https (and wss) with this certificate and key, PEM. */
40
+ tls?: { cert: string, key: string }
38
41
  fetch: (req: Request, peer: Peer) => Response | Promise<Response>
39
42
  /** A websocket asks to connect: return what takes the open socket, or null to refuse. Decided
40
43
  * from the request alone (its path), before the handshake. */
@@ -64,6 +67,7 @@ const serveBun = (opts: ServeOptions): Served => {
64
67
  server = Bun.serve<BunSocketData>({
65
68
  port: opts.port,
66
69
  hostname: opts.hostname ?? "0.0.0.0",
70
+ ...(opts.tls ? { tls: { cert: opts.tls.cert, key: opts.tls.key } } : {}),
67
71
  // seconds, the most Bun takes: an endpoint of a project's backend may think for 30
68
72
  idleTimeout: 255,
69
73
  async fetch(req, srv) {
@@ -110,33 +114,35 @@ const serveBun = (opts: ServeOptions): Served => {
110
114
 
111
115
  // ───────────────────────────── Node ─────────────────────────────
112
116
 
113
- const requestOf = (req: IncomingMessage, upgrade = false): Request => {
117
+ const requestOf = (req: IncomingMessage, upgrade = false, scheme = "http"): Request => {
114
118
  const headers = new Headers()
115
119
  for (const [k, v] of Object.entries(req.headers)) {
116
120
  if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(", ") : v)
117
121
  }
118
122
  const method = req.method ?? "GET"
119
123
  const bodyless = upgrade || method === "GET" || method === "HEAD"
120
- return new Request(new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).href, {
124
+ return new Request(new URL(req.url ?? "/", `${scheme}://${req.headers.host ?? "localhost"}`).href, {
121
125
  method, headers,
122
126
  ...(bodyless ? {} : { body: Readable.toWeb(req), duplex: "half" }),
123
127
  } as unknown as RequestInit)
124
128
  }
125
129
 
126
130
  const serveNode = async (opts: ServeOptions): Promise<Served> => {
127
- const server = createServer((req, res) => {
131
+ const scheme = opts.tls ? "https" : "http"
132
+ const onRequest = (req: IncomingMessage, res: ServerResponse) => {
128
133
  void (async () => {
129
134
  let response: Response
130
- try { response = await opts.fetch(requestOf(req), { ip: req.socket.remoteAddress }) }
135
+ try { response = await opts.fetch(requestOf(req, false, scheme), { ip: req.socket.remoteAddress }) }
131
136
  catch (e) { response = failure(opts, e) }
132
137
  res.writeHead(response.status, Object.fromEntries(response.headers))
133
138
  if (!response.body || req.method === "HEAD") { res.end(); return }
134
139
  Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream).on("error", () => res.destroy()).pipe(res)
135
140
  })()
136
- })
137
- server.on("upgrade", (req, socket) => {
141
+ }
142
+ const server = opts.tls ? createHttpsServer({ cert: opts.tls.cert, key: opts.tls.key }, onRequest) : createServer(onRequest)
143
+ server.on("upgrade", (req: IncomingMessage, socket: Duplex) => {
138
144
  let accept: ((socket: Socket) => SocketHandlers) | null = null
139
- try { accept = opts.websocket?.(requestOf(req, true), { ip: req.socket.remoteAddress }) ?? null }
145
+ try { accept = opts.websocket?.(requestOf(req, true, scheme), { ip: req.socket.remoteAddress }) ?? null }
140
146
  catch (e) { opts.onError?.(e) }
141
147
  if (!accept) { socket.destroy(); return }
142
148
  let handlers: SocketHandlers | null = null
@@ -34,6 +34,8 @@ type Built = {
34
34
  sources: string[]
35
35
  /** The arguments of the `vite build` that makes it. */
36
36
  build: string[]
37
+ /** A command run after it in the package (a build step outside vite), when there is one. */
38
+ after?: string[]
37
39
  }
38
40
 
39
41
  const PLAYER: Built = {
@@ -47,10 +49,11 @@ const PAGE: Built = {
47
49
  }
48
50
 
49
51
  // Web Lite (lecodes-web-lite's dist): the app as a web page, DOM and CSS — what `lecodes web`
50
- // serves and builds from.
52
+ // serves and builds from. Its plugins' halves are a step of their own after the vite build (which
53
+ // empties dist/): build/plugins.ts writes dist/plugins/ and plugins.json from the plugin repo.
51
54
  const LITE: Built = {
52
- pkg: "lecodes-web-lite", dist: "dist", entry: "lite.js", vendored: "web-lite", build: [],
53
- sources: ["hosts/web-lite/src", "hosts/web-lite/index.html", "renderers/web-lite/src", "renderers/web-shared/src", "hosts/web-player/src/dev.ts", "engines/gl-lite/src"],
55
+ pkg: "lecodes-web-lite", dist: "dist", entry: "lite.js", vendored: "web-lite", build: [], after: ["bun", "build/plugins.ts"],
56
+ sources: ["hosts/web-lite/src", "hosts/web-lite/index.html", "hosts/web-lite/build", "renderers/web-lite/src", "renderers/web-shared/src", "hosts/web-player/src/dev.ts", "engines/gl-lite/src", "hosts/plugins/plugins"],
54
57
  }
55
58
 
56
59
  /** The built page of Web Lite, or null when this CLI has none. */
@@ -71,6 +74,11 @@ const resolve = (built: Built, log: (message: string) => void): string | null =>
71
74
  log(`Building ${built.pkg} (its sources are newer than its build)…`)
72
75
  const done = spawnSync("bunx", ["vite", "build", ...built.build], { cwd: checkout, stdio: "pipe", encoding: "utf8", shell: process.platform === "win32" })
73
76
  if (done.status !== 0) log(`${built.pkg} did not build — the browser gets the build that is there:\n${done.stderr || done.stdout}`)
77
+ else if (built.after) {
78
+ const [command, ...args] = built.after
79
+ const step = spawnSync(command!, args, { cwd: checkout, stdio: "pipe", encoding: "utf8", shell: process.platform === "win32" })
80
+ if (step.status !== 0) log(`${built.pkg}: \`${built.after.join(" ")}\` failed — its build is without that step:\n${step.stderr || step.stdout}`)
81
+ }
74
82
  }
75
83
  if (has(dist)) return dist
76
84
  }
@@ -1,9 +1,8 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import type { Project, ProjectAsset } from "./api"
3
3
  import type { LocalScan } from "../project/localFiles"
4
- import { classifyKind, isTextKind } from "../project/localFiles"
4
+ import { isTextKind } from "../project/localFiles"
5
5
  import { buildPaths } from "../project/materialize"
6
- import { basename } from "node:path"
7
6
 
8
7
  /*
9
8
  * Compares the local working tree against the project's current server state — the basis for
@@ -49,7 +48,7 @@ export const computeServerDiff = (project: Project, scan: LocalScan): ServerDiff
49
48
  const local = localByPath.get(path)
50
49
 
51
50
  if (local && !server) {
52
- const text = isTextKind(classifyKind(basename(path)))
51
+ const text = isTextKind(local.kind)
53
52
  entries.push({ path, status: "added", binary: !text, serverText: "", localText: text ? readFileSync(local.absPath, "utf8") : undefined })
54
53
  continue
55
54
  }
@@ -1,7 +1,8 @@
1
1
  // A plugin repo's `Package.swift`, from the manifests: the plugins with an iOS half as ONE SwiftPM
2
- // package — a target per plugin (its sources `plugins/<id>/ios`, the packages its manifest names)
3
- // and a product per plugin. Each plugin is its own MODULE, so two plugins may declare a type of the
4
- // same name, and an Xcode project links a plugin as a product instead of compiling its folder.
2
+ // package — a target per plugin (its sources `plugins/<id>/ios`, the packages its manifest names,
3
+ // a binary target per prebuilt xcframework of `ios.binaries`, by its path in the repo) and a product
4
+ // per plugin. Each plugin is its own MODULE, so two plugins may declare a type of the same name, and
5
+ // an Xcode project links a plugin as a product instead of compiling its folder.
5
6
  //
6
7
  // The targets depend on the LeCodes SDK as a package on disk: `LECODES_IOS_SDK_PATH`, else the path
7
8
  // the repo's package.json names (`lecodes.iosSdkPath`), else the sibling `../lecodes` (a checkout
@@ -11,7 +12,7 @@
11
12
  // of its own (templates/ios.ts), pinned by plugins.lock.json.
12
13
 
13
14
  import type { PluginManifest } from "./manifest"
14
- import { pascal } from "./manifest"
15
+ import { binaryName, pascal } from "./manifest"
15
16
 
16
17
  export class PackageError extends Error {}
17
18
 
@@ -30,7 +31,8 @@ export const DEFAULT_SDK_PATH = "../lecodes"
30
31
 
31
32
  /** The text of the repo's Package.swift for `plugins` (every manifest; those without an iOS half are
32
33
  * left out); `sdkPath` is the repo's own SDK location (package.json `lecodes.iosSdkPath`). Throws
33
- * PackageError when two plugins pin one package at different versions. */
34
+ * PackageError when two plugins pin one package at different versions, or when a binary's name is
35
+ * another target's (one name, one target of the package). */
34
36
  export const emitPackageSwift = (plugins: { id: string, manifest: PluginManifest }[], sdkPath: string = DEFAULT_SDK_PATH): string => {
35
37
  const ios = plugins.filter(p => p.manifest.ios !== null)
36
38
  const packages = new Map<string, { url: string, exact?: string, from?: string, by: string }>()
@@ -44,6 +46,14 @@ export const emitPackageSwift = (plugins: { id: string, manifest: PluginManifest
44
46
  }
45
47
  }
46
48
  const moduleOf = (p: { id: string, manifest: PluginManifest }) => iosModule(p.id, p.manifest)
49
+ const targetNames = new Map<string, string>(ios.map(p => [moduleOf(p), `the module of "${p.id}"`]))
50
+ for (const p of ios) {
51
+ for (const path of p.manifest.ios!.binaries ?? []) {
52
+ const prior = targetNames.get(binaryName(path))
53
+ if (prior) throw new PackageError(`"${p.id}" carries the binary ${binaryName(path)} and ${binaryName(path)} is ${prior} — one name is one target of the package`)
54
+ targetNames.set(binaryName(path), `a binary of "${p.id}"`)
55
+ }
56
+ }
47
57
 
48
58
  const products = ios.map(p => ` .library(name: "${moduleOf(p)}", targets: ["${moduleOf(p)}"]),`)
49
59
  const dependencies = [
@@ -51,9 +61,11 @@ export const emitPackageSwift = (plugins: { id: string, manifest: PluginManifest
51
61
  ...[...packages.values()].map(pkg => ` .package(url: "${pkg.url}", ${pkg.exact ? `exact: "${pkg.exact}"` : `from: "${pkg.from}"`}),`),
52
62
  ]
53
63
  const targets = ios.map(p => {
64
+ const binaries = p.manifest.ios!.binaries ?? []
54
65
  const deps = [
55
66
  `.product(name: "LeCodes", package: "lecodes")`,
56
67
  ...(p.manifest.ios!.packages ?? []).flatMap(pkg => pkg.products.map(name => `.product(name: "${name}", package: "${swiftPackageName(pkg.url)}")`)),
68
+ ...binaries.map(path => `"${binaryName(path)}"`),
57
69
  ]
58
70
  return [
59
71
  ` // ${p.id}`,
@@ -62,6 +74,7 @@ export const emitPackageSwift = (plugins: { id: string, manifest: PluginManifest
62
74
  ` dependencies: [${deps.join(", ")}],`,
63
75
  ` path: "plugins/${p.id}/ios"`,
64
76
  ` ),`,
77
+ ...binaries.map(path => ` .binaryTarget(name: "${binaryName(path)}", path: "plugins/${p.id}/${path.split(/[\\/]/).join("/")}"),`),
65
78
  ].join("\n")
66
79
  })
67
80