lecodes-cli 0.18.2 → 0.19.0
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 +1240 -170
- package/package.json +3 -2
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/compile/aspectMacro.ts +86 -0
- package/runtime/sdk/compile/assetIconMacro.ts +384 -0
- package/runtime/sdk/compile/assetMacro.ts +146 -0
- package/runtime/sdk/compile/assetName.ts +50 -0
- package/runtime/sdk/compile/bundler.ts +287 -0
- package/runtime/sdk/compile/compileProject.ts +129 -0
- package/runtime/sdk/compile/detectEntry.ts +128 -0
- package/runtime/sdk/compile/fontMacro.ts +459 -0
- package/runtime/sdk/compile/fontRegistry.ts +78 -0
- package/runtime/sdk/compile/header.ts +67 -0
- package/runtime/sdk/compile/index.ts +100 -0
- package/runtime/sdk/compile/libraryImports.ts +52 -0
- package/runtime/sdk/compile/liteMaterial.ts +247 -0
- package/runtime/sdk/compile/sceneEditor.ts +88 -0
- package/runtime/sdk/compile/serverSplit.ts +233 -0
- package/runtime/sdk/compile/serverTypes.ts +227 -0
- package/runtime/sdk/compile/sfnt.ts +98 -0
- package/runtime/sdk/compile/shaderSchema.ts +202 -0
- package/runtime/sdk/compile/shaderTargets.ts +81 -0
- package/runtime/sdk/compile/sourcemap.ts +25 -0
- package/runtime/sdk/core/Aspect.ts +65 -9
- package/runtime/sdk/core/StateMachine.ts +308 -0
- package/runtime/sdk/core/time.ts +3 -0
- package/runtime/sdk/gl/CameraPlace.ts +52 -52
- package/runtime/sdk/gl/Light.ts +12 -4
- package/runtime/sdk/gl/Lightmap.ts +87 -10
- package/runtime/sdk/gl/Material.ts +15 -0
- package/runtime/sdk/gl/Model.ts +68 -8
- package/runtime/sdk/gl/NavAgent.ts +337 -0
- package/runtime/sdk/gl/NavMesh.ts +397 -0
- package/runtime/sdk/gl/Ragdoll.ts +270 -0
- package/runtime/sdk/gl/Scene.ts +7 -0
- package/runtime/sdk/gl/Shape.ts +37 -5
- package/runtime/sdk/gl/Terrain.ts +1092 -0
- package/runtime/sdk/gl/Texture.ts +17 -0
- package/runtime/sdk/gl/Trigger.ts +45 -45
- package/runtime/sdk/gl/animation/Animator.ts +35 -2
- package/runtime/sdk/gl/animation/Layer.ts +3 -1
- package/runtime/sdk/gl/animation/Loop.ts +5 -0
- package/runtime/sdk/gl/animation/Playback.ts +4 -3
- package/runtime/sdk/gl/animation/core.ts +52 -10
- package/runtime/sdk/gl/scenarios.ts +291 -291
- package/runtime/sdk/gl/state.ts +6 -6
- package/runtime/sdk/gl/terrainMesh.ts +219 -0
- package/runtime/sdk/inject.ts +12 -1
- package/runtime/sdk/net/codec.ts +119 -0
- package/runtime/sdk/net/core.ts +384 -0
- package/runtime/sdk/net/index.ts +181 -0
- package/runtime/sdk/net/replication.ts +622 -0
- package/runtime/sdk/scene/defineScene.ts +140 -14
- package/runtime/sdk/scene/editorPlugins.ts +18 -0
- package/runtime/sdk/scene/gizmos.ts +148 -148
- package/runtime/sdk/ui/UINode.ts +4 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { createRequire } from "node:module"
|
|
4
|
+
import { dirname, join, relative } from "node:path"
|
|
5
|
+
import { fileURLToPath } from "node:url"
|
|
6
|
+
import { collectAssetMacros, desugarAssetMacros, hasAssetMacro, type AssetMacroRef } from "./assetMacro"
|
|
7
|
+
import type { ScanModule } from "./detectEntry"
|
|
8
|
+
import { hasAspectExtends, injectAspectNames } from "./aspectMacro"
|
|
9
|
+
import { hasImportStatement, rewriteLibraryImports } from "./libraryImports"
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
* The user-project bundler. Project files live in an in-memory map; the whole SDK is delivered via
|
|
13
|
+
* chisel's `inject` so user code uses globals (Scene, Sprite, toast, …) with NO imports. chisel — the
|
|
14
|
+
* Rust method-granular bundler (@letary/chisel) — tree-shakes the injected SDK down to what the
|
|
15
|
+
* project references, including unused class *methods*. It is the only bundler (the esbuild fallback
|
|
16
|
+
* is gone). Bare imports are a hard error (chisel's resolver enforces it).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type MemoryFiles = Record<string, string>
|
|
20
|
+
|
|
21
|
+
/** Locate the SDK inject entry: an explicit override (LECODES_SDK_INJECT — used by the published CLI,
|
|
22
|
+
* which vendors the SDK source), else the source in-repo. chisel needs the SOURCE tree (not a bundle). */
|
|
23
|
+
const resolveInject = (): string => {
|
|
24
|
+
const override = process.env.LECODES_SDK_INJECT
|
|
25
|
+
if (override && existsSync(override)) return override
|
|
26
|
+
const here = dirname(fileURLToPath(import.meta.url)) // …/sdk/src/compile — portable (Node + Bun)
|
|
27
|
+
const src = join(here, "..", "inject.ts")
|
|
28
|
+
if (existsSync(src)) return src
|
|
29
|
+
throw new Error("sdk: could not locate the inject source (LECODES_SDK_INJECT / src/inject.ts)")
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const normalize = (path: string): string => {
|
|
33
|
+
const parts: string[] = []
|
|
34
|
+
for (const part of path.split("/")) {
|
|
35
|
+
if (!part || part === ".") continue
|
|
36
|
+
if (part === "..") parts.pop()
|
|
37
|
+
else parts.push(part)
|
|
38
|
+
}
|
|
39
|
+
return "/" + parts.join("/")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ---- chisel (Rust) bundler -------------------------------------------------------------------
|
|
43
|
+
//
|
|
44
|
+
// chisel is fed an in-memory file map over JSON-stdio: the SDK source under `/__sdk/…` (injected via
|
|
45
|
+
// `/__sdk/inject.ts`), the project files at their own paths, and a `define` map. It does
|
|
46
|
+
// method-granular tree-shaking + (when minify is on) compress + mangle. Assets/shaders/svg reach it
|
|
47
|
+
// as `asset()` macros desugared to default imports; aspect names are injected as `static aspect`.
|
|
48
|
+
|
|
49
|
+
// `EDITOR` is a boolean define: `false` (const-folded, `if (EDITOR)` branches DCE'd away) in
|
|
50
|
+
// every shipped bundle, `true` only in scene-editor compiles (CompileOptions.editor). Requires a
|
|
51
|
+
// chisel with boolean-define support — an older binary ignores the entry and leaves `EDITOR` as a
|
|
52
|
+
// free identifier (a runtime ReferenceError only in code that actually uses it).
|
|
53
|
+
const CHISEL_DEFINE: Record<string, string> = { RAD2DEG: "57.29577951308232", DEG2RAD: "0.017453292519943295" }
|
|
54
|
+
|
|
55
|
+
// Methods the native host invokes by name have no in-bundle caller, so method-DCE can't see them.
|
|
56
|
+
// The SDK prefixes its framework / host-facing methods with `_` (`_emitTouchStart`, `_emitClick`,
|
|
57
|
+
// `_onSlotEvent`, `_emitCollision`, …), so keep every underscore-prefixed method on classes that survive.
|
|
58
|
+
const CHISEL_KEEP = [ "_*" ]
|
|
59
|
+
|
|
60
|
+
/** The two inject modules (paths inside chisel's file map, see collectSdkFiles): the client SDK
|
|
61
|
+
* (every UI/2D/3D global) and the server SDK (`t`, `model`, `defineDb`, `channel`, … — see
|
|
62
|
+
* src/server/inject.ts). A bundle gets exactly one of them. */
|
|
63
|
+
export const CLIENT_INJECT = "/__sdk/inject.ts"
|
|
64
|
+
export const SERVER_INJECT = "/__sdk/server/inject.ts"
|
|
65
|
+
|
|
66
|
+
/** The SDK source root (parent of `inject.ts`). chisel needs the source tree, not a prebuilt bundle. */
|
|
67
|
+
const resolveSdkSrc = (): string => {
|
|
68
|
+
const inject = resolveInject()
|
|
69
|
+
if (!inject.endsWith("inject.ts")) {
|
|
70
|
+
throw new Error(`chisel needs the SDK source tree (src/inject.ts); got ${inject}. Set LECODES_SDK_INJECT to the source.`)
|
|
71
|
+
}
|
|
72
|
+
return dirname(inject)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const isSdkSource = (path: string) =>
|
|
76
|
+
/\.[tj]s$/.test(path) && !path.endsWith(".d.ts") &&
|
|
77
|
+
!path.includes("/compile/") && !path.includes("/tests/") && !path.includes("/__tests__/")
|
|
78
|
+
|
|
79
|
+
// The SDK tree doesn't change within a process — read it once.
|
|
80
|
+
const sdkFilesCache = new Map<string, MemoryFiles>()
|
|
81
|
+
const collectSdkFiles = (sdkSrc: string): MemoryFiles => {
|
|
82
|
+
const cached = sdkFilesCache.get(sdkSrc)
|
|
83
|
+
if (cached) return cached
|
|
84
|
+
const out: MemoryFiles = {}
|
|
85
|
+
const walk = (dir: string) => {
|
|
86
|
+
for (const name of readdirSync(dir)) {
|
|
87
|
+
const p = join(dir, name)
|
|
88
|
+
if (statSync(p).isDirectory()) walk(p)
|
|
89
|
+
else if (isSdkSource(p)) out["/__sdk/" + relative(sdkSrc, p).split("\\").join("/")] = readFileSync(p, "utf8")
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
walk(sdkSrc)
|
|
93
|
+
sdkFilesCache.set(sdkSrc, out)
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Locate the chisel binary: an explicit override (LECODES_CHISEL_BIN), else the prebuilt binary from
|
|
98
|
+
* the @letary/chisel npm package (which resolves the platform via optional @letary/chisel-<platform>). */
|
|
99
|
+
const resolveChiselBin = (): string => {
|
|
100
|
+
const override = process.env.LECODES_CHISEL_BIN
|
|
101
|
+
if (override) {
|
|
102
|
+
if (existsSync(override)) return override
|
|
103
|
+
throw new Error(`LECODES_CHISEL_BIN points at a missing file: ${override}`)
|
|
104
|
+
}
|
|
105
|
+
try {
|
|
106
|
+
const bin = createRequire(import.meta.url)("@letary/chisel").binaryPath() as string | null
|
|
107
|
+
if (bin && existsSync(bin)) return bin
|
|
108
|
+
} catch { /* @letary/chisel (or its platform binary) not installed */ }
|
|
109
|
+
throw new Error("chisel binary not found. Install @letary/chisel, or set LECODES_CHISEL_BIN.")
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Parse every module in `files` via chisel scan (no bundling): resolved local imports +
|
|
113
|
+
* hasExports per file. Null when the binary is missing or predates scan mode (it then rejects
|
|
114
|
+
* the entry-less input) — detectEntry then falls back to its regex scanner. Passed into
|
|
115
|
+
* `detectEntry` as its `scanner` by every node caller (detectEntry itself stays browser-safe). */
|
|
116
|
+
export const scanModulesChisel = (files: MemoryFiles): Record<string, ScanModule> | null => {
|
|
117
|
+
let bin: string
|
|
118
|
+
try {
|
|
119
|
+
bin = resolveChiselBin()
|
|
120
|
+
} catch {
|
|
121
|
+
return null
|
|
122
|
+
}
|
|
123
|
+
const input = JSON.stringify({ scan: true, files })
|
|
124
|
+
const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
|
|
125
|
+
if (r.status !== 0 || r.error) return null
|
|
126
|
+
try {
|
|
127
|
+
const parsed = JSON.parse(r.stdout || "{}") as { scan?: Record<string, ScanModule> }
|
|
128
|
+
return parsed.scan ?? null
|
|
129
|
+
} catch {
|
|
130
|
+
return null
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Every `asset('…')` must name a file that's actually in the compile input — a missing one is a
|
|
136
|
+
* COMPILE ERROR, never a fallback.
|
|
137
|
+
*
|
|
138
|
+
* chisel used to turn an asset import with no backing module into `const x = "<the specifier>"`:
|
|
139
|
+
* a relative source path nothing resolves at runtime, so the app got a silently broken reference
|
|
140
|
+
* (and, since the desugared imports are appended, a const declared after its own use — a TDZ
|
|
141
|
+
* ReferenceError at boot that read like a bundler crash). Reporting the call site here instead
|
|
142
|
+
* names the real problem: the file isn't there.
|
|
143
|
+
*/
|
|
144
|
+
const assertAssetsExist = (refs: { file: string, ref: AssetMacroRef }[], files: MemoryFiles) => {
|
|
145
|
+
const missing: string[] = []
|
|
146
|
+
for (const { file, ref } of refs) {
|
|
147
|
+
// Bare specifiers aren't paths at all — chisel reports those as bare imports.
|
|
148
|
+
if (!ref.path.startsWith("./") && !ref.path.startsWith("../") && !ref.path.startsWith("/")) continue
|
|
149
|
+
const dir = file.slice(0, file.lastIndexOf("/"))
|
|
150
|
+
const base = ref.path.startsWith("/") ? normalize(ref.path) : normalize(`${dir}/${ref.path}`)
|
|
151
|
+
// Same candidate list chisel's resolver uses, so "found here" means "found there".
|
|
152
|
+
const found = [ base, `${base}.ts`, `${base}.js`, `${base}/index.ts` ].some(c => files[c] !== undefined)
|
|
153
|
+
if (!found) missing.push(`asset not found: ${ref.path} (${file.replace(/^\//, "")}:${ref.line})`)
|
|
154
|
+
}
|
|
155
|
+
if (missing.length > 0) throw new Error(missing.join("\n"))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const bundleProjectChisel = async (
|
|
159
|
+
entrypoint: string,
|
|
160
|
+
files: MemoryFiles,
|
|
161
|
+
format: "esm" | "iife",
|
|
162
|
+
sourcemap: boolean,
|
|
163
|
+
minifyOverride?: boolean,
|
|
164
|
+
editor?: boolean,
|
|
165
|
+
libraries?: Record<string, MemoryFiles>,
|
|
166
|
+
inject: string = CLIENT_INJECT,
|
|
167
|
+
): Promise<{ code: string, map: string | null, diagnostics: string[] }> => {
|
|
168
|
+
const entry = normalize(entrypoint)
|
|
169
|
+
const slugs = new Set(Object.keys(libraries ?? {}))
|
|
170
|
+
// Per-file source transforms (project files only — SDK aspects declare their own `static aspect`):
|
|
171
|
+
// - rewrite bare library imports (`from "acme-ui"`) → the injected `/__lib/<slug>/…` tree
|
|
172
|
+
// - desugar `asset('./x')` → a default import that resolves to the synthetic asset module
|
|
173
|
+
// - inject `static aspect = '<name>'` from `class … extends Aspect<'<name>', …>`
|
|
174
|
+
const projFiles: MemoryFiles = {}
|
|
175
|
+
const assetRefs: { file: string, ref: AssetMacroRef }[] = []
|
|
176
|
+
for (const [ k, v ] of Object.entries(files)) {
|
|
177
|
+
const path = normalize(k)
|
|
178
|
+
let src = v
|
|
179
|
+
if (/\.[tj]sx?$/.test(path)) {
|
|
180
|
+
if (hasImportStatement(src)) src = rewriteLibraryImports(src, slugs)
|
|
181
|
+
if (hasAssetMacro(src)) {
|
|
182
|
+
const found = collectAssetMacros(src, path)
|
|
183
|
+
for (const ref of found) assetRefs.push({ file: path, ref })
|
|
184
|
+
src = desugarAssetMacros(src, path, found)
|
|
185
|
+
}
|
|
186
|
+
if (hasAspectExtends(src)) src = injectAspectNames(src)
|
|
187
|
+
}
|
|
188
|
+
projFiles[path] = src
|
|
189
|
+
}
|
|
190
|
+
if (!projFiles[entry]) throw new Error(`Entrypoint not found: ${entry}`)
|
|
191
|
+
|
|
192
|
+
// Library dependencies: merge each library's files under /__lib/<slug>/… (like /__sdk/…). They
|
|
193
|
+
// get the same asset/aspect desugaring as project code, but NOT the bare-import rewrite — a
|
|
194
|
+
// library can't declare dependencies (flat graph v1), so any bare import in a library is an
|
|
195
|
+
// error chisel surfaces. Library-internal relative imports resolve within the prefix untouched.
|
|
196
|
+
for (const [ slug, libFiles ] of Object.entries(libraries ?? {})) {
|
|
197
|
+
for (const [ k, v ] of Object.entries(libFiles)) {
|
|
198
|
+
const path = normalize(`/__lib/${slug}/${k}`)
|
|
199
|
+
let src = v
|
|
200
|
+
if (/\.[tj]sx?$/.test(path)) {
|
|
201
|
+
if (hasAssetMacro(src)) {
|
|
202
|
+
const found = collectAssetMacros(src, path)
|
|
203
|
+
for (const ref of found) assetRefs.push({ file: path, ref })
|
|
204
|
+
src = desugarAssetMacros(src, path, found)
|
|
205
|
+
}
|
|
206
|
+
if (hasAspectExtends(src)) src = injectAspectNames(src)
|
|
207
|
+
}
|
|
208
|
+
projFiles[path] = src
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
assertAssetsExist(assetRefs, projFiles)
|
|
213
|
+
|
|
214
|
+
const sdkFiles = collectSdkFiles(resolveSdkSrc())
|
|
215
|
+
// Shipped bundles minify by default; edit-mode bundles pass `false` — mangling erases class
|
|
216
|
+
// names, which the scene editor matches aspect schemas by (`describeAspect().className`).
|
|
217
|
+
const minify = minifyOverride ?? (process.env.LECODES_CHISEL_MINIFY !== "0")
|
|
218
|
+
const input = JSON.stringify({
|
|
219
|
+
files: { ...sdkFiles, ...projFiles },
|
|
220
|
+
entry,
|
|
221
|
+
inject: [ inject ],
|
|
222
|
+
format,
|
|
223
|
+
minify,
|
|
224
|
+
// Chain fusion, ON by default (`LECODES_CHISEL_FUSE=0` opts out): Vec3/date math chains lower to
|
|
225
|
+
// scalar arithmetic (bit-exact — JS numbers are f64 and the lowering never reassociates) and
|
|
226
|
+
// particle `curve()`/`colorCurve()` chains constant-fold into the exact native Float32Array.
|
|
227
|
+
fuse: process.env.LECODES_CHISEL_FUSE !== "0",
|
|
228
|
+
define: { ...CHISEL_DEFINE, EDITOR: editor ? "true" : "false" },
|
|
229
|
+
keep: CHISEL_KEEP,
|
|
230
|
+
sourcemap,
|
|
231
|
+
// Reactive-UI desugaring (memoized children .map → __uiMap + auto-wrapped signal reads in
|
|
232
|
+
// UIText/style args). Only fires on free references to the injected globals, i.e. user code.
|
|
233
|
+
// An older chisel binary ignores the unknown field — bundles still work, children just
|
|
234
|
+
// rebuild per change instead of reusing nodes.
|
|
235
|
+
reactive_ui: process.env.LECODES_CHISEL_REACTIVE_UI !== "0",
|
|
236
|
+
// Flatten literal array args of UI factory calls into variadic args (`UIColumn([a, b])` →
|
|
237
|
+
// `UIColumn(a, b)`) — the SDK's buildUI dispatch flattens array args one level anyway, so
|
|
238
|
+
// this only drops an array allocation per call site. Safe to always request: the sibling SDK
|
|
239
|
+
// source above has the variadic dispatch, and an older binary ignores the unknown field.
|
|
240
|
+
flatten_ui: process.env.LECODES_CHISEL_FLATTEN_UI !== "0",
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
const bin = resolveChiselBin()
|
|
244
|
+
const r = spawnSync(bin, [], { input, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 })
|
|
245
|
+
if (r.status === 2 || r.error) {
|
|
246
|
+
throw new Error(`chisel failed: ${r.error?.message ?? r.stderr ?? "unknown error"}`)
|
|
247
|
+
}
|
|
248
|
+
let parsed: { code?: string, map?: string, error?: string, diagnostics?: string[] }
|
|
249
|
+
try {
|
|
250
|
+
parsed = JSON.parse(r.stdout || "{}")
|
|
251
|
+
} catch {
|
|
252
|
+
throw new Error(`chisel produced invalid output: ${(r.stdout || r.stderr || "").slice(0, 200)}`)
|
|
253
|
+
}
|
|
254
|
+
if (parsed.error) throw new Error(`chisel: ${parsed.error}`)
|
|
255
|
+
if (parsed.code == null) throw new Error("chisel produced no code")
|
|
256
|
+
// Non-fatal build-time findings (`path:line:col: message`) — today: particle curve chains the
|
|
257
|
+
// engine would reject at set time. Warn so they're visible in the CLI / server log; the bundle
|
|
258
|
+
// itself is unaffected (chisel leaves an invalid chain as real calls).
|
|
259
|
+
const diagnostics = parsed.diagnostics ?? []
|
|
260
|
+
for (const d of diagnostics) console.warn(`warning: ${d}`)
|
|
261
|
+
return { code: parsed.code, map: parsed.map ?? null, diagnostics }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Bundle the project `files` (path → source) starting at `entrypoint`, with the SDK injected.
|
|
266
|
+
* `format` is "esm" by default (what the platform ships); "iife" produces a self-running script.
|
|
267
|
+
*/
|
|
268
|
+
export const bundleProject = async (
|
|
269
|
+
entrypoint: string,
|
|
270
|
+
files: MemoryFiles,
|
|
271
|
+
format: "esm" | "iife" = "esm",
|
|
272
|
+
): Promise<string> => (await bundleProjectChisel(entrypoint, files, format, false)).code
|
|
273
|
+
|
|
274
|
+
/** Like `bundleProject`, but also returns chisel's source map (and any build-time `diagnostics`).
|
|
275
|
+
* The map's `sources` are the original module paths and its generated positions are relative to
|
|
276
|
+
* `code` (line 1 = first line of `code`). Pass `sourcemap: false` to skip map generation entirely —
|
|
277
|
+
* `map` then comes back null. */
|
|
278
|
+
export const bundleProjectWithMap = async (
|
|
279
|
+
entrypoint: string,
|
|
280
|
+
files: MemoryFiles,
|
|
281
|
+
format: "esm" | "iife" = "esm",
|
|
282
|
+
minify?: boolean,
|
|
283
|
+
editor?: boolean,
|
|
284
|
+
libraries?: Record<string, MemoryFiles>,
|
|
285
|
+
sourcemap = true,
|
|
286
|
+
inject?: string,
|
|
287
|
+
): Promise<{ code: string, map: string | null, diagnostics: string[] }> => bundleProjectChisel(entrypoint, files, format, sourcemap, minify, editor, libraries, inject)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Turns a project's files into the esbuild input map + entrypoint + metadata header, then bundles.
|
|
2
|
+
// The one canonical "user project → deployable bundle" path: the backend and CLI both call this
|
|
3
|
+
// (the backend resolves asset URLs from the DB, the CLI from local metadata — same function, the
|
|
4
|
+
// URLs are passed in as `publicUrl` + per-entry `fileSrc`/`shaders`).
|
|
5
|
+
|
|
6
|
+
import { bundleProjectWithMap, scanModulesChisel, type MemoryFiles } from "./bundler"
|
|
7
|
+
import { detectEntry } from "./detectEntry"
|
|
8
|
+
import { buildHeader } from "./header"
|
|
9
|
+
import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
|
|
10
|
+
import { stubServerEntries } from "./serverSplit"
|
|
11
|
+
|
|
12
|
+
export type CompileFileType = "text" | "resource" | "shader"
|
|
13
|
+
|
|
14
|
+
export type CompileEntry = {
|
|
15
|
+
path: string
|
|
16
|
+
type: CompileFileType
|
|
17
|
+
/** Source text for code / .svg files. */
|
|
18
|
+
text?: string
|
|
19
|
+
/** Server src for a binary resource (e.g. "/uploads/x.png"); prefixed with publicUrl. */
|
|
20
|
+
fileSrc?: string
|
|
21
|
+
/** Under `localAssets`: keep THIS resource in the URL form (publicUrl + fileSrc) anyway — for
|
|
22
|
+
* hosts that bundle most assets locally but stream heavy ones (e.g. videos) from the server. */
|
|
23
|
+
remote?: boolean
|
|
24
|
+
/** Compiled-shader artifacts (platform + src); the first drives the per-backend URL. */
|
|
25
|
+
shaders?: { platform: string, src: string }[]
|
|
26
|
+
/** `shaders` are LOCAL files staged next to the bundle (bare artifact names, not server srcs):
|
|
27
|
+
* the module compiles to a fetchLocal lookup instead of a URL. Set by the CLI's local shader
|
|
28
|
+
* compile for hosts with no asset server (the desktop host). */
|
|
29
|
+
shadersLocal?: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type CompileOptions = {
|
|
33
|
+
entries: CompileEntry[]
|
|
34
|
+
name: string
|
|
35
|
+
publicUrl: string
|
|
36
|
+
entryOverride?: string
|
|
37
|
+
/** Prepend the metadata header (default true). */
|
|
38
|
+
header?: boolean
|
|
39
|
+
/** Generated-icon URL for the header (backend only). */
|
|
40
|
+
icon?: string
|
|
41
|
+
/** `// fonts:` header payload (from the font() macro pass the caller ran over the entries). */
|
|
42
|
+
fontsHeader?: string
|
|
43
|
+
/** `// icons:` header payload (from the assetIcon() macro pass the caller ran over the entries). */
|
|
44
|
+
iconsHeader?: string
|
|
45
|
+
/** "esm" (default, shipped) or "iife" (self-running, for the local renderer). */
|
|
46
|
+
format?: "esm" | "iife"
|
|
47
|
+
/** Resolve `asset()` resources to a LOCAL host asset at runtime instead of a `publicUrl` URL: each
|
|
48
|
+
* resource compiles to `"id:" + _creatorUtils.fetchLocal(<name>)` (the host reads the file by name
|
|
49
|
+
* and returns its systemId). For hosts with no asset server (the desktop native host), where a URL
|
|
50
|
+
* can't be fetched. Off by default — the shipped/web build keeps the URL form. */
|
|
51
|
+
localAssets?: boolean
|
|
52
|
+
/** Override minification (default: on, `LECODES_CHISEL_MINIFY` env aside). Scene-editor bundles
|
|
53
|
+
* compile with `false`: mangling erases class names, which the editor matches aspect schemas by. */
|
|
54
|
+
minify?: boolean
|
|
55
|
+
/** Generate a source map (default true) and inline it into the bundle. `false` skips the map
|
|
56
|
+
* entirely — a smaller bundle, but runtime errors report generated positions only. */
|
|
57
|
+
sourcemap?: boolean
|
|
58
|
+
/** Editor compile: the `EDITOR` global compiles to `true` (scene-editor bundles). Default false —
|
|
59
|
+
* shipped bundles get `EDITOR` = `false`, const-folded so `if (EDITOR)` branches are DCE'd. */
|
|
60
|
+
editor?: boolean
|
|
61
|
+
/** Component-library dependencies: slug → the library's files (root-relative paths). Merged under
|
|
62
|
+
* `/__lib/<slug>/…` and reachable from project code via `import … from "<slug>"`. See
|
|
63
|
+
* docs/library-system-plan.md §4. */
|
|
64
|
+
libraries?: Record<string, MemoryFiles>
|
|
65
|
+
/** Base URL of the project's deployed server (draft or prod), baked into the RPC/channel stubs
|
|
66
|
+
* that replace every `*.server.ts` module in the client bundle (docs/backend-plan.md §4). Server
|
|
67
|
+
* files never reach the client bundle either way; with no URL, calling an endpoint fails with
|
|
68
|
+
* "server not deployed". */
|
|
69
|
+
serverUrl?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const compileProject = async (opts: CompileOptions): Promise<string> => {
|
|
73
|
+
// Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
|
|
74
|
+
const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
|
|
75
|
+
const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
|
|
76
|
+
if (!entry) {
|
|
77
|
+
throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const files: Record<string, string> = {}
|
|
81
|
+
for (const e of entries) {
|
|
82
|
+
if (e.type === "resource") {
|
|
83
|
+
if (opts.localAssets && !e.remote) {
|
|
84
|
+
// The host resolves the asset by name at runtime (reads the local file → systemId); the
|
|
85
|
+
// "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
|
|
86
|
+
const name = e.path.split(/[\\/]/).pop() ?? e.path
|
|
87
|
+
files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(name)}))`
|
|
88
|
+
} else {
|
|
89
|
+
files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
|
|
90
|
+
}
|
|
91
|
+
} else if (e.type === "shader") {
|
|
92
|
+
const shaders = e.shaders ?? []
|
|
93
|
+
if (e.shadersLocal && shaders.length > 0) {
|
|
94
|
+
// Artifacts compiled locally and staged NEXT TO the bundle (no asset server): the host
|
|
95
|
+
// reads them by name through the same fetchLocal door the built-in materials use
|
|
96
|
+
// (`Material.lit` → fetchLocal("lit.filamat")), and Material.load parses the "id:" scheme.
|
|
97
|
+
// The name is built at RUNTIME because one bundle serves every native backend — the
|
|
98
|
+
// desktop host reports "desktop" on Windows and "opengl" on Linux, iOS "metal".
|
|
99
|
+
// Only entries the CLI actually compiled+staged carry `shadersLocal`; server artifacts
|
|
100
|
+
// (URLs) and un-staged ones keep the URL forms below.
|
|
101
|
+
const name = shaders[0].src.split(/[\\/]/).pop() ?? ""
|
|
102
|
+
const [prefix, suffix = ".filamat"] = name.split("_opengl")
|
|
103
|
+
files[e.path] = `export default ("id:" + _creatorUtils.fetchLocal(${JSON.stringify(prefix + "_")} + _creator.backend + ${JSON.stringify(suffix)}))`
|
|
104
|
+
} else if (shaders.length > 2) {
|
|
105
|
+
files[e.path] = `export default \`${opts.publicUrl}${shaders[0].src.replace("_opengl", "_${_creator.subBackend ?? _creator.backend}")}\``
|
|
106
|
+
} else if (shaders.length > 0) {
|
|
107
|
+
files[e.path] = `export default \`${opts.publicUrl}${shaders[0].src.replace("_opengl", "_${_creator.backend}")}\``
|
|
108
|
+
} else {
|
|
109
|
+
files[e.path] = `export default ""` // local-only shader with no compiled artifact yet
|
|
110
|
+
}
|
|
111
|
+
} else if (e.path.toLowerCase().endsWith(".svg")) {
|
|
112
|
+
files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`
|
|
113
|
+
} else {
|
|
114
|
+
files[e.path] = e.text ?? ""
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { code, map } = await bundleProjectWithMap(
|
|
119
|
+
entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true,
|
|
120
|
+
)
|
|
121
|
+
if (opts.header === false) {
|
|
122
|
+
return map ? `${code}\n${inlineSourceMapComment(map)}` : code
|
|
123
|
+
}
|
|
124
|
+
const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader })
|
|
125
|
+
if (!map) return header + code
|
|
126
|
+
// Shift the map down past the header (prepended before the code); the inline comment lands last.
|
|
127
|
+
const headerLines = (header.match(/\n/g) ?? []).length
|
|
128
|
+
return `${header}${code}\n${inlineSourceMapComment(offsetSourceMap(map, headerLines))}`
|
|
129
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Entrypoint detection from the project's import graph. The entry is the file no other project
|
|
2
|
+
// file imports (an import-graph root): a side-effect registration module is imported by the entry,
|
|
3
|
+
// so it stops being a candidate, and the entry itself may freely use `export`.
|
|
4
|
+
//
|
|
5
|
+
// Pick order among roots: `/main.ts` (the documented convention) → export-free roots by size →
|
|
6
|
+
// any root by size. `roots.length > 1` without a `/main.ts` means the choice was ambiguous —
|
|
7
|
+
// CLI callers surface that as a warning suggesting `main.ts` or `--entry`.
|
|
8
|
+
//
|
|
9
|
+
// This module is intentionally dependency-free (no node imports) so the FRONTEND can run the same
|
|
10
|
+
// detection live in the editor. Node callers inject chisel's `scan` mode (real parser — comments,
|
|
11
|
+
// strings, `import type` handled correctly) via the `scanner` parameter (`scanModulesChisel` from
|
|
12
|
+
// bundler.ts); without one — in the browser, or with a pre-scan chisel binary — the regex fallback
|
|
13
|
+
// below runs over the same resolution rules.
|
|
14
|
+
|
|
15
|
+
type Files = Record<string, string>
|
|
16
|
+
|
|
17
|
+
/** Per-module facts entry detection consumes (chisel `scan` output shape). */
|
|
18
|
+
export type ScanModule = { imports: string[], hasExports: boolean, error?: string }
|
|
19
|
+
|
|
20
|
+
/** A pluggable module scanner: path→source in, per-module facts out (null = unavailable). */
|
|
21
|
+
export type ModuleScanner = (files: Files) => Record<string, ScanModule> | null
|
|
22
|
+
|
|
23
|
+
export type DetectEntryInput = {
|
|
24
|
+
path: string
|
|
25
|
+
type?: string
|
|
26
|
+
text?: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type DetectedEntry = {
|
|
30
|
+
/** The chosen entry path ("" when the project has no candidate module at all). */
|
|
31
|
+
entry: string
|
|
32
|
+
/** Import-graph roots. More than one (without a `/main.ts` among them) = ambiguous choice. */
|
|
33
|
+
roots: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The conventional entry filename — always wins when it is an import-graph root. */
|
|
37
|
+
export const ENTRY_CONVENTION = "/main.ts"
|
|
38
|
+
|
|
39
|
+
const isCode = (path: string) => /\.[tj]s$/.test(path) && !path.endsWith(".d.ts")
|
|
40
|
+
/** Never entries: editor plugins and server modules (docs/backend-plan.md — a `*.server.ts` nobody
|
|
41
|
+
* imports is still not the app; server files compile into their own bundle). */
|
|
42
|
+
const isNonEntry = (path: string) => path.endsWith(".editor.ts") || /\.server\.[tj]s$/.test(path)
|
|
43
|
+
|
|
44
|
+
const normalize = (path: string): string => {
|
|
45
|
+
const parts: string[] = []
|
|
46
|
+
for (const part of path.split("/")) {
|
|
47
|
+
if (!part || part === ".") continue
|
|
48
|
+
if (part === "..") parts.pop()
|
|
49
|
+
else parts.push(part)
|
|
50
|
+
}
|
|
51
|
+
return "/" + parts.join("/")
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const dirOf = (path: string): string => {
|
|
55
|
+
const i = path.lastIndexOf("/")
|
|
56
|
+
return i <= 0 ? "/" : path.slice(0, i)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Mirror of chisel's resolver: relative/absolute specifiers only, `.ts`/`.js`/`/index.ts`
|
|
60
|
+
* fallback, exact match first. Null for bare imports (libraries) and unresolved paths. */
|
|
61
|
+
const resolveSpec = (files: Files, importer: string, spec: string): string | null => {
|
|
62
|
+
if (!spec.startsWith("./") && !spec.startsWith("../") && !spec.startsWith("/")) return null
|
|
63
|
+
const base = normalize(spec.startsWith("/") ? spec : dirOf(importer) + "/" + spec)
|
|
64
|
+
for (const cand of [base, base + ".ts", base + ".js", base + "/index.ts"]) {
|
|
65
|
+
if (cand in files) return cand
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// One pattern, two alternatives: `import|export … from "<spec>"` (multi-line safe — the specifier
|
|
71
|
+
// clause may sit on a later line) and a side-effect `import "<spec>"`.
|
|
72
|
+
const IMPORT_RE = /(?:^|[\n;])\s*(?:import|export)\b[^'"`]*?\bfrom\s*['"]([^'"\n]+)['"]|(?:^|[\n;])\s*import\s*['"]([^'"\n]+)['"]/g
|
|
73
|
+
const EXPORT_RE = /(?:^|[\n;])\s*export\b/
|
|
74
|
+
|
|
75
|
+
/** Regex fallback for a chisel binary without scan support. Same output shape; less precise
|
|
76
|
+
* (a specifier inside a comment counts, `export type` counts as an export) — both only affect
|
|
77
|
+
* which root wins a tie, never whether the true entry is a root. */
|
|
78
|
+
export const scanModulesFallback = (files: Files): Record<string, ScanModule> => {
|
|
79
|
+
const out: Record<string, ScanModule> = {}
|
|
80
|
+
for (const [path, text] of Object.entries(files)) {
|
|
81
|
+
const imports: string[] = []
|
|
82
|
+
for (const m of text.matchAll(IMPORT_RE)) {
|
|
83
|
+
const target = resolveSpec(files, path, m[1] ?? m[2] ?? "")
|
|
84
|
+
if (target && isCode(target) && !imports.includes(target)) imports.push(target)
|
|
85
|
+
}
|
|
86
|
+
out[path] = { imports, hasExports: EXPORT_RE.test(text) }
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Detect the project's entrypoint. `entries` is the full project file list; only `.ts`/`.js` text
|
|
93
|
+
* files participate (`.d.ts`, `.editor.ts` and `*.server.ts` are never entries, and editor-only imports don't
|
|
94
|
+
* remove a file from candidacy — production compiles don't bundle editor plugins).
|
|
95
|
+
*/
|
|
96
|
+
export const detectEntry = (entries: DetectEntryInput[], scanner?: ModuleScanner): DetectedEntry => {
|
|
97
|
+
const files: Files = {}
|
|
98
|
+
for (const e of entries) {
|
|
99
|
+
if (e.type !== undefined && e.type !== "text") continue
|
|
100
|
+
if (typeof e.text !== "string") continue
|
|
101
|
+
const path = normalize(e.path.split("\\").join("/"))
|
|
102
|
+
if (isCode(path)) files[path] = e.text
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const candidates = Object.keys(files).filter((p) => !isNonEntry(p))
|
|
106
|
+
if (candidates.length === 0) return { entry: "", roots: [] }
|
|
107
|
+
|
|
108
|
+
const scan = scanner?.(files) ?? scanModulesFallback(files)
|
|
109
|
+
|
|
110
|
+
const imported = new Set<string>()
|
|
111
|
+
for (const [path, mod] of Object.entries(scan)) {
|
|
112
|
+
if (path.endsWith(".editor.ts")) continue
|
|
113
|
+
for (const target of mod.imports) imported.add(target)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let roots = candidates.filter((p) => !imported.has(p))
|
|
117
|
+
// Everything imported (a cycle through every file) — degrade to all candidates.
|
|
118
|
+
if (roots.length === 0) roots = candidates
|
|
119
|
+
|
|
120
|
+
if (roots.includes(ENTRY_CONVENTION)) return { entry: ENTRY_CONVENTION, roots }
|
|
121
|
+
|
|
122
|
+
// Export-free roots are scripts — the old heuristic's signal, kept as the tie-breaker so
|
|
123
|
+
// existing projects resolve the same way; a lone exporting root is still a valid entry.
|
|
124
|
+
const scripts = roots.filter((p) => !scan[p]?.hasExports)
|
|
125
|
+
const pool = scripts.length > 0 ? scripts : roots
|
|
126
|
+
const bySize = [...pool].sort((a, b) => files[b].length - files[a].length)
|
|
127
|
+
return { entry: bySize[0], roots }
|
|
128
|
+
}
|