lecodes-sdk 2.2.11 → 2.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/global.d.ts +13 -0
  2. package/dist/host.d.ts +8 -2
  3. package/dist/types/core/Aspect.d.ts +5 -0
  4. package/dist/types/g2/Physics2D.d.ts +3 -2
  5. package/dist/types/g2/Scene2D.d.ts +6 -0
  6. package/dist/types/gl/Scene.d.ts +6 -0
  7. package/dist/types/gl/Texture.d.ts +2 -7
  8. package/dist/types/gl/animation/DynamicBone.d.ts +3 -0
  9. package/dist/types/gl/animation/Locomotion.d.ts +3 -0
  10. package/dist/types/gl/physics/Physics.d.ts +2 -1
  11. package/dist/types/gl/physics/Ragdoll.d.ts +4 -0
  12. package/dist/types/gl/vehicle/Vehicle.d.ts +4 -0
  13. package/dist/types/inject.d.ts +4 -3
  14. package/dist/types/plugin.d.ts +3 -0
  15. package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
  16. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
  17. package/dist/types/runtime/app.d.ts +4 -2
  18. package/dist/types/runtime/fetch.d.ts +3 -0
  19. package/dist/types/runtime/pushSeam.d.ts +23 -0
  20. package/dist/types/runtime/rpc.d.ts +15 -1
  21. package/dist/types/runtime/storage.d.ts +6 -1
  22. package/dist/types/server/auth/models.d.ts +13 -1
  23. package/dist/types/server/auth/types.d.ts +2 -0
  24. package/dist/types/server/db/marci/query.d.ts +8 -2
  25. package/dist/types/server/inject.d.ts +1 -0
  26. package/dist/types/server/pushChannel.d.ts +79 -0
  27. package/dist/types/ui/UIButton.d.ts +2 -2
  28. package/dist/types/ui/UIContainer.d.ts +4 -2
  29. package/dist/types/ui/UINode.d.ts +2 -2
  30. package/dist/types/ui/UIScreen.d.ts +2 -2
  31. package/dist/types/ui/UIVideo.d.ts +3 -2
  32. package/dist/types/ui/router.d.ts +1 -1
  33. package/dist/types/version.d.ts +1 -1
  34. package/dist/types.json +1 -1
  35. package/package.json +2 -5
  36. package/src/compile/assetIconMacro.ts +2 -1
  37. package/src/compile/assetMacro.ts +2 -1
  38. package/src/compile/compileProject.ts +28 -4
  39. package/src/compile/header.ts +17 -0
  40. package/src/compile/index.ts +2 -0
  41. package/src/compile/serverSplit.ts +29 -19
  42. package/src/compile/serverTypes.ts +1 -1
  43. package/src/core/Aspect.ts +2 -1
  44. package/src/g2/Physics2D.ts +6 -3
  45. package/src/g2/Scene2D.ts +9 -1
  46. package/src/gl/Scene.ts +9 -1
  47. package/src/gl/Texture.ts +2 -1
  48. package/src/gl/animation/DynamicBone.ts +6 -2
  49. package/src/gl/animation/Locomotion.ts +11 -1
  50. package/src/gl/physics/Physics.ts +3 -2
  51. package/src/gl/physics/Ragdoll.ts +8 -3
  52. package/src/gl/vehicle/Vehicle.ts +7 -2
  53. package/src/host.d.ts +8 -2
  54. package/src/inject.ts +4 -3
  55. package/src/plugin.ts +3 -0
  56. package/src/plugins/gen/push/contract.d.ts +24 -2
  57. package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
  58. package/src/plugins/gen/push/sdk/push.ts +159 -55
  59. package/src/runtime/app.ts +4 -2
  60. package/src/runtime/fetch.ts +6 -0
  61. package/src/runtime/pushSeam.ts +22 -0
  62. package/src/runtime/rpc.ts +35 -1
  63. package/src/runtime/storage.ts +14 -4
  64. package/src/server/auth/host.ts +40 -9
  65. package/src/server/auth/models.ts +5 -1
  66. package/src/server/auth/types.ts +2 -0
  67. package/src/server/db/marci/query.ts +8 -2
  68. package/src/server/host.ts +6 -0
  69. package/src/server/inject.ts +3 -2
  70. package/src/server/push/host.ts +143 -0
  71. package/src/server/pushChannel.ts +121 -0
  72. package/src/server/runtime.ts +20 -8
  73. package/src/ui/UIButton.ts +2 -2
  74. package/src/ui/UIContainer.ts +4 -2
  75. package/src/ui/UIInput.ts +2 -2
  76. package/src/ui/UINode.ts +2 -2
  77. package/src/ui/UIScreen.ts +2 -2
  78. package/src/ui/UIVideo.ts +3 -2
  79. package/src/ui/router.ts +4 -3
  80. package/src/version.ts +1 -1
  81. package/prompts/2d.md +0 -391
  82. package/prompts/3d-scene-files.md +0 -113
  83. package/prompts/3d-scene.md +0 -122
  84. package/prompts/3d.md +0 -299
  85. package/prompts/README.md +0 -142
  86. package/prompts/ar.md +0 -142
  87. package/prompts/canvas.md +0 -28
  88. package/prompts/compose.ts +0 -46
  89. package/prompts/concept.md +0 -49
  90. package/prompts/core-design.md +0 -98
  91. package/prompts/core.md +0 -294
  92. package/prompts/design.md +0 -218
  93. package/prompts/dist/2d-game.md +0 -1567
  94. package/prompts/dist/3d-app.md +0 -1712
  95. package/prompts/dist/ar-app.md +0 -1585
  96. package/prompts/dist/concept.md +0 -49
  97. package/prompts/dist/design.md +0 -521
  98. package/prompts/dist/ui-app.md +0 -1175
  99. package/prompts/index.md +0 -39
  100. package/prompts/intake-prompt.md +0 -33
  101. package/prompts/namer-prompt.md +0 -12
  102. package/prompts/router-prompt.md +0 -50
  103. package/prompts/select.ts +0 -126
  104. package/prompts/ui-design.md +0 -203
  105. package/prompts/ui.md +0 -813
package/package.json CHANGED
@@ -12,7 +12,6 @@
12
12
  "./inject": "./src/inject.ts",
13
13
  "./plugin": "./src/plugin.ts",
14
14
  "./server": "./src/server/host.ts",
15
- "./prompts/select": "./prompts/select.ts",
16
15
  "./global.d.ts": "./dist/global.d.ts",
17
16
  "./host.d.ts": "./dist/host.d.ts",
18
17
  "./editor.d.ts": "./dist/editor.d.ts",
@@ -30,12 +29,11 @@
30
29
  "test": "bun test --randomize",
31
30
  "typecheck": "tsc --noEmit",
32
31
  "docs:check": "node docs/check.mjs",
33
- "prompts:build": "bun prompts/compose.ts",
34
32
  "sync:marcidb": "bun scripts/sync-marcidb.ts",
35
33
  "prepublishOnly": "bun run build"
36
34
  },
37
35
  "dependencies": {
38
- "@letary/chisel": "^0.14.0"
36
+ "@letary/chisel": "^0.15.0"
39
37
  },
40
38
  "devDependencies": {
41
39
  "@types/bun": "^1.3.14",
@@ -45,11 +43,10 @@
45
43
  "marcidb-embedded": "^0.15.0",
46
44
  "sharp": "^0.35.2"
47
45
  },
48
- "version": "2.2.11",
46
+ "version": "2.2.14",
49
47
  "files": [
50
48
  "src",
51
49
  "dist",
52
- "prompts",
53
50
  "tests/helpers",
54
51
  "README.md"
55
52
  ],
@@ -244,7 +244,8 @@ const iconExpr = (svg: string, call: IconCall): string => {
244
244
  return call.tint ? `Object.assign(${src}, { tintColor: (${call.tint}) })` : src
245
245
  }
246
246
 
247
- const escapeTemplate = (s: string) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")
247
+ /** Text into a template literal: backslash, backtick and `${` escaped. */
248
+ export const escapeTemplate = (s: string) => s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")
248
249
 
249
250
  // ---- the macro pass -----------------------------------------------------------------------------
250
251
 
@@ -92,7 +92,8 @@ export type AssetMacroRef = { path: string, index: number, length: number, line:
92
92
  * image, a sound, a video, a model, a font, raw bytes) plus `.svg` (an SvgSource, what UIImage
93
93
  * takes). Not `.json`, whose asset is its parsed data — a `.json` path in a string stays a string. */
94
94
  const BARE_PATH_EXT = /\.(png|jpe?g|webp|gif|avif|bmp|svg|mp3|wav|ogg|m4a|aac|flac|mp4|webm|mov|m4v|glb|gltf|ktx2|hdr|exr|ttf|otf|woff2?|bin|bake|filamat)$/i
95
- const CODE_EXT = /\.([tj]sx?|mjs|cjs|json)$/i
95
+ /** A module the bundler parses as code (and .json, which is data it understands): never a text asset. */
96
+ export const CODE_EXT = /\.([tj]sx?|mjs|cjs|json)$/i
96
97
  // What precedes an import specifier: the literal is a module path, never a resource reference.
97
98
  const IMPORT_BEFORE = /(?:\bfrom|\bimport|\bimport\s*\(|\brequire\s*\()\s*$/
98
99
 
@@ -8,6 +8,8 @@ import { detectEntry } from "./detectEntry"
8
8
  import { buildHeader } from "./header"
9
9
  import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
10
10
  import { ClientPublishError, clientPublishes, stubServerEntries } from "./serverSplit"
11
+ import { CODE_EXT } from "./assetMacro"
12
+ import { escapeTemplate } from "./assetIconMacro"
11
13
 
12
14
  export type CompileFileType = "text" | "resource" | "shader"
13
15
 
@@ -145,12 +147,20 @@ export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: strin
145
147
  files[e.path] = `export default ""` // local-only shader with no compiled artifact yet
146
148
  }
147
149
  } else if (e.path.toLowerCase().endsWith(".svg")) {
148
- files[e.path] = `export default SvgSource(\`${e.text ?? ""}\`)`
150
+ // The file's text sits in a template literal: a backtick, `${` or a backslash in it (an
151
+ // editor's `<!-- exported by \`tool\` -->` comment) would otherwise end the literal and fail
152
+ // the compile with a chisel parse error that names nothing a person can act on.
153
+ files[e.path] = `export default SvgSource(\`${escapeTemplate(e.text ?? "")}\`)`
149
154
  } else if (e.path.toLowerCase().endsWith(".json")) {
150
155
  // A .json project file is DATA, not code: `import data from "./x.json"` — and `asset("./x.json")`,
151
156
  // which desugars to that import — yields the parsed value. The bundler would otherwise try to
152
157
  // parse the file as JS. JSON is an expression, so a default export is the whole translation.
153
158
  files[e.path] = `export default ${e.text?.trim() || "null"}`
159
+ } else if (!CODE_EXT.test(e.path)) {
160
+ // Any other text file (.txt, .csv, .md, .css, .html, .xml, .yml …) is its TEXT: `asset("./levels.csv")`
161
+ // yields the string. Handing the raw text to the bundler as a module parsed as JS failed at
162
+ // run time instead (`__asset_0 is not defined`), after a compile that said nothing.
163
+ files[e.path] = `export default ${JSON.stringify(e.text ?? "")}`
154
164
  } else {
155
165
  files[e.path] = e.text ?? ""
156
166
  }
@@ -158,13 +168,27 @@ export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: strin
158
168
  return files
159
169
  }
160
170
 
161
- /** The ids app.json lists under `"plugins"` (id → version), in its order. */
171
+ /** app.json's `"plugins"` in either shape — `{ "camera": "*" }` (id → version spec) or
172
+ * `["camera"]` (ids, every version `*`) — as id → spec. Anything else is no plugin. The list
173
+ * form is what CLAUDE.md and most hands write first; reading it as "no plugins" dropped the
174
+ * plugin from a web build and a shell with one warning nobody tied to the shape. */
175
+ export const pluginSpecsOf = (plugins: unknown): Record<string, string> => {
176
+ if (Array.isArray(plugins)) return Object.fromEntries(plugins.filter((p): p is string => typeof p === "string").map((id) => [id, "*"]))
177
+ if (plugins && typeof plugins === "object") {
178
+ return Object.fromEntries(Object.entries(plugins as Record<string, unknown>).map(([id, v]) => [id, typeof v === "string" ? v : "*"]))
179
+ }
180
+ return {}
181
+ }
182
+
183
+ /** The ids of app.json's `"plugins"` (either shape), in its order. */
184
+ export const pluginIdsOf = (plugins: unknown): string[] => Object.keys(pluginSpecsOf(plugins))
185
+
186
+ /** The ids app.json lists under `"plugins"`, in its order. */
162
187
  export const pluginsOfAppJson = (entries: CompileEntry[]): string[] => {
163
188
  const app = entries.find(e => e.path.replace(/^[./\\]+/, "") === "app.json")
164
189
  if (!app?.text) return []
165
190
  try {
166
- const plugins = (JSON.parse(app.text) as { plugins?: unknown }).plugins
167
- return plugins && typeof plugins === "object" && !Array.isArray(plugins) ? Object.keys(plugins) : []
191
+ return pluginIdsOf((JSON.parse(app.text) as { plugins?: unknown }).plugins)
168
192
  } catch {
169
193
  return [] // a broken app.json is reported by whoever reads it as a config
170
194
  }
@@ -28,6 +28,18 @@ export type HeaderOptions = {
28
28
  plugins?: string[]
29
29
  }
30
30
 
31
+ /** The optional engines of the game stack, by the bundled bridge calls that mean "in use": a body,
32
+ * a character, a vehicle or a ragdoll created, a ray cast, a sound loaded or played, the net
33
+ * bridge named, a navmesh call. Not the calls every 3D bundle carries (Node's body pose setters,
34
+ * Scene's audio listener). A `Physics.supported` probe pulls the Physics class whole, so it
35
+ * counts as use — the full variant is the safe side of that line. */
36
+ export const ENGINE_SIGNALS: readonly (readonly [name: "physics" | "net" | "nav" | "audio", re: RegExp])[] = [
37
+ ["physics", /_creator\.(?:physicsCreateBody|physicsConfigure|physicsRaycast|characterCreate|vehicleCreate|ragdollCreate)\(/],
38
+ ["net", /\b_creatorNet\b/], // the net core takes the bridge into a local once (core.ts bridge()): its calls never name it
39
+ ["nav", /_creatorNav\.\w+\(/],
40
+ ["audio", /_creatorAudio\.(?:loadClip|play)\(/], // a sound loaded or played; Scene's listener setters ride in every 3D bundle
41
+ ]
42
+
31
43
  export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}): string => {
32
44
  let header = `// sdk: ${SDK_VERSION}\n`
33
45
  header += `// name: ${name.slice(0, 20)}\n`
@@ -51,6 +63,11 @@ export const buildHeader = (name: string, code: string, opts: HeaderOptions = {}
51
63
  // bundle, and nothing 2D works without a Scene2D anyway.
52
64
  if (code.includes("_creator2d.createScene")) header += `// 2d: enable\n`
53
65
 
66
+ // The game stack a shell's SDK variant must carry (the `3d` variant has no Jolt, net, nav or
67
+ // game audio; `2d` / `core` no net or nav): the CLI's resolveVariant reads these lines, so a 3D
68
+ // game with physics no longer gets a variant where Physics.configure throws on the device.
69
+ for (const [name, re] of ENGINE_SIGNALS) if (re.test(code)) header += `// ${name}: enable\n`
70
+
54
71
  if (usesAR(code, opts.plugins)) header += `// ar: enable\n`
55
72
 
56
73
  // Short, so with the engine lines: a host reads it before it registers anything.
@@ -68,6 +68,8 @@ export {
68
68
  compileProject,
69
69
  compileProjectWithMap,
70
70
  pluginsOfAppJson,
71
+ pluginSpecsOf,
72
+ pluginIdsOf,
71
73
  pluginsOfBundle,
72
74
  entriesToFiles,
73
75
  localAssetNames,
@@ -2,14 +2,14 @@
2
2
  * The client/server split of a project (docs/backend-plan.md §4).
3
3
  *
4
4
  * `*.server.ts` files never reach the client bundle: `compileProject` swaps each one for a STUB
5
- * module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__serverOnly` — client SDK
5
+ * module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__pushChannel`, `__serverOnly` — client SDK
6
6
  * globals, src/runtime/rpc.ts), so `import { listNotes } from "./notes.server"` in client code keeps
7
7
  * its types and calls the server. `compileServerBundle` bundles the same files with the SERVER inject
8
8
  * behind a synthetic entry that registers every module on `globalThis.__lecodesServer` (chisel keeps
9
9
  * side effects, not entry exports) — the runner reads that (src/server/runtime.ts).
10
10
  *
11
11
  * Export kinds are found by a static scan (no TS program needed): a function export is an
12
- * endpoint, a `channel<…>()…` export is a channel, anything else is server-only.
12
+ * endpoint, a `channel<…>()…` export is a channel, a `pushChannel<…>()` export a push channel, anything else is server-only.
13
13
  */
14
14
 
15
15
  import type { AppConfig } from "../server/auth/appConfig"
@@ -29,7 +29,7 @@ export const serverModuleId = (path: string) => path.split("\\").join("/").repla
29
29
  * a helper: a function that is not async is used by server files alone, and the app's import of
30
30
  * it is refused at compile time (serverTypes.ts), never a call that silently holds a promise.
31
31
  */
32
- export type ServerExportKind = "fn" | "channel" | "other"
32
+ export type ServerExportKind = "fn" | "channel" | "push" | "other"
33
33
  export type ServerExport = { name: string, kind: ServerExportKind }
34
34
 
35
35
  // ───────────────────────────── static export scan ─────────────────────────────
@@ -66,6 +66,7 @@ const IDENT = "[A-Za-z_$][\\w$]*"
66
66
  const classifyInit = (init: string): ServerExportKind => {
67
67
  const s = init.trimStart()
68
68
  if (/^channel\s*[<(]/.test(s)) return "channel"
69
+ if (/^pushChannel\s*[<(]/.test(s)) return "push"
69
70
  if (/^async\s+function\b/.test(s)) return "fn"
70
71
  if (/^async\b/.test(s)) return "fn" // async (…) => / async x =>
71
72
  return "other" // a sync function, a value: the server's own
@@ -113,38 +114,43 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
113
114
  // ───────────────────────────── client stubs ─────────────────────────────
114
115
 
115
116
  /** The client-side replacement of a `*.server.ts` module. `serverUrl` is baked in (draft/prod are
116
- * separate compiles); "" means "no server deployed" — calls fail with a clear error. */
117
- export const serverStubSource = (path: string, exports: ServerExport[], serverUrl: string): string => {
117
+ * separate compiles); "" means "no server deployed" — calls fail with a clear error. `push` = the project
118
+ * has push channels (in this module or another): the stub opens by installing the push runtime, so the
119
+ * transport registers the device when the server asks, whether or not the app hears a channel itself. */
120
+ export const serverStubSource = (path: string, exports: ServerExport[], serverUrl: string, push = exports.some(e => e.kind === "push")): string => {
118
121
  const mod = serverModuleId(path)
119
122
  const url = JSON.stringify(serverUrl)
120
123
  const lines = exports.map(({ name, kind }) => {
121
124
  const id = JSON.stringify(`${mod}#${name}`)
122
125
  if (kind === "fn") return `export const ${name} = __rpc(${url}, ${id})`
123
126
  if (kind === "channel") return `export const ${name} = __channel(${url}, ${id})`
127
+ if (kind === "push") return `export const ${name} = __pushChannel(${url}, ${id})`
124
128
  return `export const ${name} = __serverOnly(${id})`
125
129
  })
130
+ if (push) lines.unshift("__pushEnable()")
126
131
  return `// client stub of ${mod} — generated by the lecodes compiler\n${lines.join("\n")}\n`
127
132
  }
128
133
 
129
134
  // ───────────────────────────── a channel in the app ─────────────────────────────
130
135
 
131
136
  /** A channel export has ONE type on both sides, so `publish` type-checks in the app — where the
132
- * export is the subscribing proxy. Said at compile time, with what to do instead. */
137
+ * export is the subscribing proxy; a push channel's `send` likewise. Said at compile time, with what to do instead. */
133
138
  export class ClientPublishError extends Error {
134
139
  readonly problems: string[]
135
140
  constructor(problems: string[]) {
136
- super(`a channel goes from the server to the app — the app subscribes; to send, call a server function that publishes:\n ${problems.join("\n ")}`)
141
+ super(`a channel goes from the server to the app — the app listens; to send, call a server function that publishes (a push channel: that sends):\n ${problems.join("\n ")}`)
137
142
  this.name = "ClientPublishError"
138
143
  this.problems = problems
139
144
  }
140
145
  }
141
146
 
142
- /** `<channel>.publish(` in the project's client files, for every channel imported from a `*.server.ts`. */
147
+ /** `<channel>.publish(` and `<pushChannel>.send(` in the project's client files, for every channel imported from a `*.server.ts`. */
143
148
  export const clientPublishes = (entries: CompileEntry[]): string[] => {
144
- const channels = new Map<string, Set<string>>()
149
+ /** module → export name → the server's verb */
150
+ const channels = new Map<string, Map<string, string>>()
145
151
  for (const [path, exports] of Object.entries(scanServerModules(entries))) {
146
- const names = exports.filter(e => e.kind === "channel").map(e => e.name)
147
- if (names.length) channels.set(path.replace(/\.[tj]s$/, ""), new Set(names))
152
+ const names = new Map(exports.filter(e => e.kind === "channel" || e.kind === "push").map(e => [e.name, e.kind === "push" ? "send" : "publish"]))
153
+ if (names.size) channels.set(path.replace(/\.[tj]s$/, ""), names)
148
154
  }
149
155
  if (channels.size === 0) return []
150
156
  const out: string[] = []
@@ -152,21 +158,22 @@ export const clientPublishes = (entries: CompileEntry[]): string[] => {
152
158
  if (e.type !== "text" || isServerPath(e.path) || !/\.[tj]sx?$/.test(e.path)) continue
153
159
  const src = e.text ?? ""
154
160
  const dir = normalizePath(e.path).replace(/\/[^/]*$/, "")
155
- const locals: string[] = []
161
+ const locals: [string, string][] = []
156
162
  for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g)) {
157
163
  if (!m[2].startsWith(".")) continue
158
164
  const names = channels.get(normalizePath(`${dir}/${m[2]}`).replace(/\.[tj]s$/, ""))
159
165
  if (!names) continue
160
166
  for (const part of m[1].split(",")) {
161
167
  const [imported, local] = part.split(/\s+as\s+/).map(s => s.trim())
162
- if (imported && names.has(imported)) locals.push(local ?? imported)
168
+ const verb = imported ? names.get(imported) : undefined
169
+ if (verb) locals.push([local ?? imported, verb])
163
170
  }
164
171
  }
165
172
  if (!locals.length) continue
166
173
  const code = blankCommentsAndStrings(src)
167
- for (const local of locals) {
168
- for (const m of code.matchAll(new RegExp(`(?<![\\w$.])${local.replace(/\$/g, "\\$")}\\s*\\.\\s*publish\\s*\\(`, "g"))) {
169
- out.push(`${serverModuleId(e.path)}:${code.slice(0, m.index).split("\n").length}: ${local}.publish(…)`)
174
+ for (const [local, verb] of locals) {
175
+ for (const m of code.matchAll(new RegExp(`(?<![\\w$.])${local.replace(/\$/g, "\\$")}\\s*\\.\\s*${verb}\\s*\\(`, "g"))) {
176
+ out.push(`${serverModuleId(e.path)}:${code.slice(0, m.index).split("\n").length}: ${local}.${verb}(…)`)
170
177
  }
171
178
  }
172
179
  }
@@ -228,10 +235,13 @@ export const scanServerModules = (entries: CompileEntry[]): Record<string, Serve
228
235
  }
229
236
 
230
237
  /** Replace each `*.server.ts` entry with its client stub (returns a new entries array). */
231
- export const stubServerEntries = (entries: CompileEntry[], serverUrl: string): CompileEntry[] =>
232
- entries.map(e => e.type === "text" && isServerPath(e.path)
233
- ? { ...e, text: serverStubSource(e.path, scanServerExports(e.text ?? "", serverModuleId(e.path)), serverUrl) }
238
+ export const stubServerEntries = (entries: CompileEntry[], serverUrl: string): CompileEntry[] => {
239
+ const modules = scanServerModules(entries)
240
+ const push = Object.values(modules).some(exports => exports.some(e => e.kind === "push"))
241
+ return entries.map(e => e.type === "text" && isServerPath(e.path)
242
+ ? { ...e, text: serverStubSource(e.path, modules[normalizePath(e.path)] ?? [], serverUrl, push) }
234
243
  : e)
244
+ }
235
245
 
236
246
  export type CompileServerOptions = {
237
247
  entries: CompileEntry[]
@@ -237,7 +237,7 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
237
237
  if (!modSym) continue
238
238
  const exported = new Map(checker.getExportsOfModule(modSym).map(s => [s.name, s]))
239
239
  for (const ex of exports) {
240
- if (ex.kind === "other") continue
240
+ if (ex.kind === "other" || ex.kind === "push") continue
241
241
  const id = `${serverModuleId(path)}#${ex.name}`
242
242
  let sym = exported.get(ex.name)
243
243
  if (!sym) { warnings.push(`${id}: export not found by the checker — unvalidated`); continue }
@@ -403,7 +403,8 @@ export abstract class AspectHost<E extends EventMap = EventMap> extends Emitter<
403
403
  }
404
404
 
405
405
  // ---- systems: aspects of a scene --------------------------------------------
406
- /** @internal What a scene needs to carry systems. Scene / Scene2D implement it over these helpers. */
406
+ /** What a scene needs to carry systems: Scene and Scene2D implement it over the helpers below. Public
407
+ * (never stripped from the public types: the `System` type names it). */
407
408
  export type SystemHost = { readonly _systems: Map<Function, Aspect<any, any, any>> }
408
409
 
409
410
  /** @internal `scene.system(Ctor, opts)`. */
@@ -231,9 +231,12 @@ export class Physics2D extends Aspect<"physics", Node2D> {
231
231
  }
232
232
  private static _gravity: [number, number] = [ 0, -980 ]
233
233
 
234
- /** Whether this build has physics support (CREATOR_2D_PHYSICS). NOTE: this reports the BUILD, not
235
- * the world — there is nothing else to guard, since the world creates itself. */
236
- static get supported(): boolean { return !!_creator2d.physicsHasSupport && _creator2d.physicsHasSupport() }
234
+ /** Whether this host has 2D physics (a 2D engine built with CREATOR_2D_PHYSICS). False, never a
235
+ * throw, where there is no 2D engine at all (Web Lite, a 3d / core engine variant). It reports
236
+ * the build, not the world — there is nothing else to guard, since the world creates itself. */
237
+ static get supported(): boolean {
238
+ return typeof _creator2d !== "undefined" && !!_creator2d && typeof _creator2d.physicsHasSupport === "function" && _creator2d.physicsHasSupport()
239
+ }
237
240
 
238
241
  /** Render interpolation of body transforms between the fixed 60 Hz steps (global; default on). Turn
239
242
  * off to save per-frame transform writes when you have many moving bodies / don't need >60 Hz
package/src/g2/Scene2D.ts CHANGED
@@ -62,6 +62,12 @@ export class Scene2D implements Presentable {
62
62
  private static _active: Scene2D | null = null
63
63
  static get active(): Scene2D | null { return Scene2D._active }
64
64
 
65
+ /** Whether this host has the 2D engine. False on Web Lite (UI and 3D only) and in a 3d / core
66
+ * engine variant of an app built with `lecodes app`; there `new Scene2D()` throws. */
67
+ static get supported(): boolean {
68
+ return typeof _creator2d !== "undefined" && !!_creator2d && typeof _creator2d.createScene === "function"
69
+ }
70
+
65
71
  /** @internal — scene-level pointer listeners (the catch-all; target = hit node or null). */
66
72
  readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
67
73
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []
@@ -70,6 +76,7 @@ export class Scene2D implements Presentable {
70
76
  private readonly _filter?: 'nearest' | 'linear'
71
77
 
72
78
  constructor(options: Scene2DOptions = {}) {
79
+ if (!Scene2D.supported) throw new Error("Scene2D: this host has no 2D engine (Web Lite runs UI and 3D apps; a 3d / core engine variant has no 2D) — check Scene2D.supported before creating one")
73
80
  installRenderSyncedFrames()
74
81
  this.id = _creator2d.createScene()
75
82
  this.camera = new Camera2D(this.id)
@@ -121,7 +128,8 @@ export class Scene2D implements Presentable {
121
128
  }
122
129
 
123
130
  // ---- systems: aspects of the scene (core/Aspect.ts `System<'x', Scene2D>`) ----
124
- /** @internal class → instance, the authoritative store (named accessors mirror this). */
131
+ /** class → instance, the authoritative store (named accessors mirror this). Kept in the public
132
+ * types: it is what makes a Scene2D a `SystemHost` there. */
125
133
  readonly _systems = new Map<Function, Aspect<any, any, any>>()
126
134
 
127
135
  /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
package/src/gl/Scene.ts CHANGED
@@ -206,7 +206,14 @@ export class Scene implements Presentable {
206
206
  private static _active: Scene | null = null
207
207
  static get active(): Scene | null { return Scene._active }
208
208
 
209
+ /** Whether this host has the 3D engine. False where the bundle was marked `gl: disable` (no scene
210
+ * created anywhere in it) and in a 2d / core engine variant; there `new Scene()` throws. */
211
+ static get supported(): boolean {
212
+ return typeof _creator !== "undefined" && !!_creator && typeof _creator.createScene === "function"
213
+ }
214
+
209
215
  constructor(options: SceneOptions = {}) {
216
+ if (!Scene.supported) throw new Error("Scene: this host has no 3D engine (a 2d / core engine variant, or a bundle that creates no scene) — check Scene.supported before creating one")
210
217
  installRenderSyncedFrames()
211
218
  const parent = (options as any)._parent as Scene | undefined
212
219
  if (parent) {
@@ -366,7 +373,8 @@ export class Scene implements Presentable {
366
373
  }
367
374
 
368
375
  // ---- systems: aspects of the scene (core/Aspect.ts `System`) ----
369
- /** @internal class → instance, the authoritative store (named accessors mirror this). */
376
+ /** class → instance, the authoritative store (named accessors mirror this). Kept in the public
377
+ * types: it is what makes a Scene a `SystemHost` there. */
370
378
  readonly _systems = new Map<Function, Aspect<any, any, any>>()
371
379
 
372
380
  /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
package/src/gl/Texture.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // A 3D GPU texture, decoded natively by creator-gl. Load from a URL or an already-fetched response.
2
2
 
3
3
  import { fetch, type FetchResponse, type File } from "../runtime/fetch"
4
+ import type { Canvas } from "../canvas/Canvas"
4
5
 
5
6
  /** `Texture.load` options. */
6
7
  export type TextureLoadOptions = {
@@ -57,7 +58,7 @@ export class Texture {
57
58
  // Wrap a Canvas's baked surface as a 3D texture (RGBA8, already rasterized → synchronous, no decode).
58
59
  // The id is cached on the canvas (opaque _tex3dId), so backing several materials shares one texture and
59
60
  // canvas.update() re-uploads it. Structural type avoids a Canvas import (keeps 3D free of 2D-canvas code).
60
- static fromCanvas(canvas: { _bakeSurface(): number, width: number, height: number, pixelRatio: number, _tex3dId: number }): Texture {
61
+ static fromCanvas(canvas: Canvas): Texture {
61
62
  if (!canvas._tex3dId) {
62
63
  const surfaceId = canvas._bakeSurface()
63
64
  canvas._tex3dId = _creator.createTextureFromCanvas(surfaceId)
@@ -54,8 +54,8 @@ const FLOOR_NONE = 0, FLOOR_PLANE = 1, FLOOR_PROBE = 2
54
54
 
55
55
  let warned = false
56
56
  const supported = (): boolean => {
57
- if (_creator.dynamicBoneCreate) return true
58
- if (!warned) { warned = true; console.warn("DynamicBone needs a newer host (no dynamicBoneCreate) — the animation shows as is") }
57
+ if (DynamicBone.supported) return true
58
+ if (!warned) { warned = true; console.warn("DynamicBone: this host has no dynamic bones (DynamicBone.supported is false) — the animation shows as is") }
59
59
  return false
60
60
  }
61
61
 
@@ -134,6 +134,10 @@ export class DynamicBoneCollider extends Aspect<"dynamicBoneCollider", Node> {
134
134
  export class DynamicBone extends Aspect<"dynamicBone", Node> {
135
135
  static readonly aspect = "dynamicBone"
136
136
 
137
+ /** Whether this host simulates dynamic bones. False, never a throw, on Web Lite and in headless
138
+ * tests; there the aspect attaches, warns once, and `chain` stays empty. */
139
+ static get supported(): boolean { return typeof _creator !== "undefined" && !!_creator && typeof _creator.dynamicBoneCreate === "function" }
140
+
137
141
  /** Only these bones (by name) join the chain — the root always does, a bone left out takes its
138
142
  * subtree with it. Default: the root's whole subtree. */
139
143
  bones?: string[]
@@ -225,9 +225,15 @@ export type LocomotionEvents = {
225
225
  state: (state: LocomotionState) => void
226
226
  }
227
227
 
228
+ let locoWarned = false
229
+
228
230
  export class Locomotion extends Aspect<"loco", Node, LocomotionEvents> {
229
231
  static readonly aspect = "loco"
230
232
 
233
+ /** Whether this host runs the locomotion selector. False, never a throw, on Web Lite and in
234
+ * headless tests; there the aspect attaches, warns once, and the body stays `'idle'`. */
235
+ static get supported(): boolean { return typeof _creator !== "undefined" && !!_creator && typeof _creator.locoCreate === "function" }
236
+
231
237
  /** The animator whose clips are shown. Default: the first one on this node or under it (a Model
232
238
  * child), which is the usual character — a capsule node with the model inside. */
233
239
  animator?: Animator
@@ -281,7 +287,11 @@ export class Locomotion extends Aspect<"loco", Node, LocomotionEvents> {
281
287
  * walk, a slowed-down character). */
282
288
  rebuild(): void {
283
289
  const anim = this.anim
284
- if (!anim || !_creator.locoCreate) return
290
+ if (!Locomotion.supported) {
291
+ if (!locoWarned) { locoWarned = true; console.warn("Locomotion: this host has no locomotion engine (Locomotion.supported is false) — the character stays idle") }
292
+ return
293
+ }
294
+ if (!anim) return
285
295
  if (!this._loco) {
286
296
  const first = this.set.idle ?? (this.set.gaits ?? [])[0] // binding a clip is what creates the native animator
287
297
  if (first !== undefined) anim._slot(name(first))
@@ -209,9 +209,10 @@ export class Physics extends Aspect<"physics", Node> implements CompWriter {
209
209
  _creator.physicsConfigure(cx(g), cy(g), cz(g), config.maxBodies ?? 0)
210
210
  }
211
211
 
212
- /** Whether this build has physics support (CREATOR_GL_PHYSICS). */
212
+ /** Whether this host has 3D physics (an engine built with CREATOR_GL_PHYSICS). False, never a
213
+ * throw, where there is no 3D engine at all. */
213
214
  static get supported(): boolean {
214
- return !!_creator.physicsHasSupport && _creator.physicsHasSupport()
215
+ return typeof _creator !== "undefined" && !!_creator && typeof _creator.physicsHasSupport === "function" && _creator.physicsHasSupport()
215
216
  }
216
217
 
217
218
  /** Closest pickable body hit by the ray from `origin` along `dir` (up to `maxDist`), or null. */
@@ -45,7 +45,7 @@ import type { FieldMeta } from "../../core/fields"
45
45
  import { Mathf } from "../../math/Mathf"
46
46
  import { cx, cy, cz, Vec3, type Vec3Like } from "../../math/vec"
47
47
  import { Node } from "../Node"
48
- import { DEFAULT_FRICTION } from "./Physics"
48
+ import { DEFAULT_FRICTION, Physics } from "./Physics"
49
49
 
50
50
  export interface RagdollHinge {
51
51
  /** The hinge axis in the MODEL's space (its rest pose: +Z forward, +Y up, +X the model's left in
@@ -169,6 +169,11 @@ export const humanoidParts = (find: (role: string) => string | null): RagdollPar
169
169
  export class Ragdoll extends Aspect<"ragdoll", Node> {
170
170
  static readonly aspect = "ragdoll"
171
171
 
172
+ /** Whether this host can drop a body: 3D physics with ragdoll parts. False, never a throw,
173
+ * without physics or on an older host. A host that has it can still refuse the parts
174
+ * (`activate()` returns false). */
175
+ static get supported(): boolean { return Physics.supported && typeof _creator.ragdollCreate === "function" }
176
+
172
177
  /** The parts: `'humanoid'` (default) finds the standard bones by name; a list places bodies on any
173
178
  * bones — parents before children. */
174
179
  parts: RagdollPart[] | "humanoid" = "humanoid"
@@ -243,9 +248,9 @@ export class Ragdoll extends Aspect<"ragdoll", Node> {
243
248
  private _release: { left: number; blend: number } | null = null
244
249
 
245
250
  onAttach(): void {
246
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
251
+ if (!Physics.supported) return
247
252
  if (!_creator.ragdollCreate) {
248
- console.warn("Ragdoll needs a newer host (no ragdollCreate) — the body will not fall")
253
+ console.warn("Ragdoll: this host has no ragdoll parts (Ragdoll.supported is false) — the body will not fall")
249
254
  return
250
255
  }
251
256
  const find = (role: string): string | null => {
@@ -194,6 +194,11 @@ const clamp = (v: number, lo: number, hi: number): number => (v < lo ? lo : v >
194
194
  export class Vehicle extends Aspect<"vehicle", Node> {
195
195
  static readonly aspect = "vehicle"
196
196
 
197
+ /** Whether this host can drive a car: 3D physics with the vehicle model. False, never a throw,
198
+ * without physics or on an older host. A host that has it can still refuse a car's settings
199
+ * (`id` stays 0, with a warning). */
200
+ static get supported(): boolean { return Physics.supported && typeof _creator.vehicleCreate === "function" }
201
+
197
202
  /** Chassis mass in kg (default 1200). Wheels are massless in this model. */
198
203
  mass = 1200
199
204
  /** Centre of mass height in chassis space (metres, negative = lower). Left out, it drops to halfway
@@ -274,9 +279,9 @@ export class Vehicle extends Aspect<"vehicle", Node> {
274
279
  private _retunePending = false
275
280
 
276
281
  onAttach(): void {
277
- if (!_creator.physicsHasSupport || !_creator.physicsHasSupport()) return
282
+ if (!Physics.supported) return
278
283
  if (!_creator.vehicleCreate) {
279
- console.warn("Vehicle needs a newer host (no vehicleCreate) — the car will not move")
284
+ console.warn("Vehicle: this host has no vehicle model (Vehicle.supported is false) — the car will not move")
280
285
  return
281
286
  }
282
287
  const shape = this.node.get(Shape)
package/src/host.d.ts CHANGED
@@ -6,8 +6,11 @@
6
6
  import type { SvgSource } from "./runtime/misc"
7
7
 
8
8
  declare global {
9
- function setTimeout(handler: (...args: any[]) => void, timeout?: number): number
10
- function setInterval(handler: (...args: any[]) => void, timeout?: number): number
9
+ /** As in the browser, but the delay is REQUIRED (the native runtime throws without it) and no
10
+ * extra argument reaches the handler. Timers tick with the frame on every native host: one
11
+ * frame is the resolution. */
12
+ function setTimeout(handler: () => void, timeout: number): number
13
+ function setInterval(handler: () => void, timeout: number): number
11
14
  function clearTimeout(id: number): void
12
15
  function clearInterval(id: number): void
13
16
  /** Per-frame loop, driven by the host (viewer RAF / desktop runTick). `dt` is **seconds** since
@@ -38,6 +41,9 @@ declare global {
38
41
  /** Compile-time macro: `asset('./logo.svg')` yields the file as an SVG image source — for
39
42
  * `UIImage(...)` and `bgImage`. */
40
43
  function asset(path: `${string}.svg`): SvgSource
44
+ /** Compile-time macro: `asset('./levels.csv')` yields a text file's TEXT (.txt, .csv, .md, .css,
45
+ * .html, .xml, .yml — it ships inside the bundle). Parse it yourself. */
46
+ function asset(path: `${string}.${"txt" | "csv" | "md" | "css" | "html" | "xml" | "yml" | "yaml"}`): string
41
47
  /** Compile-time macro: `asset('./hero.png')` is desugared by the bundler into the module import
42
48
  * for that resource. The file must exist — a path that resolves to nothing fails the compile
43
49
  * (`asset not found: ./hero.png (main.ts:3)`); there is no runtime fallback. Calls inside
package/src/inject.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  // ---- math ----
11
11
  export { Mathf } from "./math/Mathf"
12
- export { Vec2, Vec3 } from "./math/vec"
12
+ export { Vec2, Vec3, type Vec2Like, type Vec3Like } from "./math/vec"
13
13
  export { Quat } from "./math/quat"
14
14
  export { Mat4 } from "./math/mat4"
15
15
  export { Color } from "./core/color"
@@ -43,10 +43,11 @@ export { WebSocket } from "./runtime/net"
43
43
  export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
44
44
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
45
45
  // only ever imports its server functions (docs/backend-plan.md §4).
46
- export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
46
+ export { __rpc, __channel, __pushChannel, __serverOnly, RpcError } from "./runtime/rpc"
47
47
  // What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
48
48
  export { resource, action, dataOf, failureOf, type Loaded, type Failure, type Resource, type Action } from "./runtime/resource"
49
49
  export type { ChannelSubscription } from "./server/channel"
50
+ export type { PushChannel, PushMessage, PushNotification, PushTarget } from "./server/pushChannel"
50
51
  export type { StoredFile, StoredImage } from "./server/files/models"
51
52
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
52
53
  // Game audio (docs/audio-plan.md): decoded clips, a voice pool, buses with effects; 3D through the
@@ -162,7 +163,7 @@ export {
162
163
  type MapPaddingValues, type MarkerItem, type MarkerTap, type MarkerLayer, type MarkerLayerOptions,
163
164
  type LineLayer, type LineLayerOptions, type UserLocationOptions,
164
165
  } from "./plugins/gen/map/sdk/map"
165
- export { Push, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/gen/push/sdk/push"
166
+ export { Push, __pushEnable, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/gen/push/sdk/push"
166
167
  export { Browser, WebView, type BrowserOpenOptions, type WebViewParams, type WebViewEvents } from "./plugins/gen/browser/sdk/browser"
167
168
  export {
168
169
  RTCPeerConnection, MediaStream, MediaStreamTrack, RTCDataChannel, RTCRtpSender, RTCRtpReceiver, RTCRtpTransceiver,
package/src/plugin.ts CHANGED
@@ -44,6 +44,9 @@ export { Quat, type QuatLike } from "./math/quat"
44
44
  export { Mat4 } from "./math/mat4"
45
45
  export type { ClickEvent, TouchStartEvent } from "./runtime/touch"
46
46
  export type { DismissOptions } from "./ui/presentable"
47
+ /** The seam the push plugin's wrapper installs its runtime on (src/runtime/pushSeam.ts): what the app's transport
48
+ * reads the device's address from, registers through, and a push channel's handlers are kept by. */
49
+ export { _pushRuntime, type PushRuntime } from "./runtime/pushSeam"
47
50
  /** @internal The navigation epoch: a destination that prepares before it presents (ARScene) reads it
48
51
  * before its awaits and gives up when another navigation moved it meanwhile. */
49
52
  export { _navEpoch }