lecodes-cli 2.4.1 → 2.4.2

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 (44) hide show
  1. package/dist/index.js +18914 -1829
  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/index.ts +26 -9
  22. package/src/commands/app/shared.ts +72 -22
  23. package/src/commands/plugin.ts +3 -3
  24. package/src/commands/push.ts +2 -2
  25. package/src/commands/web.ts +12 -5
  26. package/src/compile/collect.ts +2 -3
  27. package/src/compile/collectLocal.ts +13 -2
  28. package/src/compile/designCompile.ts +2 -3
  29. package/src/compile/projectCompile.ts +3 -0
  30. package/src/dev/devServer.ts +63 -7
  31. package/src/dev/https.ts +50 -0
  32. package/src/dev/serve.ts +15 -9
  33. package/src/dev/webRunner.ts +11 -3
  34. package/src/platform/serverDiff.ts +2 -3
  35. package/src/plugin/emitWeb.ts +17 -8
  36. package/src/plugin/firstParty.ts +30 -0
  37. package/src/plugin/generate.ts +41 -21
  38. package/src/plugin/manifest.ts +33 -3
  39. package/src/project/localFiles.ts +44 -7
  40. package/src/web/build.ts +33 -9
  41. package/src/web/halves.ts +76 -0
  42. package/runtime/web/assets/ar-CHvaraG4.js +0 -1
  43. package/runtime/web-lite/assets/scene3d-Bew8g7Ws.js +0 -1081
  44. package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +0 -1
@@ -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
  }
@@ -358,18 +358,27 @@ export interface PluginGL {
358
358
  /** The pose of the scene's camera (its world matrix). */
359
359
  setCameraMatrix(scene: number, m16: ArrayLike<number>): void
360
360
  /** A projection the half computes (column-major, OpenGL clip conventions), with the near / far it
361
- * was built with, in place of the scene's fov projection. */
361
+ * was built with, in place of the scene's fov projection: drawn as it is — an off-centre one (a
362
+ * camera's intrinsics) too. Made for one view: a change of \`viewport\` wants a new one. */
362
363
  setProjection(scene: number, m16: ArrayLike<number>, near: number, far: number): void
363
364
  /** Back to the scene's own (fov) projection. */
364
365
  clearProjection(scene: number): void
365
- /** The scene's background: a playing video behind the scene, through \`uv\` — the affine map from
366
- * the viewport's normalized coordinates (0,0 top-left) to the video's, row-major
367
- * [a, b, tx, c, d, ty] (identity by default). null = none. */
368
- setBackground(scene: number, video: HTMLVideoElement | null, uv?: ArrayLike<number>): void
369
- /** The web has no external textures: a video is handed to setBackground as it is. Always null. */
366
+ /** The scene's background, behind the scene: a playing video (a camera's stream), or a canvas the
367
+ * half (or an engine it runs) draws the camera's picture on; null = none. \`uv\` = the affine map
368
+ * from the viewport's normalized coordinates (0,0 top-left) to the picture's, row-major
369
+ * [a, b, tx, c, d, ty] (identity by default). Web Lite shows the element itself: moved under the
370
+ * scene's canvas (its inline style replaced: absolute, the scene's box filled, \`object-fit: cover\`
371
+ * — \`uv\` is not read; a canvas sized to \`viewport\` × devicePixelRatio is shown 1:1), taken out of
372
+ * the page when it is replaced or cleared. The web player (a preview) shows a video only. */
373
+ setBackground(scene: number, source: HTMLVideoElement | HTMLCanvasElement | null, uv?: ArrayLike<number>): void
374
+ /** The web has no external textures: a video or a canvas is handed to setBackground as it is.
375
+ * Always null. */
370
376
  createExternalTexture(): null
371
- /** \`body\` before each frame (the page's animation frame), until the returned function is called —
372
- * or the world swap. */
377
+ /** \`body\` before each frame of the 3D engine, until the returned function is called — or the
378
+ * world swap: in the host's own animation frame, before the tick and the render, so what a body
379
+ * sets (a pose, a projection) is that frame's picture. A requestAnimationFrame callback of the
380
+ * half's own is a frame late unless the browser runs it before the host's (callbacks run in the
381
+ * order they were asked for). Web Lite runs the bodies while a scene is on show. */
373
382
  onFrame(body: () => void): () => void
374
383
  /** The composite of the scene's next frame — the 3D picture, not the UI over it: RGBA8, top row
375
384
  * first, width, height; null (0 × 0) when the scene is not the one presented. */
@@ -0,0 +1,30 @@
1
+ // The first-party plugins as the SDK carries them: `plugins/gen/<id>/` of its source tree — the
2
+ // contract.d.ts and the app's wrapper (sdk/), copies of the plugin repo's (core's
3
+ // scripts/vendor-plugins.ts). A project's HALF FOLDER (`implements` in its manifest) implements one
4
+ // of these contracts: the one of the SDK this CLI compiles with, so the glue `lecodes plugin gen`
5
+ // writes for the half and the wrapper in the bundle are of one contract.
6
+
7
+ import { existsSync, readFileSync, readdirSync } from "node:fs"
8
+ import { join } from "node:path"
9
+ import { sdkSourceDir } from "../compile/projectCompile"
10
+
11
+ const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
12
+
13
+ /** The SDK's copy of a first-party plugin's contract, or null when the SDK carries no such plugin. */
14
+ export const firstPartyContractFile = (id: string): string | null => {
15
+ if (!sdkSourceDir || !ID.test(id)) return null
16
+ const file = join(sdkSourceDir, "plugins", "gen", id, "contract.d.ts")
17
+ return existsSync(file) ? file : null
18
+ }
19
+
20
+ /** The first-party plugins the SDK carries, by id. */
21
+ export const firstPartyIds = (): string[] => {
22
+ if (!sdkSourceDir) return []
23
+ const gen = join(sdkSourceDir, "plugins", "gen")
24
+ return existsSync(gen) ? readdirSync(gen).filter((id) => existsSync(join(gen, id, "contract.d.ts"))).sort() : []
25
+ }
26
+
27
+ /** A copy's text as the plugin wrote it: the vendoring points the import of the SDK at its own file
28
+ * (`../../../plugin`), a contract reads `lecodes-sdk/plugin` and nothing else. */
29
+ export const firstPartyContractText = (file: string): string =>
30
+ readFileSync(file, "utf8").replace(/from "(?:\.\.\/)+plugin"/g, `from "lecodes-sdk/plugin"`)
@@ -16,7 +16,8 @@ import { EmitError, emitSdk } from "./emitSdk"
16
16
  import { emitSwift } from "./emitSwift"
17
17
  import { emitWeb } from "./emitWeb"
18
18
  import { lower } from "./lower"
19
- import { MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
19
+ import { CONTRACT_FILE, MANIFEST_FILE, ManifestError, readManifestIfAny, type PluginManifest } from "./manifest"
20
+ import { firstPartyContractFile, firstPartyContractText } from "./firstParty"
20
21
  import { missingDeclarations, usesFeatures, usesPermissions, type PermissionKind } from "./permissions"
21
22
  import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "./emitPackage"
22
23
 
@@ -24,19 +25,31 @@ import { DEFAULT_SDK_PATH, PACKAGE_FILE, PackageError, emitPackageSwift } from "
24
25
  // compared and pasted; `\` is Windows' own).
25
26
  const shown = (from: string, to: string): string => relative(from, to).split(sep).join("/")
26
27
 
27
- export const CONTRACT_FILE = "contract.d.ts"
28
+ export { CONTRACT_FILE }
28
29
 
29
30
  export type PluginDir = { id: string, dir: string }
30
31
 
31
- /** The plugins under `path`: itself when it holds a contract, else every `plugins/<id>/` that does. */
32
+ /** The first-party plugin a folder's manifest says it implements (a project's half folder), or null.
33
+ * Read loosely: what the manifest holds is readManifest's to say. */
34
+ const implementsOf = (dir: string): string | null => {
35
+ try {
36
+ const raw = JSON.parse(readFileSync(join(dir, MANIFEST_FILE), "utf8").replace(/^/, "")) as { implements?: unknown }
37
+ return typeof raw.implements === "string" ? raw.implements : null
38
+ } catch { return null }
39
+ }
40
+
41
+ /** A plugin's folder: one with its contract, or a half folder that implements a first-party one's. */
42
+ const isPluginDir = (dir: string): boolean => existsSync(join(dir, CONTRACT_FILE)) || implementsOf(dir) !== null
43
+
44
+ /** The plugins under `path`: itself when it is one, else every `plugins/<id>/` that is. */
32
45
  export const findPlugins = (path: string): PluginDir[] => {
33
46
  const root = resolve(path)
34
- if (existsSync(join(root, CONTRACT_FILE))) return [{ id: basename(root), dir: root }]
47
+ if (isPluginDir(root)) return [{ id: basename(root), dir: root }]
35
48
  const holder = join(root, "plugins")
36
49
  if (!existsSync(holder)) return []
37
50
  return readdirSync(holder).sort()
38
51
  .map(id => ({ id, dir: join(holder, id) }))
39
- .filter(p => statSync(p.dir).isDirectory() && existsSync(join(p.dir, CONTRACT_FILE)))
52
+ .filter(p => statSync(p.dir).isDirectory() && isPluginDir(p.dir))
40
53
  }
41
54
 
42
55
  /** The CLI carries no TypeScript: a contract is read with the one its author has — the plugin's own
@@ -54,34 +67,39 @@ export const loadTypescript = (dir: string): typeof tsModule => {
54
67
 
55
68
  export type Generated = { file: string, text: string }
56
69
 
57
- /** Everything one plugin's contract generates, as `path → text`. Throws ContractError / EmitError. */
70
+ /** Everything one plugin's contract generates, as `path → text`. Throws ContractError / EmitError /
71
+ * ManifestError. A half folder (`implements`) is generated from the contract of the plugin it
72
+ * implements, the SDK's copy (firstParty.ts), and under that plugin's id: `web/ar.gen.ts` registers
73
+ * the service "ar"; it has no sdk/ — the app's side is the SDK's wrapper. */
58
74
  export const generatePlugin = (ts: typeof tsModule, plugin: PluginDir): { contract: Contract, manifest: PluginManifest | null, files: Generated[] } => {
59
- const file = join(plugin.dir, CONTRACT_FILE)
60
- const contract = readContract(ts, file, readFileSync(file, "utf8"))
61
75
  // The manifest is held to the folders the plugin has: a half the manifest does not name is built
62
76
  // into no host.
63
77
  const manifest = readManifestIfAny(plugin.dir, plugin.id)
78
+ const implemented = manifest?.implements
79
+ const file = implemented ? firstPartyContractFile(implemented)! : join(plugin.dir, CONTRACT_FILE)
80
+ const contract = readContract(ts, file, implemented ? firstPartyContractText(file) : readFileSync(file, "utf8"))
64
81
  checkPermissions(plugin, contract, manifest)
65
- const handWritten = existsSync(join(plugin.dir, "sdk", `${plugin.id}.ts`))
82
+ const id = implemented ?? plugin.id
83
+ const handWritten = existsSync(join(plugin.dir, "sdk", `${id}.ts`))
66
84
  const single = contract.channels.length === 1
67
- const files: Generated[] = contract.channels.map(channel => ({
85
+ const files: Generated[] = implemented ? [] : contract.channels.map(channel => ({
68
86
  // One channel is the rule; a contract with several names each file after its channel.
69
- file: join(plugin.dir, "sdk", single ? `${plugin.id}.gen.ts` : `${plugin.id}.${channel.iface}.gen.ts`),
70
- text: emitSdk(contract, channel, { id: plugin.id, handWritten }),
87
+ file: join(plugin.dir, "sdk", single ? `${id}.gen.ts` : `${id}.${channel.iface}.gen.ts`),
88
+ text: emitSdk(contract, channel, { id, handWritten }),
71
89
  }))
72
90
  // A platform's file is generated where its FOLDER exists: the plugin says which platforms it has
73
91
  // by having them.
74
92
  if (existsSync(join(plugin.dir, "ios"))) {
75
93
  const name = single ? contract.channels[0]!.iface : contract.channels[0]!.iface + "Plugin"
76
- files.push({ file: join(plugin.dir, "ios", `${name}.gen.swift`), text: emitSwift(lower(contract), { id: plugin.id }) })
94
+ files.push({ file: join(plugin.dir, "ios", `${name}.gen.swift`), text: emitSwift(lower(contract), { id }) })
77
95
  }
78
96
  if (existsSync(join(plugin.dir, "android"))) {
79
97
  const name = single ? contract.channels[0]!.iface : contract.channels[0]!.iface + "Plugin"
80
98
  const pkg = kotlinPackage(join(plugin.dir, "android"))
81
- files.push({ file: join(pkg.dir, `${name}.gen.kt`), text: emitKotlin(lower(contract), { id: plugin.id, package: pkg.name }) })
99
+ files.push({ file: join(pkg.dir, `${name}.gen.kt`), text: emitKotlin(lower(contract), { id, package: pkg.name }) })
82
100
  }
83
101
  if (existsSync(join(plugin.dir, "web"))) {
84
- files.push({ file: join(plugin.dir, "web", `${plugin.id}.gen.ts`), text: emitWeb(lower(contract), { id: plugin.id }) })
102
+ files.push({ file: join(plugin.dir, "web", `${id}.gen.ts`), text: emitWeb(lower(contract), { id }) })
85
103
  const foreign = hostImports(join(plugin.dir, "web"))
86
104
  if (foreign.length) throw new EmitError(`web/ imports the host — a web half depends on its generated file, the document it is handed and the browser:\n${foreign.map(f => ` ${f}`).join("\n")}`)
87
105
  }
@@ -164,7 +182,8 @@ export const repoSdkPath = (root: string): string => {
164
182
  }
165
183
 
166
184
  export type GenReport = {
167
- plugins: { id: string, contract: Contract, written: string[], stale: string[], fresh: string[] }[]
185
+ /** `implements`: a half folder's first-party plugin, whose contract it was generated from. */
186
+ plugins: { id: string, implements?: string, contract: Contract, written: string[], stale: string[], fresh: string[] }[]
168
187
  /** The repo's own files (its Package.swift), when the path is a repo of plugins. */
169
188
  repo: { written: string[], stale: string[], fresh: string[] }
170
189
  /** Contracts that did not read or emit, as printable text. */
@@ -175,7 +194,7 @@ export type GenReport = {
175
194
  * to `path`. A failed contract does not stop the others. */
176
195
  export const generate = (path: string, opts: { check?: boolean } = {}): GenReport => {
177
196
  const plugins = findPlugins(path)
178
- if (plugins.length === 0) throw new CliError(`No plugin under ${resolve(path)}: a plugin is a directory with a ${CONTRACT_FILE} (one, or plugins/<id>/ of a repo).`)
197
+ if (plugins.length === 0) throw new CliError(`No plugin under ${resolve(path)}: a plugin is a directory with a ${CONTRACT_FILE}, or a half folder whose ${MANIFEST_FILE} says what it \`implements\` (one, or plugins/<id>/ of a repo or a project).`)
179
198
  const ts = loadTypescript(plugins[0]!.dir)
180
199
  const root = resolve(path)
181
200
  const report: GenReport = { plugins: [], repo: { written: [], stale: [], fresh: [] }, failures: [] }
@@ -194,7 +213,7 @@ export const generate = (path: string, opts: { check?: boolean } = {}): GenRepor
194
213
  try {
195
214
  const { contract, manifest, files } = generatePlugin(ts, plugin)
196
215
  if (manifest) manifests.push({ id: plugin.id, manifest })
197
- const entry = { id: plugin.id, contract, written: [] as string[], stale: [] as string[], fresh: [] as string[] }
216
+ const entry = { id: plugin.id, implements: manifest?.implements, contract, written: [] as string[], stale: [] as string[], fresh: [] as string[] }
198
217
  for (const f of files) place(f, entry)
199
218
  report.plugins.push(entry)
200
219
  } catch (e) {
@@ -206,9 +225,10 @@ export const generate = (path: string, opts: { check?: boolean } = {}): GenRepor
206
225
  }
207
226
 
208
227
  // A repo of plugins (not one plugin): its Package.swift, from every manifest that read. Written
209
- // only when every contract made it — a repo with a broken plugin keeps the package it has.
210
- const isRepo = !existsSync(join(root, CONTRACT_FILE))
211
- if (isRepo && report.failures.length === 0 && manifests.some(m => m.manifest.ios)) {
228
+ // only when every contract made it — a repo with a broken plugin keeps the package it has — and
229
+ // only when one has an iOS half: a project whose plugins are web halves has no package to make.
230
+ const isRepo = !(plugins.length === 1 && plugins[0]!.dir === root)
231
+ if (isRepo && report.failures.length === 0 && manifests.some(m => m.manifest.ios !== null)) {
212
232
  try {
213
233
  place({ file: join(root, PACKAGE_FILE), text: emitPackageSwift(manifests, repoSdkPath(root)) }, report.repo)
214
234
  } catch (e) {
@@ -9,11 +9,18 @@
9
9
  //
10
10
  // Desktop plugins are another system (a prebuilt library behind a C ABI) with a manifest of their
11
11
  // own shape; nothing of it is read here.
12
+ //
13
+ // A HALF FOLDER of a project (`implements: "<first-party id>"`) has no contract and no sdk/ of its
14
+ // own: it implements the contract of a first-party plugin the SDK carries, for the platforms it has
15
+ // a half of — app.json names it for those (`"ar": { "web": "./plugins/ar-8thwall" }`), the
16
+ // first-party halves stay everywhere else.
12
17
 
13
18
  import { existsSync, readFileSync, statSync } from "node:fs"
14
- import { join } from "node:path"
19
+ import { isAbsolute, join } from "node:path"
20
+ import { firstPartyContractFile, firstPartyIds } from "./firstParty"
15
21
 
16
22
  export const MANIFEST_FILE = "lecodes-plugin.json"
23
+ export const CONTRACT_FILE = "contract.d.ts"
17
24
 
18
25
  /** A SwiftPM dependency the iOS half links. */
19
26
  export type IosPackage = { url: string, exact?: string, from?: string, products: string[] }
@@ -30,6 +37,9 @@ export type PluginManifest = {
30
37
  * contract between a bundle, the runtime and a half. */
31
38
  sdk: string
32
39
  description?: string
40
+ /** A HALF FOLDER: the first-party plugin whose contract the folder implements (it has no
41
+ * contract.d.ts and no sdk/ of its own — the app's side is the SDK's wrapper). */
42
+ implements?: string
33
43
  ios: {
34
44
  /** The Swift module the half is built as in a shell. Default: `LeCodes` + the id in PascalCase. */
35
45
  module?: string
@@ -56,6 +66,11 @@ export type PluginManifest = {
56
66
  dependencies?: Record<string, string>
57
67
  /** The half's bundler resources, by the name `register` receives them under. */
58
68
  resources?: Record<string, WebResource>
69
+ /** A folder of the plugin (relative to it) put beside the built module AS IS, names unhashed: a
70
+ * runtime the half loads by its own url (`new URL("<static>/x.js", import.meta.url)`), which
71
+ * fetches its files relative to itself. A project's half; the first-party halves are built in
72
+ * Web Lite's page with `resources`. */
73
+ static?: string
59
74
  } | null
60
75
  }
61
76
 
@@ -69,7 +84,7 @@ const ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/
69
84
  const VERSION = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/
70
85
  const RANGE = /^[\^~]?\d+\.\d+\.\d+$/
71
86
  const PLATFORMS = ["ios", "android", "web"] as const
72
- const KNOWN = new Set(["id", "name", "version", "sdk", "description", ...PLATFORMS])
87
+ const KNOWN = new Set(["id", "name", "version", "sdk", "description", "implements", ...PLATFORMS])
73
88
  const GONE: Record<string, string> = {
74
89
  provides: "the channels are the contract's (contract.d.ts)",
75
90
  js: "the wrapper is found by convention (sdk/)",
@@ -78,7 +93,7 @@ const GONE: Record<string, string> = {
78
93
  const BRANCH: Record<(typeof PLATFORMS)[number], Set<string>> = {
79
94
  ios: new Set(["module", "register", "infoPlist", "entitlements", "packages"]),
80
95
  android: new Set(["module", "register", "dependencies", "gradlePlugins"]),
81
- web: new Set(["register", "dependencies", "resources"]),
96
+ web: new Set(["register", "dependencies", "resources", "static"]),
82
97
  }
83
98
 
84
99
  const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
@@ -116,6 +131,13 @@ export const readManifest = (dir: string, id: string): PluginManifest => {
116
131
  const sdk = text("sdk")
117
132
  if (sdk !== undefined && !RANGE.test(sdk)) problems.push(`sdk: "${sdk}" — a range of the SDK's version: ^2.0.0`)
118
133
  if (raw.description !== undefined && typeof raw.description !== "string") problems.push("description: a text")
134
+ if (raw.implements !== undefined) {
135
+ const impl = raw.implements
136
+ if (typeof impl !== "string" || !ID.test(impl)) problems.push(`implements: the id of the first-party plugin whose contract the folder implements ("ar")`)
137
+ else if (!firstPartyContractFile(impl)) problems.push(`implements: "${impl}" is no first-party plugin of this SDK (${firstPartyIds().join(", ") || "it carries none"})`)
138
+ if (existsSync(join(dir, CONTRACT_FILE))) problems.push(`implements: the folder implements another plugin's contract — it has no ${CONTRACT_FILE} of its own`)
139
+ if (isFolder(join(dir, "sdk"))) problems.push("implements: the folder implements another plugin — its app side is the SDK's, it has no sdk/ of its own")
140
+ }
119
141
 
120
142
  for (const platform of PLATFORMS) {
121
143
  const branch = raw[platform]
@@ -181,6 +203,14 @@ export const readManifest = (dir: string, id: string): PluginManifest => {
181
203
  }
182
204
  }
183
205
  }
206
+ const folder = web.static
207
+ if (folder !== undefined) {
208
+ const segments = typeof folder === "string" ? folder.split(/[\\/]/) : []
209
+ if (typeof folder !== "string" || folder.trim() === "" || isAbsolute(folder) || segments.some((s) => s === ".." || s === "." || s === "")) {
210
+ problems.push(`web.static: a folder of the plugin, by its path in it ("xr-runtime")`)
211
+ } else if (!isFolder(join(dir, folder))) problems.push(`web.static: ${folder} is not a folder of the plugin`)
212
+ else if (folder === "index.js") problems.push(`web.static: "index.js" is the built module's name`)
213
+ }
184
214
  }
185
215
 
186
216
  if (problems.length) throw new ManifestError(file, problems)