lecodes-cli 2.4.0 → 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.
- package/dist/index.js +18930 -1829
- package/package.json +10 -9
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/ar-D6oDL4pw.js +1 -0
- package/runtime/web/assets/ar-mVXX3kRQ.js +2 -0
- package/runtime/web/assets/qrAnchorWorker-Cb5E8cF-.js +1 -0
- package/runtime/web/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
- package/runtime/web/index.html +1 -0
- package/runtime/web/player.js +12 -12
- package/runtime/web-lite/assets/{index-By07xWGA.js → index-oPsDD47E.js} +1 -1
- package/runtime/web-lite/assets/scene3d-BAl_QQyi.js +1105 -0
- package/runtime/web-lite/features.json +2 -2
- package/runtime/web-lite/index.html +2 -1
- package/runtime/web-lite/lite.js +9 -9
- package/runtime/web-lite/plugins/ar/_virtual_lecodes-half-resources-CNYmVR88.js +1 -0
- package/runtime/web-lite/plugins/ar/assets/qrAnchorWorker-DcxVKAqo.js +1 -0
- package/runtime/web-lite/plugins/ar/assets/qr_anchor_wasm_bg-D_ePkCCu.wasm +0 -0
- package/runtime/web-lite/plugins/ar/index-C2uuuLhz.js +2 -0
- package/runtime/web-lite/plugins.json +1 -1
- package/src/commands/app/index.ts +26 -9
- package/src/commands/app/shared.ts +72 -22
- package/src/commands/plugin.ts +3 -3
- package/src/commands/push.ts +2 -2
- package/src/commands/web.ts +12 -5
- package/src/compile/collect.ts +2 -3
- package/src/compile/collectLocal.ts +13 -2
- package/src/compile/designCompile.ts +2 -3
- package/src/compile/projectCompile.ts +3 -0
- package/src/dev/devServer.ts +63 -7
- package/src/dev/https.ts +50 -0
- package/src/dev/serve.ts +15 -9
- package/src/dev/webRunner.ts +11 -3
- package/src/platform/serverDiff.ts +2 -3
- package/src/plugin/emitWeb.ts +17 -8
- package/src/plugin/firstParty.ts +30 -0
- package/src/plugin/generate.ts +41 -21
- package/src/plugin/manifest.ts +33 -3
- package/src/project/localFiles.ts +44 -7
- package/src/web/build.ts +57 -11
- package/src/web/halves.ts +76 -0
- package/runtime/web/assets/ar-CHvaraG4.js +0 -1
- package/runtime/web-lite/assets/scene3d-D5R7Vq3f.js +0 -888
- package/runtime/web-lite/plugins/ar/index-CHvaraG4.js +0 -1
package/src/dev/devServer.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
//
|
|
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(
|
|
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 ?
|
|
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
|
package/src/dev/https.ts
ADDED
|
@@ -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 {
|
|
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 ?? "/",
|
|
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
|
|
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.
|
|
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
|
package/src/dev/webRunner.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
}
|
package/src/plugin/emitWeb.ts
CHANGED
|
@@ -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
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
|
|
369
|
-
|
|
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
|
|
372
|
-
*
|
|
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"`)
|
package/src/plugin/generate.ts
CHANGED
|
@@ -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
|
|
28
|
+
export { CONTRACT_FILE }
|
|
28
29
|
|
|
29
30
|
export type PluginDir = { id: string, dir: string }
|
|
30
31
|
|
|
31
|
-
/** The
|
|
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 (
|
|
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() &&
|
|
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
|
|
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 ? `${
|
|
70
|
-
text: emitSdk(contract, channel, { id
|
|
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
|
|
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
|
|
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", `${
|
|
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
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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) {
|
package/src/plugin/manifest.ts
CHANGED
|
@@ -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)
|