lecodes-cli 2.0.3 → 2.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2580 -872
- package/package.json +16 -11
- package/runtime/sdk-types.json +1 -1
- package/runtime/web/VERSION.json +3 -3
- package/runtime/web/assets/creator-2d-CYlV77UR.js +1 -0
- package/runtime/web/assets/creator-2d-nG7rGEmq.wasm +0 -0
- package/runtime/web/assets/{creator-full-D2dSDdDj.wasm → creator-full-Cla1R8N6.wasm} +0 -0
- package/runtime/web/assets/creator-full-DmfoULcB.js +1 -0
- package/runtime/web/assets/creator-full.js-D8SMHuCt.symbols +8419 -0
- package/runtime/web/assets/creator-ui-CbtoaRlz.js +1 -0
- package/runtime/web/assets/creator-ui-DZaoxIpX.wasm +0 -0
- package/runtime/web/player.js +17 -17
- package/runtime/web-lite/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/runtime/web-lite/assets/ar-BOdQ-wV4.js +1 -0
- package/runtime/web-lite/assets/basis-C64VHDVD.js +1 -0
- package/runtime/web-lite/assets/basis_transcoder-VXdx5NbI.wasm +0 -0
- package/runtime/web-lite/assets/canvas-WxiusXCB.js +1 -0
- package/runtime/web-lite/assets/creatorAnim-BOwKr-C1.js +1 -0
- package/runtime/web-lite/assets/creatorParticles-DYlv4g1J.js +1 -0
- package/runtime/web-lite/assets/creatorPhysics-k7wL0kV1.js +1 -0
- package/runtime/web-lite/assets/draco-BiISTFcR.js +118 -0
- package/runtime/web-lite/assets/draco_decoder-DsQ12WqX.wasm +0 -0
- package/runtime/web-lite/assets/index-3CbsYWmo.js +2 -0
- package/runtime/web-lite/assets/neutral_ibl128-D-lzdkLN.ktx +0 -0
- package/runtime/web-lite/assets/pager-Db5BY7FB.js +1 -0
- package/runtime/web-lite/assets/plugins-CuEPHbHL.js +1 -0
- package/runtime/web-lite/assets/roboto-cyrillic-DVBNKCCH.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-cyrillic-ext-1gPz9dK9.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-DVLYEgdx.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-greek-ext-D98D44EL.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-cAJwcdBQ.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-latin-ext-t610Sqxq.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-rest-C7JkyNHT.woff2 +0 -0
- package/runtime/web-lite/assets/roboto-vietnamese-w-p-nKZZ.woff2 +0 -0
- package/runtime/web-lite/assets/scene3d-CX7MAqmN.js +866 -0
- package/runtime/web-lite/assets/tween-BnWYhJ7P.js +1 -0
- package/runtime/web-lite/assets/video-BLj61nDx.js +1 -0
- package/runtime/web-lite/assets/vlist-DrV2Q7P5.js +1 -0
- package/runtime/web-lite/assets/worker-Caf-yYEI.js +2 -0
- package/runtime/web-lite/features.json +134 -0
- package/runtime/web-lite/lite.js +90 -13
- package/runtime/web-lite/plugins/camera/index-5XOM8eHl.js +1 -0
- package/runtime/web-lite/plugins/geolocation/index-BjTKN9_P.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-CQBO044A.js +1 -0
- package/runtime/web-lite/plugins/map/_virtual_lecodes-half-resources-BYwqQkWQ.js +1 -0
- package/runtime/web-lite/plugins/map/assets/maplibre-gl-worker-DeQFNGa4.js +8 -0
- package/runtime/web-lite/plugins/map/index-BTBHQ264.js +1 -0
- package/runtime/web-lite/plugins/map/mapImpl-C6E1SwCS.js +823 -0
- package/runtime/web-lite/plugins/qr-scanner/index-CK_cYoJR.js +1 -0
- package/runtime/web-lite/plugins.json +8 -0
- package/src/commands/compile.ts +7 -1
- package/src/commands/desktop.ts +7 -4
- package/src/commands/dev.ts +4 -14
- package/src/commands/render.ts +6 -2
- package/src/commands/test.ts +25 -9
- package/src/commands/update/index.ts +5 -4
- package/src/compile/projectCompile.ts +47 -1
- package/src/dev/androidDev.ts +1 -1
- package/src/dev/backendWorker.ts +138 -0
- package/src/dev/devServer.ts +119 -115
- package/src/dev/localBackend.ts +214 -0
- package/src/dev/projectBackend.ts +199 -0
- package/src/dev/serve.ts +164 -0
- package/src/hosts/archives.ts +41 -7
- package/src/hosts/cmgenTool.ts +6 -9
- package/src/hosts/desktopRenderer.ts +6 -5
- package/src/hosts/liteRunner.ts +52 -9
- package/src/hosts/matcTool.ts +5 -22
- package/src/hosts/peerInstall.ts +7 -7
- package/src/hosts/peers.ts +1 -0
- package/src/hosts/releases.ts +6 -0
- package/src/index.ts +5 -1
- package/src/plugin/generate.ts +10 -6
- package/src/web/build.ts +45 -5
- package/runtime/web/assets/creator-2d-D9WKYM6h.js +0 -1
- package/runtime/web/assets/creator-2d-ukvEmNEp.wasm +0 -0
- package/runtime/web/assets/creator-full-BBXzQlhq.js +0 -1
- package/runtime/web/assets/creator-full.js-BYswGsqR.symbols +0 -8124
- package/runtime/web/assets/creator-ui-DIc92ion.wasm +0 -0
- package/runtime/web/assets/creator-ui-_1Z_AX2M.js +0 -1
- package/runtime/web-lite/assets/Roboto-Variable-DHm9jpd-.woff2 +0 -0
- package/runtime/web-lite/assets/creatorTween-PF8B6T8V.wasm +0 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { rmSync } from "node:fs"
|
|
2
|
+
import { join, relative } from "node:path"
|
|
3
|
+
import { createChannelHub } from "lecodes-sdk/server"
|
|
4
|
+
import { compileServer, hasServerFiles, type CompileEntry } from "../compile/projectCompile"
|
|
5
|
+
import { loadPeer } from "../hosts/peerInstall"
|
|
6
|
+
import { LECODES_DIR } from "../project/manifest"
|
|
7
|
+
import { readAppConfigAt } from "../commands/app/shared"
|
|
8
|
+
import { startLocalBackend, type LocalBackend } from "./localBackend"
|
|
9
|
+
import { serve, type Served, type Socket, type SocketHandlers } from "./serve"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The project's backend ON THE WIRE, on this machine: its server bundle run by localBackend.ts, put
|
|
13
|
+
* behind the routes the SDK's client transport speaks (sdk/src/runtime/rpc.ts) —
|
|
14
|
+
*
|
|
15
|
+
* POST <base>/api/<id> an endpoint call, the envelope always HTTP 200 (the client reads `ok`)
|
|
16
|
+
* WS <base>/ws the channel socket: the SDK's hub, the one the runner serves on le.codes
|
|
17
|
+
*
|
|
18
|
+
* Two users: the dev server mounts the routes under its token path (`lecodes dev`, `web dev`, the
|
|
19
|
+
* browser tests), and `runBackend` listens on a port of its own for the commands that run a bundle
|
|
20
|
+
* on a host this CLI starts (`desktop run`, `serve`, `render --desktop`, `test --desktop`).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type ServerBundle = Awaited<ReturnType<typeof compileServer>>
|
|
24
|
+
|
|
25
|
+
export type ProjectBackendOptions = {
|
|
26
|
+
root: string
|
|
27
|
+
log: (msg: string) => void
|
|
28
|
+
/** The database's folder (default `.lecodes/db`). */
|
|
29
|
+
dbDir?: string
|
|
30
|
+
/** The command, for the note of the database peer's install. */
|
|
31
|
+
command: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ProjectBackend = {
|
|
35
|
+
/** The server bundle of these entries; null when the project has no server files. */
|
|
36
|
+
compile: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<ServerBundle | null>
|
|
37
|
+
/** Run this bundle from now on (null = the project has no server side any more: stop). Rejects
|
|
38
|
+
* when it does not load; resolves to what to tell the developer. */
|
|
39
|
+
run: (bundle: ServerBundle | null) => Promise<string[]>
|
|
40
|
+
/** A request under the backend's base; `route` is the path after it ("/api/<id>"). */
|
|
41
|
+
handle: (req: Request, route: string, ip: string | undefined) => Promise<Response>
|
|
42
|
+
/** The channel socket (`<base>/ws`). */
|
|
43
|
+
socket: (req: Request, ip: string | undefined) => (conn: Socket) => SocketHandlers
|
|
44
|
+
/** Start again on an EMPTY database (a test's case). */
|
|
45
|
+
reset: () => Promise<void>
|
|
46
|
+
/** Resolves once the database is closed. */
|
|
47
|
+
close: () => Promise<void>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const CORS = {
|
|
51
|
+
"access-control-allow-origin": "*",
|
|
52
|
+
"access-control-allow-methods": "POST, GET, OPTIONS",
|
|
53
|
+
"access-control-allow-headers": "authorization, content-type",
|
|
54
|
+
"access-control-max-age": "86400",
|
|
55
|
+
}
|
|
56
|
+
const bearerOf = (req: Request): string | null => /^Bearer\s+(.+)$/i.exec(req.headers.get("authorization") ?? "")?.[1]?.trim() ?? null
|
|
57
|
+
const BODY_LIMIT = 8 * 1024 * 1024
|
|
58
|
+
|
|
59
|
+
export const createProjectBackend = (options: ProjectBackendOptions): ProjectBackend => {
|
|
60
|
+
const { root, log } = options
|
|
61
|
+
const dbDir = options.dbDir ?? join(root, LECODES_DIR, "db")
|
|
62
|
+
let backend: LocalBackend | null = null
|
|
63
|
+
let running: string | null = null
|
|
64
|
+
|
|
65
|
+
// The channel socket is the SDK's hub — the one the runner serves too.
|
|
66
|
+
const hub = createChannelHub<Socket>({
|
|
67
|
+
send: (conn, frame) => conn.send(frame),
|
|
68
|
+
subscribe: (_scope, ch, args, ctx) => backend
|
|
69
|
+
? backend.subscribe(ch, args, ctx)
|
|
70
|
+
: Promise.resolve({ ok: false as const, status: 404, message: "The project has no server files" }),
|
|
71
|
+
})
|
|
72
|
+
const sockets = new Set<Socket>()
|
|
73
|
+
const start = () => startLocalBackend({ root, dbDir, log, onPublish: (ch, group, data) => hub.publish("", ch, group, data) })
|
|
74
|
+
|
|
75
|
+
const envelope = (body: unknown) => new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json", "cache-control": "no-store", ...CORS } })
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
compile: async (entries, onWarning) => {
|
|
79
|
+
if (!hasServerFiles(entries)) return null
|
|
80
|
+
// the app's name, for the sign-in mail
|
|
81
|
+
const app = readAppConfigAt(root)
|
|
82
|
+
return compileServer({ entries, root, app: app ? { name: app.name } : undefined, onWarning })
|
|
83
|
+
},
|
|
84
|
+
run: async (bundle) => {
|
|
85
|
+
if (!bundle) {
|
|
86
|
+
await backend?.close()
|
|
87
|
+
backend = null
|
|
88
|
+
running = null
|
|
89
|
+
return []
|
|
90
|
+
}
|
|
91
|
+
// the embedded database is a peer, installed the first time a project defines models
|
|
92
|
+
if (Object.keys(bundle.manifest.modules).length && /\bdefineDb\s*\(/.test(bundle.code)) {
|
|
93
|
+
await loadPeer("marcidb-embedded", undefined, { for: `${options.command} (the project's database)` })
|
|
94
|
+
}
|
|
95
|
+
backend ??= start()
|
|
96
|
+
const deployed = await backend.deploy(bundle.code)
|
|
97
|
+
running = bundle.code
|
|
98
|
+
if (!deployed.changed) return []
|
|
99
|
+
const d = deployed.describe
|
|
100
|
+
const models = Object.keys(d.models).length
|
|
101
|
+
const count = (n: number, what: string) => `${n} ${what}${n === 1 ? "" : "s"}`
|
|
102
|
+
log(`server: ${count(d.endpoints.length, "endpoint")}, ${count(d.channels.length, "channel")}${models ? `, ${count(models, "model")} (database in ${relative(root, dbDir).replace(/\\/g, "/")})` : ""}`)
|
|
103
|
+
return deployed.notes.map((n) => `server: ${n}`)
|
|
104
|
+
},
|
|
105
|
+
handle: async (req, route, ip) => {
|
|
106
|
+
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS })
|
|
107
|
+
const id = route.startsWith("/api/") ? decodeURIComponent(route.slice("/api/".length)) : ""
|
|
108
|
+
if (!id || req.method !== "POST") return envelope({ ok: false, status: 404, message: "not an endpoint call" })
|
|
109
|
+
if (!backend) return envelope({ ok: false, status: 404, message: "The project has no server files" })
|
|
110
|
+
let args: unknown
|
|
111
|
+
try {
|
|
112
|
+
const body = await req.text()
|
|
113
|
+
args = body.length > BODY_LIMIT ? null : JSON.parse(body)?.args
|
|
114
|
+
} catch { args = null }
|
|
115
|
+
if (!Array.isArray(args)) return envelope({ ok: false, status: 400, message: "body must be {\"args\":[…]}" })
|
|
116
|
+
return envelope(await backend.invoke(id, args, { headers: Object.fromEntries(req.headers), ip, sessionToken: bearerOf(req) ?? undefined }))
|
|
117
|
+
},
|
|
118
|
+
socket: (req, ip) => (conn) => {
|
|
119
|
+
const c = hub.connect(conn, { ip, session: bearerOf(req) })
|
|
120
|
+
sockets.add(conn)
|
|
121
|
+
return {
|
|
122
|
+
onMessage: (raw) => { void c.message(raw) },
|
|
123
|
+
onClose: () => { sockets.delete(conn); c.close() },
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
reset: async () => {
|
|
127
|
+
if (!backend || running === null) return
|
|
128
|
+
await backend.close()
|
|
129
|
+
rmSync(dbDir, { recursive: true, force: true })
|
|
130
|
+
backend = start()
|
|
131
|
+
await backend.deploy(running)
|
|
132
|
+
},
|
|
133
|
+
close: async () => {
|
|
134
|
+
for (const conn of [...sockets]) conn.close()
|
|
135
|
+
await backend?.close()
|
|
136
|
+
backend = null
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ───────────────────────────── a backend for one run of a bundle ─────────────────────────────
|
|
142
|
+
|
|
143
|
+
export type RunBackend = {
|
|
144
|
+
/** Called by the compile, once it has the project's entries: the backend is started when there
|
|
145
|
+
* are server files, and its url — what the app's stubs call — is the answer. */
|
|
146
|
+
start: (entries: CompileEntry[], onWarning: (w: string) => void) => Promise<string | undefined>
|
|
147
|
+
/** Start again on an empty database. */
|
|
148
|
+
reset: () => Promise<void>
|
|
149
|
+
close: () => Promise<void>
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** A port that stays the same for a project from run to run: the app keeps its session under the
|
|
153
|
+
* backend's url, so a url that moved would sign the developer out at every run. */
|
|
154
|
+
const stablePort = (root: string): number => {
|
|
155
|
+
let h = 0
|
|
156
|
+
for (let i = 0; i < root.length; i++) h = (h * 31 + root.charCodeAt(i)) >>> 0
|
|
157
|
+
return 42000 + (h % 4000)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The backend of a project run on this machine by a host the CLI starts, listening on
|
|
162
|
+
* 127.0.0.1. `test`: a database of its own (`.lecodes/test-db`, emptied by `reset`) and any free
|
|
163
|
+
* port — a test never touches what `lecodes dev` and `desktop run` keep in `.lecodes/db`.
|
|
164
|
+
*/
|
|
165
|
+
export const runBackend = (options: { root: string, command: string, log: (msg: string) => void, test?: boolean }): RunBackend => {
|
|
166
|
+
const { root } = options
|
|
167
|
+
const dbDir = join(root, LECODES_DIR, options.test ? "test-db" : "db")
|
|
168
|
+
if (options.test) rmSync(dbDir, { recursive: true, force: true })
|
|
169
|
+
const backend = createProjectBackend({ root, dbDir, log: options.log, command: options.command })
|
|
170
|
+
let server: Served | null = null
|
|
171
|
+
|
|
172
|
+
const listen = (port: number) => serve({
|
|
173
|
+
port,
|
|
174
|
+
hostname: "127.0.0.1",
|
|
175
|
+
fetch: (req, peer) => {
|
|
176
|
+
const path = new URL(req.url).pathname
|
|
177
|
+
return path.startsWith("/server/") ? backend.handle(req, path.slice("/server".length), peer.ip) : new Response("Not found", { status: 404 })
|
|
178
|
+
},
|
|
179
|
+
websocket: (req, peer) => new URL(req.url).pathname === "/server/ws" ? backend.socket(req, peer.ip) : null,
|
|
180
|
+
onError: (e) => options.log(`request failed: ${e instanceof Error ? e.message : String(e)}`),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
start: async (entries, onWarning) => {
|
|
185
|
+
const bundle = await backend.compile(entries, onWarning)
|
|
186
|
+
if (!bundle) return undefined
|
|
187
|
+
for (const w of await backend.run(bundle)) onWarning(w)
|
|
188
|
+
// the project's port when it is free (two runs of one project at once: the second takes any)
|
|
189
|
+
server ??= await (options.test ? listen(0) : listen(stablePort(root)).catch(() => listen(0)))
|
|
190
|
+
return `http://127.0.0.1:${server.port}/server`
|
|
191
|
+
},
|
|
192
|
+
reset: () => backend.reset(),
|
|
193
|
+
close: async () => {
|
|
194
|
+
server?.close()
|
|
195
|
+
server = null
|
|
196
|
+
await backend.close()
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|
package/src/dev/serve.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage } from "node:http"
|
|
2
|
+
import { Readable } from "node:stream"
|
|
3
|
+
import { upgradeWebSocket } from "./wsServer"
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* ONE way for the CLI to listen: a handler of web-standard requests (`Request` → `Response`) and
|
|
7
|
+
* websockets as text messages. The CLI runs on two runtimes — Node (the npm install) and Bun (a
|
|
8
|
+
* checkout, the standalone binary) — and this file is the only place that knows which:
|
|
9
|
+
*
|
|
10
|
+
* Bun `Bun.serve`: its own HTTP server and websockets. Bun's `node:http` is NOT an option
|
|
11
|
+
* there — it emits 'upgrade' and then drops what is written to the socket, so a websocket
|
|
12
|
+
* never completes its handshake (probed on 1.3.14).
|
|
13
|
+
* Node `node:http` + the dependency-free websocket of ./wsServer.ts.
|
|
14
|
+
*
|
|
15
|
+
* A server of the CLI that needs a websocket is written against this, never against `node:http`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type Peer = {
|
|
19
|
+
/** The remote address, as the runtime reports it. */
|
|
20
|
+
ip: string | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Socket = {
|
|
24
|
+
send: (text: string) => void
|
|
25
|
+
close: () => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SocketHandlers = {
|
|
29
|
+
onMessage: (text: string) => void
|
|
30
|
+
/** Fired exactly once, however the connection ends. */
|
|
31
|
+
onClose: () => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type ServeOptions = {
|
|
35
|
+
port: number
|
|
36
|
+
/** Bind address (default 0.0.0.0). */
|
|
37
|
+
hostname?: string
|
|
38
|
+
fetch: (req: Request, peer: Peer) => Response | Promise<Response>
|
|
39
|
+
/** A websocket asks to connect: return what takes the open socket, or null to refuse. Decided
|
|
40
|
+
* from the request alone (its path), before the handshake. */
|
|
41
|
+
websocket?: (req: Request, peer: Peer) => ((socket: Socket) => SocketHandlers) | null
|
|
42
|
+
/** A handler that threw (the response is a 500). */
|
|
43
|
+
onError?: (e: unknown) => void
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type Served = {
|
|
47
|
+
/** The bound port (differs from `options.port` when 0 was passed). */
|
|
48
|
+
port: number
|
|
49
|
+
close: () => void
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const failure = (opts: ServeOptions, e: unknown): Response => {
|
|
53
|
+
opts.onError?.(e)
|
|
54
|
+
return new Response("Internal error", { status: 500, headers: { "content-type": "text/plain", "cache-control": "no-store" } })
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ───────────────────────────── Bun ─────────────────────────────
|
|
58
|
+
|
|
59
|
+
type BunSocketData = { accept: (socket: Socket) => SocketHandlers, handlers: SocketHandlers | null }
|
|
60
|
+
|
|
61
|
+
const serveBun = (opts: ServeOptions): Served => {
|
|
62
|
+
let server: ReturnType<typeof Bun.serve<BunSocketData>>
|
|
63
|
+
try {
|
|
64
|
+
server = Bun.serve<BunSocketData>({
|
|
65
|
+
port: opts.port,
|
|
66
|
+
hostname: opts.hostname ?? "0.0.0.0",
|
|
67
|
+
// seconds, the most Bun takes: an endpoint of a project's backend may think for 30
|
|
68
|
+
idleTimeout: 255,
|
|
69
|
+
async fetch(req, srv) {
|
|
70
|
+
const peer: Peer = { ip: srv.requestIP(req)?.address }
|
|
71
|
+
try {
|
|
72
|
+
if (req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
73
|
+
const accept = opts.websocket?.(req, peer)
|
|
74
|
+
if (!accept) return new Response("Not found", { status: 404 })
|
|
75
|
+
if (srv.upgrade(req, { data: { accept, handlers: null } })) return undefined as unknown as Response
|
|
76
|
+
return new Response("Expected a websocket upgrade", { status: 400 })
|
|
77
|
+
}
|
|
78
|
+
return await opts.fetch(req, peer)
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return failure(opts, e)
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
websocket: {
|
|
84
|
+
// A reload channel is silent for as long as nobody saves: Bun's pings keep it open.
|
|
85
|
+
idleTimeout: 960,
|
|
86
|
+
sendPings: true,
|
|
87
|
+
open(ws) {
|
|
88
|
+
ws.data.handlers = ws.data.accept({
|
|
89
|
+
send: (text) => { if (ws.readyState === 1) ws.send(text) },
|
|
90
|
+
close: () => { ws.close() },
|
|
91
|
+
})
|
|
92
|
+
},
|
|
93
|
+
message(ws, raw) {
|
|
94
|
+
ws.data.handlers?.onMessage(typeof raw === "string" ? raw : new TextDecoder().decode(raw))
|
|
95
|
+
},
|
|
96
|
+
close(ws) {
|
|
97
|
+
const h = ws.data.handlers
|
|
98
|
+
ws.data.handlers = null
|
|
99
|
+
h?.onClose()
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
} catch (e) {
|
|
104
|
+
// the code node's listen() reports, which callers match on
|
|
105
|
+
if ((e as { code?: string }).code === "EADDRINUSE") throw new Error(`EADDRINUSE: port ${opts.port} is already in use`)
|
|
106
|
+
throw e
|
|
107
|
+
}
|
|
108
|
+
return { port: server.port ?? opts.port, close: () => { void server.stop(true) } }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ───────────────────────────── Node ─────────────────────────────
|
|
112
|
+
|
|
113
|
+
const requestOf = (req: IncomingMessage, upgrade = false): Request => {
|
|
114
|
+
const headers = new Headers()
|
|
115
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
116
|
+
if (v !== undefined) headers.set(k, Array.isArray(v) ? v.join(", ") : v)
|
|
117
|
+
}
|
|
118
|
+
const method = req.method ?? "GET"
|
|
119
|
+
const bodyless = upgrade || method === "GET" || method === "HEAD"
|
|
120
|
+
return new Request(new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).href, {
|
|
121
|
+
method, headers,
|
|
122
|
+
...(bodyless ? {} : { body: Readable.toWeb(req), duplex: "half" }),
|
|
123
|
+
} as unknown as RequestInit)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const serveNode = async (opts: ServeOptions): Promise<Served> => {
|
|
127
|
+
const server = createServer((req, res) => {
|
|
128
|
+
void (async () => {
|
|
129
|
+
let response: Response
|
|
130
|
+
try { response = await opts.fetch(requestOf(req), { ip: req.socket.remoteAddress }) }
|
|
131
|
+
catch (e) { response = failure(opts, e) }
|
|
132
|
+
res.writeHead(response.status, Object.fromEntries(response.headers))
|
|
133
|
+
if (!response.body || req.method === "HEAD") { res.end(); return }
|
|
134
|
+
Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream).on("error", () => res.destroy()).pipe(res)
|
|
135
|
+
})()
|
|
136
|
+
})
|
|
137
|
+
server.on("upgrade", (req, socket) => {
|
|
138
|
+
let accept: ((socket: Socket) => SocketHandlers) | null = null
|
|
139
|
+
try { accept = opts.websocket?.(requestOf(req, true), { ip: req.socket.remoteAddress }) ?? null }
|
|
140
|
+
catch (e) { opts.onError?.(e) }
|
|
141
|
+
if (!accept) { socket.destroy(); return }
|
|
142
|
+
let handlers: SocketHandlers | null = null
|
|
143
|
+
const conn = upgradeWebSocket(req, socket, {
|
|
144
|
+
onMessage: (text) => { handlers?.onMessage(text) },
|
|
145
|
+
onClose: () => { handlers?.onClose() },
|
|
146
|
+
})
|
|
147
|
+
if (conn) handlers = accept(conn)
|
|
148
|
+
})
|
|
149
|
+
await new Promise<void>((resolve, reject) => {
|
|
150
|
+
server.once("error", reject)
|
|
151
|
+
server.listen(opts.port, opts.hostname ?? "0.0.0.0", () => {
|
|
152
|
+
server.removeListener("error", reject)
|
|
153
|
+
resolve()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
const address = server.address()
|
|
157
|
+
return {
|
|
158
|
+
port: typeof address === "object" && address !== null ? address.port : opts.port,
|
|
159
|
+
close: () => { server.close(); server.closeAllConnections?.() },
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const serve = async (opts: ServeOptions): Promise<Served> =>
|
|
164
|
+
typeof (globalThis as { Bun?: unknown }).Bun !== "undefined" ? serveBun(opts) : serveNode(opts)
|
package/src/hosts/archives.ts
CHANGED
|
@@ -5,8 +5,10 @@ import pkg from "../../package.json"
|
|
|
5
5
|
* `npm install <url>` puts the package in place under its own name.
|
|
6
6
|
*
|
|
7
7
|
* A url names one version, and which one is this CLI's to say: the headless host at the CLI's own
|
|
8
|
-
* version (one ABI set)
|
|
9
|
-
*
|
|
8
|
+
* version (one ABI set); an editor at the NEWEST version out that the CLI's peer range takes — the
|
|
9
|
+
* package's index.json in the bucket names what is out. So an editor is released with no release
|
|
10
|
+
* of the CLI: `lecodes update` brings it. With the index out of reach it is the range's own
|
|
11
|
+
* version, the one the CLI was published against (scripts/sync-peer-versions.ts). */
|
|
10
12
|
|
|
11
13
|
export const ARCHIVED_PEERS: ReadonlySet<string> = new Set(["lecodes-headless", "lecodes-design", "lecodes-3d-editor"])
|
|
12
14
|
|
|
@@ -16,17 +18,49 @@ export const packagesBase = (): string => (process.env.LECODES_PACKAGES_URL || B
|
|
|
16
18
|
|
|
17
19
|
export const archiveUrl = (name: string, version: string): string => `${packagesBase()}/${name}/${name}-${version}.tgz`
|
|
18
20
|
|
|
21
|
+
const numbers = (version: string): number[] => version.split(/[.-]/).slice(0, 3).map(Number)
|
|
22
|
+
const byVersion = (a: string, b: string): number => {
|
|
23
|
+
const pa = numbers(a), pb = numbers(b)
|
|
24
|
+
for (let i = 0; i < 3; i++) { const d = (pa[i] ?? 0) - (pb[i] ?? 0); if (d) return d }
|
|
25
|
+
return 0
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `^floor` takes it: not older, and the same leftmost number that is not zero. */
|
|
29
|
+
export const caretTakes = (floor: string, version: string): boolean => {
|
|
30
|
+
if (!/^\d+\.\d+\.\d+$/.test(version) || byVersion(version, floor) < 0) return false
|
|
31
|
+
const f = numbers(floor), v = numbers(version)
|
|
32
|
+
const held = f[0] ? 1 : f[1] ? 2 : 3
|
|
33
|
+
for (let i = 0; i < held; i++) if (f[i] !== v[i]) return false
|
|
34
|
+
return true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const asked = new Map<string, Promise<string[]>>()
|
|
38
|
+
/** What is out of a package, as its index names it; none with the index out of reach. */
|
|
39
|
+
const versionsOut = (name: string): Promise<string[]> => {
|
|
40
|
+
const url = `${packagesBase()}/${name}/index.json`
|
|
41
|
+
let answer = asked.get(url)
|
|
42
|
+
if (!answer) {
|
|
43
|
+
answer = fetch(`${url}?t=${Date.now()}`, { signal: AbortSignal.timeout(4000) })
|
|
44
|
+
.then(async (res) => res.ok ? ((await res.json()) as { versions?: string[] }).versions ?? [] : [])
|
|
45
|
+
.catch(() => [])
|
|
46
|
+
asked.set(url, answer)
|
|
47
|
+
}
|
|
48
|
+
return answer
|
|
49
|
+
}
|
|
50
|
+
|
|
19
51
|
/** The version of an archived peer this CLI installs; null for a peer that is none. */
|
|
20
|
-
export const archivedVersion = (name: string): string | null => {
|
|
52
|
+
export const archivedVersion = async (name: string): Promise<string | null> => {
|
|
21
53
|
if (!ARCHIVED_PEERS.has(name)) return null
|
|
22
54
|
if (name === "lecodes-headless") return pkg.version
|
|
23
55
|
const range = (pkg.peerDependencies as Record<string, string> | undefined)?.[name]
|
|
24
|
-
const
|
|
25
|
-
|
|
56
|
+
const floor = range?.replace(/^[\^~>=\s]+/, "")
|
|
57
|
+
if (!floor || !/^\d+\.\d+\.\d+/.test(floor)) return null
|
|
58
|
+
const newest = (await versionsOut(name)).filter((version) => caretTakes(floor, version)).sort(byVersion).pop()
|
|
59
|
+
return newest ?? floor
|
|
26
60
|
}
|
|
27
61
|
|
|
28
62
|
/** What an archived peer is installed by; null for a peer that is none. */
|
|
29
|
-
export const archiveSpec = (name: string): string | null => {
|
|
30
|
-
const version = archivedVersion(name)
|
|
63
|
+
export const archiveSpec = async (name: string): Promise<string | null> => {
|
|
64
|
+
const version = await archivedVersion(name)
|
|
31
65
|
return version ? archiveUrl(name, version) : null
|
|
32
66
|
}
|
package/src/hosts/cmgenTool.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs"
|
|
2
2
|
import { homedir } from "node:os"
|
|
3
3
|
import { join, resolve } from "node:path"
|
|
4
|
-
import { downloadReleaseArchive, fetchReleaseProduct, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
4
|
+
import { downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, newestForPlatform, semverNewer, type ManifestFile, type ManifestVersion } from "./releases"
|
|
5
5
|
import { CliError, note } from "../cli"
|
|
6
6
|
|
|
7
7
|
/*
|
|
@@ -11,9 +11,9 @@ import { CliError, note } from "../cli"
|
|
|
11
11
|
*
|
|
12
12
|
* The binary is NOT shipped with the CLI (most projects never bake an environment). It downloads on
|
|
13
13
|
* first use from the same S3 release bucket as the desktop renderer and matc (product "cmgen" in
|
|
14
|
-
* releases/versions.json — published by scripts/release/release-windows.ps1
|
|
15
|
-
* build's in-tree filament host tools, so the KTX it writes always
|
|
16
|
-
* ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
|
|
14
|
+
* releases/versions.json — published by scripts/release/release-windows.ps1 / release-ubuntu.sh /
|
|
15
|
+
* release-macos.sh from the desktop build's in-tree filament host tools, so the KTX it writes always
|
|
16
|
+
* matches the engine the hosts ship). Cache: ~/.lecodes/cmgen/cmgen-<version>/. LECODES_CMGEN overrides resolution entirely
|
|
17
17
|
* (local dev: hosts/desktop/build/creator-gl/filament/tools/cmgen/cmgen.exe).
|
|
18
18
|
*
|
|
19
19
|
* The CLI resolves it and hands the path to lecodes-assets through LECODES_CMGEN, so that package
|
|
@@ -29,11 +29,8 @@ const exeName = () => (process.platform === "win32" ? "cmgen.exe" : "cmgen")
|
|
|
29
29
|
const platformKey = (): string => {
|
|
30
30
|
if (process.platform === "win32") return "windows-x64"
|
|
31
31
|
if (process.platform === "linux") return "linux-x64"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
? "cmgen isn't published for macOS yet — build filament's host tools locally and set LECODES_CMGEN."
|
|
35
|
-
: `cmgen isn't published for ${process.platform}.`,
|
|
36
|
-
)
|
|
32
|
+
if (process.platform === "darwin") return macPlatformKey()
|
|
33
|
+
throw new CliError(`cmgen isn't published for ${process.platform}.`)
|
|
37
34
|
}
|
|
38
35
|
|
|
39
36
|
const cacheRoot = () => join(homedir(), ".lecodes", "cmgen")
|
|
@@ -2,7 +2,7 @@ import { execFileSync, spawn, spawnSync } from "node:child_process"
|
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, linkSync } from "node:fs"
|
|
3
3
|
import { homedir, tmpdir } from "node:os"
|
|
4
4
|
import { delimiter, join, resolve } from "node:path"
|
|
5
|
-
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, semverNewer, type ManifestVersion } from "./releases"
|
|
5
|
+
import { cachedArtifactSha, downloadReleaseArchive, fetchReleaseProduct, macPlatformKey, semverNewer, type ManifestVersion } from "./releases"
|
|
6
6
|
import { CliError, note, warnErr } from "../cli"
|
|
7
7
|
import { makeStackDemangler } from "../compile/nativeStack"
|
|
8
8
|
|
|
@@ -17,9 +17,10 @@ import { makeStackDemangler } from "../compile/nativeStack"
|
|
|
17
17
|
* ~/.lecodes/renderer/desktop-<version>/. LECODES_DESKTOP_EXE overrides resolution entirely
|
|
18
18
|
* (local dev: point it at hosts/desktop/build/lecodes-desktop.exe).
|
|
19
19
|
*
|
|
20
|
-
* Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64. The macOS host is
|
|
21
|
-
* published separately (
|
|
22
|
-
* as a notarized DMG under the same "desktop" product — so its
|
|
20
|
+
* Platforms: windows-x64 + linux (the ubuntu build) + macos-arm64 / macos-x64. The macOS host is
|
|
21
|
+
* built and published separately (hosts/desktop's LeCodes Desktop.app, one per CPU, via
|
|
22
|
+
* scripts/release/release-macos.sh) as a notarized DMG under the same "desktop" product — so its
|
|
23
|
+
* version line has gaps: a release
|
|
23
24
|
* may ship for Windows only. On darwin the cache therefore holds the newest published version
|
|
24
25
|
* that HAS a macOS artifact (never below the pin), and an app installed in /Applications is only
|
|
25
26
|
* the fallback for when the manifest can't be reached. The headless render contract
|
|
@@ -52,7 +53,7 @@ const platformKey = (renderer: DesktopRenderer = "gl"): string => {
|
|
|
52
53
|
throw new CliError(`The Vulkan desktop host is Windows-only (this is ${process.platform}). Use the default renderer.`)
|
|
53
54
|
}
|
|
54
55
|
if (process.platform === "linux") return "ubuntu-linux-x64"
|
|
55
|
-
if (process.platform === "darwin") return
|
|
56
|
+
if (process.platform === "darwin") return macPlatformKey()
|
|
56
57
|
throw new CliError(`The LeCodes Desktop host isn't available on ${process.platform}.`)
|
|
57
58
|
}
|
|
58
59
|
|
package/src/hosts/liteRunner.ts
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { spawn, type ChildProcess } from "node:child_process"
|
|
2
|
-
import { existsSync, mkdirSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { createServer } from "node:net"
|
|
4
4
|
import { dirname, join } from "node:path"
|
|
5
5
|
import { createInterface } from "node:readline"
|
|
6
6
|
import { CliError } from "../cli"
|
|
7
7
|
import { startDevServer, type DevServer } from "../dev/devServer"
|
|
8
|
-
import type {
|
|
8
|
+
import type { WebRunner } from "../dev/webRunner"
|
|
9
|
+
import { LECODES_DIR, type Manifest } from "../project/manifest"
|
|
9
10
|
import { distRoot } from "./distRoot"
|
|
10
11
|
import { peersDir } from "./peers"
|
|
11
12
|
import type { DesktopPlan } from "./desktopScript"
|
|
12
13
|
// (a relative source import, as desktopScript.ts does: lecodes-headless is an optional peer)
|
|
13
|
-
import { findDumpTarget, parseTreeDump } from "../../../../runtime/headless/src/headless/paintDump"
|
|
14
|
+
import { findDumpTarget, parseTreeDump, type DumpNode } from "../../../../runtime/headless/src/headless/paintDump"
|
|
14
15
|
|
|
15
16
|
/*
|
|
16
17
|
* `lecodes test --lite`: a flow scenario on WEB LITE, in a real browser. The scenario is the one
|
|
@@ -135,11 +136,15 @@ export type LiteSession = {
|
|
|
135
136
|
}
|
|
136
137
|
|
|
137
138
|
/** The project compiled and served with the page of Web Lite, and a browser to open it in. */
|
|
138
|
-
export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite
|
|
139
|
+
export const startLiteSession = async (options: { root: string, manifest: Manifest | null, name: string, lite?: string, web?: WebRunner, entry?: string }): Promise<LiteSession> => {
|
|
139
140
|
const port = await freePort()
|
|
141
|
+
// The project's backend, when it has one: a database of the tests' own, emptied before every case.
|
|
142
|
+
const dbDir = join(options.root, LECODES_DIR, "test-db")
|
|
143
|
+
rmSync(dbDir, { recursive: true, force: true })
|
|
144
|
+
let ran = 0
|
|
140
145
|
const server: DevServer = await startDevServer({
|
|
141
|
-
root: options.root, manifest: options.manifest, name: options.name, lite: options.lite,
|
|
142
|
-
origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry,
|
|
146
|
+
root: options.root, manifest: options.manifest, name: options.name, lite: options.lite, web: options.web,
|
|
147
|
+
origin: `http://127.0.0.1:${port}`, bind: "127.0.0.1", port, token: "test", entryOverride: options.entry, dbDir,
|
|
143
148
|
})
|
|
144
149
|
const compiled = await server.recompile()
|
|
145
150
|
if (!compiled.ok) { server.close(); throw new CliError(`Compile failed:\n${compiled.error}`) }
|
|
@@ -153,7 +158,13 @@ export const startLiteSession = async (options: { root: string, manifest: Manife
|
|
|
153
158
|
}
|
|
154
159
|
|
|
155
160
|
return {
|
|
156
|
-
|
|
161
|
+
// the web player (`web`): the same contract on its page — `window.lecodesTest` under `?test`
|
|
162
|
+
run: async (plan, runOptions) => {
|
|
163
|
+
if (ran++ > 0) await server.resetBackend()
|
|
164
|
+
return options.web
|
|
165
|
+
? runPlan(driver, `${server.webUrl}player/?dev=../..&test`, plan, runOptions, "web")
|
|
166
|
+
: runPlan(driver, `${server.liteUrl}?dev=..&test`, plan, runOptions, "lite")
|
|
167
|
+
},
|
|
157
168
|
async close() {
|
|
158
169
|
await driver.close()
|
|
159
170
|
server.close()
|
|
@@ -203,7 +214,7 @@ const FRAME = "new Promise((r) => requestAnimationFrame(() => requestAnimationFr
|
|
|
203
214
|
|
|
204
215
|
const FILL_VALUE = `(f) => f.type === "email" ? "test@example.com" : f.type === "tel" ? "5550100" : f.inputMode === "numeric" || f.inputMode === "decimal" ? "1" : f.type === "date" ? "2026-01-01" : f.type === "time" ? "12:00" : "test"`
|
|
205
216
|
|
|
206
|
-
const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions): Promise<string> => {
|
|
217
|
+
const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options: LiteRunOptions, page: "lite" | "web"): Promise<string> => {
|
|
207
218
|
const output: string[] = []
|
|
208
219
|
const seen: string[] = []
|
|
209
220
|
const say = (line: string) => { output.push(line) }
|
|
@@ -294,6 +305,30 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
294
305
|
case "type": await driver.keyboard("type", unwire(rest)); await frame(); return
|
|
295
306
|
case "fill": {
|
|
296
307
|
const value = rest === "" ? undefined : unwire(rest)
|
|
308
|
+
if (page === "web") {
|
|
309
|
+
// The player paints its fields: every empty one on show, in the tree's order, is tapped
|
|
310
|
+
// (a live <input> goes over it) and typed into.
|
|
311
|
+
const fields: DumpNode[] = []
|
|
312
|
+
const walk = (node: DumpNode) => {
|
|
313
|
+
if ((node.type === "input" || node.type === "textarea") && !node.value) fields.push(node)
|
|
314
|
+
for (const child of node.children ?? []) walk(child)
|
|
315
|
+
for (const p of node.pages ?? []) walk(p.node)
|
|
316
|
+
}
|
|
317
|
+
for (const root of parseTreeDump(await dump()).roots) walk(root.node)
|
|
318
|
+
for (const field of fields) {
|
|
319
|
+
const rect = await rectOf(field.id)
|
|
320
|
+
if (!rect || rect[2] === 0 || rect[3] === 0) continue // not on show (a covered page)
|
|
321
|
+
const kind = String(field.paint?.type ?? "")
|
|
322
|
+
const text = value ?? (kind === "email" ? "test@example.com" : kind === "tel" || kind === "phone" ? "5550100" : kind === "number" || kind === "decimal" ? "1" : "test")
|
|
323
|
+
await driver.mouse("move", rect[0] + rect[2] / 2, rect[1] + rect[3] / 2)
|
|
324
|
+
await driver.mouse("down")
|
|
325
|
+
await driver.mouse("up")
|
|
326
|
+
await frame()
|
|
327
|
+
await driver.keyboard("type", text)
|
|
328
|
+
}
|
|
329
|
+
await frame()
|
|
330
|
+
return
|
|
331
|
+
}
|
|
297
332
|
// every empty field on show, in the tree's order: what the native hosts fill
|
|
298
333
|
const count = await driver.evaluate<number>(`(() => {
|
|
299
334
|
window.__lecodesFill = [...document.querySelectorAll("${ON_SHOW} input.lc, ${ON_SHOW} textarea.lc")].filter((f) => f.value === "")
|
|
@@ -310,6 +345,14 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
310
345
|
case "scroll": {
|
|
311
346
|
const by = Number(args[0])
|
|
312
347
|
const name = typeof args[1] === "string" ? args[1] : null
|
|
348
|
+
if (page === "web") {
|
|
349
|
+
// the player scrolls what is under the pointer: the wheel over the named node, or the middle of the page
|
|
350
|
+
const point = name === null ? [options.width / 2, options.height / 2] as [number, number] : (await pointOf(args, 1)).point
|
|
351
|
+
await driver.mouse("move", point[0], point[1])
|
|
352
|
+
await driver.mouse("wheel", 0, by)
|
|
353
|
+
await frame()
|
|
354
|
+
return
|
|
355
|
+
}
|
|
313
356
|
const target = name === null
|
|
314
357
|
? `document.querySelector("${ON_SHOW} .lc-scrollable, ${ON_SHOW} .lc-vlist")`
|
|
315
358
|
: `document.querySelector('[data-node="${(await nodeOf(name)).id}"]')`
|
|
@@ -355,7 +398,7 @@ const runPlan = async (driver: Driver, url: string, plan: DesktopPlan, options:
|
|
|
355
398
|
return
|
|
356
399
|
}
|
|
357
400
|
default:
|
|
358
|
-
throw new Error(`"${op}" is not available
|
|
401
|
+
throw new Error(`"${op}" is not available in a browser run (no inspection camera, no gamepad)`)
|
|
359
402
|
}
|
|
360
403
|
}
|
|
361
404
|
|