lecodes-sdk 2.4.10 → 2.5.1

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 (76) hide show
  1. package/dist/global.d.ts +35 -0
  2. package/dist/types/core/env.d.ts +25 -0
  3. package/dist/types/core/particlesConfig.d.ts +196 -0
  4. package/dist/types/g2/Font2D.d.ts +19 -0
  5. package/dist/types/g2/Material2D.d.ts +24 -0
  6. package/dist/types/g2/Node2D.d.ts +6 -5
  7. package/dist/types/g2/Particles2D.d.ts +169 -0
  8. package/dist/types/g2/Path2D.d.ts +95 -0
  9. package/dist/types/g2/Physics2D.d.ts +1 -1
  10. package/dist/types/g2/Scene2D.d.ts +15 -17
  11. package/dist/types/g2/Sprite.d.ts +12 -1
  12. package/dist/types/g2/SpriteSheet.d.ts +4 -1
  13. package/dist/types/g2/Text2D.d.ts +96 -0
  14. package/dist/types/g2/Tilemap.d.ts +8 -0
  15. package/dist/types/g2/defineScene2d.d.ts +69 -11
  16. package/dist/types/gl/Foliage.d.ts +0 -7
  17. package/dist/types/gl/Lightmap.d.ts +101 -2
  18. package/dist/types/gl/Material.d.ts +21 -15
  19. package/dist/types/gl/Model.d.ts +8 -3
  20. package/dist/types/gl/Particles.d.ts +3 -135
  21. package/dist/types/gl/Statics.d.ts +56 -0
  22. package/dist/types/gl/staticsFormat.d.ts +101 -0
  23. package/dist/types/inject.d.ts +11 -3
  24. package/dist/types/runtime/env.d.ts +10 -0
  25. package/dist/types/runtime/envValues.d.ts +8 -0
  26. package/dist/types/scene/defineScene.d.ts +17 -12
  27. package/dist/types/server/env.d.ts +14 -0
  28. package/dist/types/server/inject.d.ts +1 -0
  29. package/dist/types/ui/fonts.d.ts +4 -0
  30. package/dist/types/version.d.ts +1 -1
  31. package/dist/types.json +1 -1
  32. package/package.json +1 -1
  33. package/src/bridges/2d.d.ts +65 -5
  34. package/src/bridges/gl.d.ts +34 -15
  35. package/src/compile/bakeMacro.ts +3 -0
  36. package/src/compile/compileProject.ts +25 -4
  37. package/src/compile/envCompile.ts +74 -0
  38. package/src/compile/fontMacro.ts +11 -6
  39. package/src/compile/index.ts +6 -0
  40. package/src/compile/resourceSlots.ts +1 -0
  41. package/src/compile/serverSplit.ts +47 -4
  42. package/src/core/env.ts +47 -0
  43. package/src/core/particlesConfig.ts +422 -0
  44. package/src/g2/Font2D.ts +87 -0
  45. package/src/g2/Material2D.ts +64 -0
  46. package/src/g2/Node2D.ts +10 -6
  47. package/src/g2/Particles2D.ts +337 -0
  48. package/src/g2/Path2D.ts +215 -0
  49. package/src/g2/Physics2D.ts +1 -1
  50. package/src/g2/Scene2D.ts +16 -22
  51. package/src/g2/Sprite.ts +23 -4
  52. package/src/g2/SpriteSheet.ts +9 -5
  53. package/src/g2/Text2D.ts +178 -0
  54. package/src/g2/Tilemap.ts +10 -0
  55. package/src/g2/defineScene2d.ts +108 -21
  56. package/src/g2/groups2d.ts +2 -2
  57. package/src/gl/Foliage.ts +1 -16
  58. package/src/gl/Lightmap.ts +70 -26
  59. package/src/gl/Material.ts +51 -21
  60. package/src/gl/Model.ts +19 -5
  61. package/src/gl/Particles.ts +22 -361
  62. package/src/gl/Statics.ts +301 -0
  63. package/src/gl/nav/NavMesh.ts +4 -1
  64. package/src/gl/staticsFormat.ts +240 -0
  65. package/src/inject.ts +18 -3
  66. package/src/runtime/env.ts +13 -0
  67. package/src/runtime/envValues.ts +8 -0
  68. package/src/runtime/fetch.ts +18 -0
  69. package/src/scene/defineScene.ts +107 -27
  70. package/src/server/certs/russian-trusted-ca.pem +81 -0
  71. package/src/server/env.ts +25 -0
  72. package/src/server/host.ts +7 -0
  73. package/src/server/inject.ts +2 -1
  74. package/src/server/trustedCa.ts +23 -0
  75. package/src/ui/fonts.ts +46 -0
  76. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.15.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.4.10",
46
+ "version": "2.5.1",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -50,8 +50,9 @@ declare global {
50
50
  addEntityToScene(sceneId: i32, entityId: i32): void
51
51
  /** @c c2dRemoveEntityFromScene */
52
52
  removeEntityFromScene(sceneId: i32, entityId: i32): void
53
- /** @c c2dSetLayerYSort */
54
- setLayerYSort(sceneId: i32, layer: i32, enabled: boolean): void
53
+ /** Y-sort: among entities of one renderPriority and one z, the one lower on screen (smaller world Y)
54
+ * draws on top — top-down depth. One flag per scene. @c c2dSetYSort */
55
+ setYSort(sceneId: i32, enabled: boolean): void
55
56
 
56
57
  /** @c c2dCameraSetPosition */
57
58
  cameraSetPosition(sceneId: i32, x: f32, y: f32): void
@@ -76,10 +77,14 @@ declare global {
76
77
  setRotation(entityId: i32, deg: f32): void
77
78
  /** @c c2dSetScale */
78
79
  setScale(entityId: i32, sx: f32, sy: f32): void
79
- /** @c c2dSetLayer */
80
- setLayer(entityId: i32, layer: i32): void
81
- /** @c c2dSetZ */
80
+ /** Coarse draw order 0 (first) … 7 (last), default 4 — the 3D Mesh.renderPriority. Within one
81
+ * priority `z` orders, then Y-sort. @c c2dSetRenderPriority */
82
+ setRenderPriority(entityId: i32, priority: i32): void
83
+ /** Draw order within a renderPriority (higher = on top). @c c2dSetZ */
82
84
  setZ(entityId: i32, z: f32): void
85
+ /** The node's Material2D as the three ints the renderer batches by: blend 0 alpha / 1 additive;
86
+ * filter 0 renderer default / 1 nearest / 2 linear; wrap 0 clamp / 1 repeat. @c c2dSetMaterial */
87
+ setMaterial(entityId: i32, blend: i32, filter: i32, wrap: i32): void
83
88
  /** @c c2dSetVisible */
84
89
  setVisible(entityId: i32, visible: boolean): void
85
90
  /** @c c2dGetPosition @out 2 */
@@ -131,6 +136,9 @@ declare global {
131
136
  destroyTexture(texId: i32): void
132
137
  /** @c c2dSetDefaultFilter */
133
138
  setDefaultFilter(linear: boolean): void
139
+ /** The engine's 1x1 white texture — a sprite without art is a solid rect of its tint, and vector
140
+ * fills draw through it in the sprite batch. Created on first use. @c c2dWhiteTexture */
141
+ whiteTexture(): i32
134
142
 
135
143
  // Textures from a _creatorCanvas surface (RGBA8, already rasterized — no decode, so synchronous).
136
144
  // See ./canvas.d.ts.
@@ -155,6 +163,58 @@ declare global {
155
163
  /** @c c2dSetTile */
156
164
  setTile(tilemapId: i32, x: i32, y: i32, index: i32): void
157
165
 
166
+ // Paths (Path2D): vector strokes and fills in the entity's local frame, drawn through the white
167
+ // texture so they batch with sprites. pts = x,y pairs; count = points.
168
+ /** Turns the entity into a path (as setSprite makes a sprite) and sets its outer ring. @c c2dSetPath */
169
+ setPath(entityId: i32, pts: Float32Array, count: i32, closed: boolean): void
170
+ /** @c c2dPathClearHoles */
171
+ pathClearHoles(entityId: i32): void
172
+ /** A hole ring (fills only). @c c2dPathAddHole */
173
+ pathAddHole(entityId: i32, pts: Float32Array, count: i32): void
174
+ /** width <= 0 = no stroke; widthPx = logical screen px (constant while zooming) else world units;
175
+ * join 0 miter / 1 round / 2 bevel; cap 0 butt / 1 round / 2 square. @c c2dSetPathStroke */
176
+ setPathStroke(entityId: i32, width: f32, widthPx: boolean, join: i32, cap: i32, r: f32, g: f32, b: f32, a: f32): void
177
+ /** @c c2dSetPathFill */
178
+ setPathFill(entityId: i32, enabled: boolean, r: f32, g: f32, b: f32, a: f32): void
179
+ /** The stroke's outline: a band of `width` (the stroke's units) around it, drawn in the engine's UNDER
180
+ * pass — at one z every node's outline lies under every node's body, so roads merge at junctions.
181
+ * 0 = none. @c c2dSetPathOutline */
182
+ setPathOutline(entityId: i32, width: f32, r: f32, g: f32, b: f32, a: f32): void
183
+ /** The 1-px anti-alias fringe, on by default (off for crisp pixel-art lines). @c c2dSetPathAntialias */
184
+ setPathAntialias(entityId: i32, enabled: boolean): void
185
+
186
+ // Text (Text2D): fonts parsed natively (TTF/OTF bytes from a host fetch buffer), glyphs rasterized
187
+ // as signed distance fields into one atlas per font — one atlas for every size.
188
+ /** @custom */
189
+ createFont(systemId: bytes, onComplete: (fontId: number) => void, onReject: (e: any) => void): void
190
+ /** @c c2dDestroyFont */
191
+ destroyFont(fontId: i32): void
192
+ /** Turns the entity into a text (as setSprite makes a sprite). size = em height in world units, or in
193
+ * logical screen px when sizePx (constant on screen while zooming). The block is anchored by
194
+ * setSpriteAnchor. @c c2dSetText */
195
+ setText(entityId: i32, fontId: i32, text: string, size: f32, sizePx: boolean): void
196
+ /** align 0 left / 1 center / 2 right; maxWidth in `size` units (0 = no wrap); lineHeight a multiplier
197
+ * of the font's own (0 = the font's); letterSpacing in em. @c c2dSetTextStyle */
198
+ setTextStyle(entityId: i32, align: i32, maxWidth: f32, lineHeight: f32, letterSpacing: f32): void
199
+ /** @c c2dSetTextColor */
200
+ setTextColor(entityId: i32, r: f32, g: f32, b: f32, a: f32): void
201
+ /** A halo, width in em (0 = none, up to ~0.15). @c c2dSetTextOutline */
202
+ setTextOutline(entityId: i32, width: f32, r: f32, g: f32, b: f32, a: f32): void
203
+ /** The laid-out block [width, height] in `size` units. @c c2dGetTextSize @out 2 */
204
+ getTextSize(entityId: i32): Float32Array
205
+
206
+ // Particles (Particles2D): one creator-particles system per entity — the 3D engine's simulation and
207
+ // its config records (sdk/src/core/particlesConfig.ts), drawn through the 2D batcher.
208
+ /** Turns the entity into a particle system (as setSprite makes a sprite): the sheet texture (0 = solid
209
+ * squares of the particle colour) and its flipbook grid; maxParticles 0 = the default (1000). @c c2dSetParticles */
210
+ setParticles(entityId: i32, textureId: i32, sheetCols: i32, sheetRows: i32, maxParticles: i32): void
211
+ /** The config records, [tag, payloadLen, ...payload] — the same buffer `_creator.setParticleSystemConfig` takes. @c c2dParticlesConfig */
212
+ particlesConfig(entityId: i32, data: Float32Array): void
213
+ /** A burst at the emitter's current place. @c c2dParticlesSpawn */
214
+ particlesSpawn(entityId: i32, count: f32): void
215
+ /** @c c2dParticlesAlive */
216
+ particlesAlive(entityId: i32): i32
217
+
158
218
  /** @c c2dBulkSetPositions */
159
219
  bulkSetPositions(ids: Uint32Array, xy: Float32Array, count: i32): void
160
220
 
@@ -14,14 +14,17 @@ export type PickBone = { name: string, weight: f32 }
14
14
  * `bones` = its JOINTS_0 / WEIGHTS_0 pairs with weight > 0 (empty = unweighted). */
15
15
  export type PickVertex = { index: u32, bind: [f32, f32, f32], world: [f32, f32, f32], bones: PickBone[] }
16
16
  /** The closest triangle under a screen point (`pickTriangle`). `node` = the mesh node's own entity (0
17
- * when the host has none), `lightmapOrdinal` = the primitive's index over the file's meshes (the bake's record
18
- * `<key>@<ordinal>`), `uv1` (the lightmap uv the GPU reads: the bake's packed one when loaded) only when the primitive
19
- * carries one. */
17
+ * when the host has none), `lightmapOrdinal` = the primitive's index over the file's walked nodes' primitives (the bake's
18
+ * record `<key>@<ordinal>`; a LOD twin's = its base's), `uv1` (the lightmap uv the GPU reads: the bake's packed one when loaded) only when the primitive
19
+ * carries one, `lightmapTexture` + `lightmapST` = the rect the primitive draws with (lightmapApply's: the light page's
20
+ * texture id, 0 = none, and [sx, sy, ox, oy]) - what `Lightmap.probe` reads under the static level cache, where a
21
+ * static has no JS node to ask. */
20
22
  export type TrianglePick = {
21
23
  entity: u32, node: u32, nodeName: string, lightmapOrdinal: u32,
22
24
  mesh: string, primitive: u32, material: string,
23
25
  triangle: u32, backface: boolean, distance: f32,
24
26
  point: [f32, f32, f32], bary: [f32, f32, f32], uv1?: [f32, f32],
27
+ lightmapTexture: u32, lightmapST?: [f32, f32, f32, f32],
25
28
  skin: string, vertices: PickVertex[],
26
29
  }
27
30
 
@@ -297,6 +300,20 @@ declare global {
297
300
  * with the source's own buffers and material instances. Returns the renderables made (0 = nothing scattered).
298
301
  * Absent on a host without it: Scatter then clones the model per placement. @count count=placements/5 */
299
302
  createGlbScatter?(entityId: u32, sourceId: u32, placements: Float32Array, cell: f32, castShadows: boolean): u32
303
+ /** The static level cache (gl/Statics.ts ↔ creator-gl/src/statics.cpp). `staticsWrite` puts the snapshot's bytes
304
+ * at an absolute path (the `lecodes static` run; a host without a file system leaves it out). `staticsBegin`
305
+ * takes the fetched cache for the level under `roots` (the covered subtrees' entities, in the snapshot's order)
306
+ * in `sceneId` and builds its body-only placements
307
+ * (returns the kind count, 0 = not a cache); `staticsBuildKind` clones `templateEntity` at every placement of
308
+ * that kind, binding the bake's records to `pages` (light, aux per page, interleaved) and creating the static
309
+ * bodies; `staticsEnd` reports and frees. Absent on a host without it: the level builds the slow way.
310
+ * @count size=data */
311
+ staticsWrite?(path: string, data: Uint8Array): boolean
312
+ /** @count rootCount=roots */
313
+ staticsBegin?(sceneId: u32, roots: Uint32Array, cache: bytes): u32
314
+ /** @count count=pages */
315
+ staticsBuildKind?(kind: u32, templateEntity: u32, pages: Uint32Array): u32
316
+ staticsEnd?(): u32
300
317
  setCulling(entityId: u32, culling: boolean): void
301
318
  /** Filament RenderableManager::setPriority — the coarse draw order, 0..7 (Mesh.renderPriority).
302
319
  * Optional: an older host leaves everything at the default 4. */
@@ -696,9 +713,9 @@ declare global {
696
713
  * `Shape.fit()` measures. Optional: absent on hosts predating the binding. @out 6 */
697
714
  computeBoundingBox?(entityId: u32): Float32Array
698
715
  /** DEBUG pick: the closest TRIANGLE under a screen point (logical px) among the loaded GLB instances —
699
- * entityId = one Model's root, 0 = every instance — CPU-skinned with the joints' CURRENT pose, both
700
- * faces. null on a miss. One full CPU skin per call — click-rate only. Optional: a debug tool, hosts
701
- * may lack it. */
716
+ * entityId = one Model's root, 0 = every instance IN THE SCENE and drawn (a hidden LOD level, an
717
+ * off-scene template are skipped) — CPU-skinned with the joints' CURRENT pose, both faces. null on a
718
+ * miss. One full CPU skin per call — click-rate only. Optional: a debug tool, hosts may lack it. */
702
719
  pickTriangle?(entityId: u32, screenX: f32, screenY: f32): TrianglePick | null
703
720
  setColliderFromMesh(entityId: u32, meshEntityId: u32, form: i32): void
704
721
  setColliderBox(entityId: u32, centerX: f64, centerY: f64, centerZ: f64, sizeX: f64, sizeY: f64, sizeZ: f64): void
@@ -744,8 +761,11 @@ declare global {
744
761
  transmit?: string, tracer?: u32, denoiser?: u32, thinTexels?: f32, objectSlab?: f32, cubeKeys?: string, cubeIds?: Uint32Array): boolean
745
762
  /** Lightmap consumption: the next createGlb takes lightmap.filamat (the material behind that instance id) for its
746
763
  * OPAQUE materials and the masked twin for its MASK materials instead of the ubershader, and keeps TEXCOORD_1;
747
- * BLEND (glass) materials always keep the ubershader. 0xFFFFFFFF clears. The same call arms the foliage tier. @custom */
748
- setNextGlbLightmapped?(materialInstanceId: u32, maskedMaterialInstanceId?: u32): void
764
+ * BLEND (glass) materials always keep the ubershader. 0xFFFFFFFF clears. The same call arms the foliage tier;
765
+ * `impostorMaterialInstanceId` (a tree's: foliage-impostor.filamat) goes to the MASK material whose glTF extras carry
766
+ * `lecodes.impostor` = [A, cx, cy, cz, r, E] — the impostor lecodes-assets bakes as a tree's last LOD level —
767
+ * in place of the masked twin (omitted / 0xFFFFFFFF = none). @custom */
768
+ setNextGlbLightmapped?(materialInstanceId: u32, maskedMaterialInstanceId?: u32, impostorMaterialInstanceId?: u32): void
749
769
  /** The level-wide scale every lightmap-material instance shares (present and future): lux per encoded 1.0 of the
750
770
  * light atlas — the .bake's `lightScale` times whatever multiplier a debug knob wants. `emissiveNits` = the bake's
751
771
  * radiance of emission 1.0 (the .bake's `emissiveNits`): over 0 the statics' glow is drawn scene-referred with it -
@@ -770,7 +790,7 @@ declare global {
770
790
  lox: f32, loy: f32, loz: f32, sx: f32, sy: f32, sz: f32): u32
771
791
  /** Bind the light page + the aux page + a RECORD's rect (uv1 * [sx, sy] + [ox, oy]) and move the renderables it went on
772
792
  * to light channel 1 alone — a baked static takes nothing from the real-time sun and lamps (channel 0). The bake's
773
- * unit is the mesh: a GLB instance is one record per primitive (`<key>@<ordinal>` in the .bake); `ordinal` >= 0 binds
793
+ * unit is the node's mesh: a GLB instance is one record per node primitive (`<key>@<ordinal>` in the .bake); `ordinal` >= 0 binds
774
794
  * that primitive (its index over the file's meshes) of every node that draws it, on a material instance of its own;
775
795
  * -1 (the default) = every lightmap-material primitive of the entity (a Mesh entity, a terrain). Returns how many
776
796
  * material instances took it (0 = not loaded lightmapped). */
@@ -815,8 +835,8 @@ declare global {
815
835
  setLightChannel?(entityId: u32, channel: u32, enable: boolean): void
816
836
  /** Shadow flags on every renderable of a GLB instance (Mesh has setCastShadows/setReceiveShadows). */
817
837
  setGlbShadows?(entityId: u32, cast: boolean, receive: boolean): void
818
- /** Foliage — the vegetation tier (`foliage.filamat` / `foliage-masked.filamat`, armed per model
819
- * through setNextGlbLightmapped; the engine knows the tier by its `benders` parameter). Level-wide WIND FIELD (the
838
+ /** Foliage — the vegetation tier (the bark `foliage` + the cards `foliage-cards` / `grass`, a tree's far
839
+ * impostor `foliage-impostor`, armed per model through setNextGlbLightmapped; the engine knows the tier by its `benders` parameter). Level-wide WIND FIELD (the
820
840
  * model of Unity HDRP's Wind.hlsl, 2026-09-23): direction on the ground (normalised by the host), speed m/s,
821
841
  * turbulence 0..2 = the random share of the speed, gust 0..1 = the gust field's strength, gustSpeed m/s = how fast
822
842
  * the gust pattern travels. The per-material drag / stiffness / lean / shiver profile comes from the GLB's material
@@ -830,10 +850,9 @@ declare global {
830
850
  * leaf card takes (bark always all of it), skyAo 0..1 = how far the baked sky visibility darkens the ambient.
831
851
  * Level-wide, present and future instances. */
832
852
  foliageSetLeaf?(shiver: f32, translucency: f32, leafShadow: f32, skyAo: f32): void
833
- /** The look of the cards (2026-09-23): canopy 0..1 = the card's shading normal from its own plane to the crown's hull
834
- * normal (hides the flat quads), normalMap = the cards' normal-map strength, edgeFade = a card seen edge-on dissolves
835
- * over this much of |N·V|, coverage = alpha × (1 + coverage × mip level) so a distant crown keeps its density. */
836
- foliageSetLook?(canopy: f32, normalMap: f32, edgeFade: f32, coverage: f32): void
853
+ /** The look of the cards: coverage = alpha × (1 + coverage × mip level) so a distant crown keeps its density as its
854
+ * atlas mips away. */
855
+ foliageSetLook?(coverage: f32): void
837
856
  /** An entity whose live world position bends the vegetation within `radius` metres; <= 0 removes it. The host
838
857
  * writes the 8 benders nearest the camera to the shader each frame. */
839
858
  foliageSetBender?(entityId: u32, radius: f32): void
@@ -224,6 +224,9 @@ export const expandBakeMacros = async (
224
224
  if (manifest.probes) fields.push(`probes: ${ref1(manifest.probes)}`)
225
225
  if (manifest.volume) fields.push(`volume: ${ref1(manifest.volume)}`)
226
226
  if (manifest.uv1) fields.push(`uv1: ${ref1(manifest.uv1)}`)
227
+ // the static level cache beside the bake (gl/Statics.ts): staged when `lecodes static` has written it
228
+ const statics = target.replace(/\.bake$/, ".static")
229
+ if (exists(statics)) fields.push(`statics: asset(${JSON.stringify(statics)})`)
227
230
  } else {
228
231
  fields.unshift("data: null")
229
232
  fields.push("light: []", "aux: []")
@@ -9,6 +9,7 @@ import { detectEntry } from "./detectEntry"
9
9
  import { buildHeader } from "./header"
10
10
  import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
11
11
  import { ClientPublishError, clientPublishes, stubServerEntries } from "./serverSplit"
12
+ import { checkClientEnvReads, ENV_VALUES_PATH, publicEnvOf } from "./envCompile"
12
13
  import { CODE_EXT } from "./assetMacro"
13
14
  import { escapeTemplate } from "./assetIconMacro"
14
15
 
@@ -76,6 +77,11 @@ export type CompileOptions = {
76
77
  * files never reach the client bundle either way; with no URL, calling an endpoint fails with
77
78
  * "server not deployed". */
78
79
  serverUrl?: string
80
+ /** The project's keys (`NAME: value` — the project's .env, or Settings → Keys): the `PUBLIC_*` ones are
81
+ * baked into the bundle as `env.PUBLIC_*` (envCompile.ts), the rest never reach it. App code reading a
82
+ * key that is not `PUBLIC_*` fails the compile (`ClientEnvError`); a `PUBLIC_*` key read but not given
83
+ * is a warning. Absent = no keys: every app read throws at run time. */
84
+ env?: Record<string, string>
79
85
  /** The TEXT of a resource entry by its root path (`/assets/lightmap/level.bake`) — how `bake()` reads the manifest
80
86
  * it names at compile time (the CLI: the file on disk). Without one, or null for a file, that bake() compiles to an
81
87
  * unbaked level (a warning says so). */
@@ -86,7 +92,11 @@ export type CompileOptions = {
86
92
  * preview inlines it) plus the entrypoint the compile used. `code` carries the metadata header
87
93
  * unless `header: false`; `map` is already shifted past that header. `bakes` = the `bake()` targets
88
94
  * the entry reaches (bakeMacro.ts). */
89
- export type CompileResult = { code: string, map: string | null, entry: string, bakes: BundleBake[] }
95
+ export type CompileResult = {
96
+ code: string, map: string | null, entry: string, bakes: BundleBake[],
97
+ /** The keys app code reads (`env.PUBLIC_*`, envCompile.ts), sorted — what a settings page lists before any is set. */
98
+ envReads: string[]
99
+ }
90
100
 
91
101
  /** The basename a local resource would be looked up by. */
92
102
  export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop() ?? path
@@ -130,7 +140,8 @@ export const entriesToFiles = (entries: CompileEntry[], opts: { publicUrl: strin
130
140
  // The host resolves the asset by name at runtime (reads the local file → systemId); the
131
141
  // "id:" prefix is the host's local-resource URL scheme (parsed by the UI + 2D texture paths).
132
142
  const name = localNames.get(e.path) ?? localAssetBase(e.path)
133
- files[e.path] = `export default ("id:" + ${localAssetId(JSON.stringify(name))})`
143
+ // `__localAsset` (runtime/fetch.ts) records url ↔ name: the id is this run's, the name is stable
144
+ files[e.path] = `export default __localAsset("id:" + ${localAssetId(JSON.stringify(name))}, ${JSON.stringify(name)})`
134
145
  } else {
135
146
  files[e.path] = `export default "${opts.publicUrl}${e.fileSrc ?? ""}"`
136
147
  }
@@ -258,7 +269,16 @@ export const compileProjectWithMap = async (opts: CompileOptions): Promise<Compi
258
269
  throw new Error("Couldn't find an entrypoint (no .ts/.js project file — name yours main.ts). Pass entryOverride.")
259
270
  }
260
271
 
272
+ // The project's keys: app code reads PUBLIC_* only (a secret's name there is an error), and the bundle
273
+ // carries the PUBLIC_* values as the compiled-in env-values module (envCompile.ts).
274
+ const envReads = checkClientEnvReads(opts.entries)
275
+ const publicEnv = publicEnvOf(opts.env ?? {})
276
+ const unset = [...envReads].filter(name => !(name in publicEnv)).sort()
277
+ if (unset.length && opts.onWarning) {
278
+ opts.onWarning(`The app reads env.${unset.join(", env.")} and ${unset.length === 1 ? "it is" : "they are"} not set — add ${unset.length === 1 ? "it" : "them"} to the project's .env (NAME=value) or to Settings → Keys; a read throws until then`)
279
+ }
261
280
  const files = entriesToFiles(entries, opts)
281
+ files[ENV_VALUES_PATH] = `export const values = ${JSON.stringify(publicEnv)}\n`
262
282
  const { code, map, bakes } = await bundleProjectWithMap(
263
283
  entry, files, opts.format ?? "esm", opts.minify, opts.editor, opts.libraries, opts.sourcemap ?? true, undefined, opts.readResource,
264
284
  )
@@ -269,11 +289,12 @@ export const compileProjectWithMap = async (opts: CompileOptions): Promise<Compi
269
289
  opts.onWarning(`The app uses ${missing.map(id => `"${id}"`).join(", ")} and app.json does not list ${missing.length === 1 ? "it" : "them"}: a plugin that is not listed is not there — in a shell built from this project and in the browser alike. Add to app.json: "plugins": { ${missing.map(id => `"${id}": "*"`).join(", ")} }`)
270
290
  }
271
291
  }
272
- if (opts.header === false) return { code, map, entry, bakes }
292
+ const reads = [...envReads].sort()
293
+ if (opts.header === false) return { code, map, entry, bakes, envReads: reads }
273
294
  const header = buildHeader(opts.name, code, { icon: opts.icon, fonts: opts.fontsHeader, icons: opts.iconsHeader, plugins })
274
295
  // Shift the map down past the header (prepended before the code).
275
296
  const headerLines = (header.match(/\n/g) ?? []).length
276
- return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry, bakes }
297
+ return { code: header + code, map: map ? offsetSourceMap(map, headerLines) : null, entry, bakes, envReads: reads }
277
298
  }
278
299
 
279
300
  /** The bundle as ONE string, the map inlined at its end (the shipped/preview form). */
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The project's keys in the APP bundle (src/core/env.ts): what the compile bakes in, what it refuses,
3
+ * and the check that no secret's VALUE made it into a bundle.
4
+ *
5
+ * - `publicEnvOf`: of the project's keys, the `PUBLIC_*` ones — the only values the client bundle gets
6
+ * (compileProject.ts writes them at ENV_VALUES_PATH, replacing src/runtime/envValues.ts).
7
+ * - `checkClientEnvReads`: app code (every code file that is not `*.server.ts`) may read `env.PUBLIC_*`
8
+ * only; `env.STRIPE_KEY` there is a compile error naming the file — the bundle is public. A `PUBLIC_*`
9
+ * name read but not set is a warning (the read throws at run time, src/core/env.ts).
10
+ * - `findEnvLeaks`: a secret pasted as a literal (`fetch(url, { headers: { key: "sk_live_…" } })`) is
11
+ * what `env` cannot prevent — so where the values are known (the CLI's .env, the platform's keys)
12
+ * the finished bundle text is searched for each non-public value, raw and JSON-escaped. Values
13
+ * shorter than `minLength` (8) are too short to search for without false hits and are skipped.
14
+ */
15
+
16
+ import { CODE_EXT } from "./assetMacro"
17
+ import type { CompileEntry } from "./compileProject"
18
+ import { isServerPath, scanEnvReads, serverModuleId } from "./serverSplit"
19
+ import { isPublicEnvName } from "../core/env"
20
+
21
+ /** The SDK's env-values placeholder (src/runtime/envValues.ts), overridden in the file map with the real ones. */
22
+ export const ENV_VALUES_PATH = "/__sdk/runtime/envValues.ts"
23
+
24
+ export const publicEnvOf = (env: Record<string, string>): Record<string, string> =>
25
+ Object.fromEntries(Object.entries(env).filter(([name]) => isPublicEnvName(name)).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))
26
+
27
+ export class ClientEnvError extends Error {
28
+ readonly problems: string[]
29
+ constructor(problems: string[]) {
30
+ super(`a key that is not PUBLIC_* is a secret, and the app's bundle is public — read it in a *.server.ts file and call an endpoint, or name it PUBLIC_… if the app may carry it:\n ${problems.join("\n ")}`)
31
+ this.name = "ClientEnvError"
32
+ this.problems = problems
33
+ }
34
+ }
35
+
36
+ const isClientCode = (e: CompileEntry) =>
37
+ e.type === "text" && CODE_EXT.test(e.path) && !isServerPath(e.path) && !/^(\.\/|\/)?design\//.test(e.path.split("\\").join("/"))
38
+
39
+ /** The `env` names app code reads, by file. Throws `ClientEnvError` on a name that is not `PUBLIC_*`. */
40
+ export const checkClientEnvReads = (entries: CompileEntry[]): Set<string> => {
41
+ const reads = new Set<string>()
42
+ const problems: string[] = []
43
+ for (const e of entries) {
44
+ if (!isClientCode(e)) continue
45
+ for (const name of scanEnvReads(e.text ?? "")) {
46
+ if (isPublicEnvName(name)) reads.add(name)
47
+ else problems.push(`${serverModuleId(e.path)}: env.${name}`)
48
+ }
49
+ }
50
+ if (problems.length) throw new ClientEnvError(problems)
51
+ return reads
52
+ }
53
+
54
+ const escapeForSearch = (value: string) => JSON.stringify(value).slice(1, -1)
55
+
56
+ /** The names of the keys whose value is in `text`, raw or JSON-escaped (`PUBLIC_*` ones are not secrets). */
57
+ export const findEnvLeaks = (text: string, env: Record<string, string>, opts: { minLength?: number } = {}): string[] => {
58
+ const min = opts.minLength ?? 8
59
+ const out: string[] = []
60
+ for (const [name, value] of Object.entries(env)) {
61
+ if (isPublicEnvName(name) || value.length < min) continue
62
+ if (text.includes(value) || text.includes(escapeForSearch(value))) out.push(name)
63
+ }
64
+ return out.sort()
65
+ }
66
+
67
+ export class EnvLeakError extends Error {
68
+ readonly keys: string[]
69
+ constructor(keys: string[], where: string) {
70
+ super(`the value of ${keys.join(", ")} is in ${where} — a secret pasted into app code or an asset? The app's bundle is public: read a secret as env.${keys[0]} in a *.server.ts file and call an endpoint`)
71
+ this.name = "EnvLeakError"
72
+ this.keys = keys
73
+ }
74
+ }
@@ -8,8 +8,9 @@
8
8
  // font("./fonts/Brand.ttf") // project file → real family from its name table
9
9
  //
10
10
  // Registry ids resolve against the vendored snapshot (fontRegistry.ts). Two source modes:
11
- // cdn — faces stay remote: the generated code bakes registry URLs, picking woff2/ttf via the
12
- // host's `_creatorTree.fontFormat` (absent → ttf). Platform/server-backed compiles.
11
+ // cdn — faces stay remote: the generated code bakes the registry URL BASE (ending in "."); the
12
+ // runtime's `__registerFont` appends the host's `_creatorTree.fontFormat` (absent → ttf)
13
+ // for the UI tree and always ttf for 2D text (Font2D). Platform/server-backed compiles.
13
14
  // vendor — the caller downloads faces into the project (assets/fonts/ + fonts.lock.json) and
14
15
  // hands back their entry paths; faces then ride the normal asset pipeline (dev URLs,
15
16
  // `id:` + fetchLocal, lecodesfile://) — fully offline, and local faces register
@@ -19,6 +20,10 @@
19
20
  // read from the font itself (sfnt.ts). Registration is idempotent per (family, weight, style) —
20
21
  // hosts dedupe — so several files declaring the same font cost nothing.
21
22
  //
23
+ // The generated module calls the bundle global `__registerFont(family, url, weight, italic)`
24
+ // (ui/fonts.ts), never `_creatorTree.registerFont` directly: it registers the face with the UI tree
25
+ // AND records its TTF URL, so `Font2D.load("Manrope")` resolves the same identifier UI text takes.
26
+ //
22
27
  // A file that DECLARES its own `font` identifier (function/const/import) is skipped entirely —
23
28
  // real scoping wins, the macro only exists where the global would.
24
29
 
@@ -408,8 +413,8 @@ export const applyFontMacro = async (entries: FontMacroEntry[], opts: FontMacroO
408
413
  fw.entry.text = text
409
414
 
410
415
  const lines: string[] = [
411
- "// Generated by the font() compile macro: registers this file's fonts. Imports hoist, so the",
412
- "// faces are declared before the importing module's body runs. Hosts dedupe repeated faces.",
416
+ "// Generated by the font() compile macro: registers this file's fonts (UI tree + Font2D). Imports",
417
+ "// hoist, so the faces are declared before the importing module's body runs. Hosts dedupe repeats.",
413
418
  ]
414
419
  const faceImports = new Map<string, string>() // entry path → import ident
415
420
  const body: string[] = []
@@ -417,7 +422,7 @@ export const applyFontMacro = async (entries: FontMacroEntry[], opts: FontMacroO
417
422
  const ref = faces.get(key)!
418
423
  let urlExpr: string
419
424
  if (ref.source.kind === "cdn") {
420
- urlExpr = `${JSON.stringify(ref.source.urlBase)} + (_creatorTree.fontFormat ?? "ttf")`
425
+ urlExpr = JSON.stringify(ref.source.urlBase) // ends in "." — __registerFont appends the format
421
426
  } else {
422
427
  let ident = faceImports.get(ref.source.path)
423
428
  if (!ident) {
@@ -427,7 +432,7 @@ export const applyFontMacro = async (entries: FontMacroEntry[], opts: FontMacroO
427
432
  }
428
433
  urlExpr = ident
429
434
  }
430
- body.push(`_creatorTree.registerFont(${JSON.stringify(ref.family)}, ${urlExpr}, ${ref.weight}, ${ref.italic ? 2 : 0}, () => {}, () => {})`)
435
+ body.push(`__registerFont(${JSON.stringify(ref.family)}, ${urlExpr}, ${ref.weight}, ${ref.italic})`)
431
436
  }
432
437
  for (const [family, ident] of idents) lines.push(`export const ${ident} = ${JSON.stringify(family)}`)
433
438
  lines.push(...body)
@@ -9,6 +9,7 @@ export {
9
9
  ClientPublishError,
10
10
  scanServerExports,
11
11
  scanServerModules,
12
+ scanEnvReads,
12
13
  stubServerEntries,
13
14
  serverStubSource,
14
15
  serverEntrySource,
@@ -21,6 +22,8 @@ export {
21
22
  type ServerManifest,
22
23
  } from "./serverSplit"
23
24
  export { deriveEndpointParams, EndpointTypeError, ServerHelperImportError, UnawaitedUserQueryError, WrongSideGlobalError, type DeriveResult } from "./serverTypes"
25
+ export { checkClientEnvReads, ClientEnvError, ENV_VALUES_PATH, EnvLeakError, findEnvLeaks, publicEnvOf } from "./envCompile"
26
+ export { ENV_NAME, PUBLIC_ENV_PREFIX, isPublicEnvName } from "../core/env"
24
27
  export {
25
28
  detectEntry,
26
29
  scanModulesFallback,
@@ -64,6 +67,9 @@ export {
64
67
  type ShaderParamValue,
65
68
  } from "./shaderSchema"
66
69
  export { offsetSourceMap, inlineSourceMapComment } from "./sourcemap"
70
+ // the static level cache's file format (gl/staticsFormat.ts): `lecodes level build` / `lightmap bake` stitch a bake's
71
+ // records into a cache without a host run
72
+ export { decode as decodeStatics, encode as encodeStatics, stitch as stitchStatics, readHeader as readStaticsHeader, bakeDigest as staticsBakeDigest, type StaticsFile } from "../gl/staticsFormat"
67
73
  export { buildHeader, detectProjectKind, type HeaderOptions } from "./header"
68
74
  export {
69
75
  compileProject,
@@ -18,6 +18,7 @@ export const resourceSlots = {
18
18
  registerFont: [ 1 ],
19
19
  fetchLocal: [ 0 ],
20
20
  "Texture2D.load": [ 0 ],
21
+ "Font2D.load": [ 0 ],
21
22
  "Texture.load": [ 0 ],
22
23
  "Model.load": [ 0 ],
23
24
  "AnimationClip.load": [ 0 ],
@@ -36,20 +36,39 @@ export type ServerExport = { name: string, kind: ServerExportKind }
36
36
  // ───────────────────────────── static export scan ─────────────────────────────
37
37
 
38
38
  /** Blank out comments and string/template contents (keeps line structure) so the regexes below don't
39
- * trip on `export` inside a comment or a string. */
40
- export const blankCommentsAndStrings = (src: string): string => {
39
+ * trip on `export` inside a comment or a string. `templateCode`: a template's `${…}` stays code
40
+ * (`scanEnvReads`: a key read inside an interpolation is a read). */
41
+ export const blankCommentsAndStrings = (src: string, opts: { templateCode?: boolean } = {}): string => {
41
42
  let out = ""
42
43
  let i = 0
43
44
  const n = src.length
45
+ /** templateCode: the brace depth inside each open `${`. */
46
+ const holes: number[] = []
47
+ /** A template's text from `i` to its closing backtick, or to a `${` that stays code (templateCode). */
48
+ const templateText = () => {
49
+ while (i < n && src[i] !== "`") {
50
+ if (src[i] === "\\") { out += " "; i += 2; continue }
51
+ if (opts.templateCode && src[i] === "$" && src[i + 1] === "{") { out += "${"; i += 2; holes.push(0); return }
52
+ out += src[i] === "\n" ? "\n" : " "; i++
53
+ }
54
+ out += "`"; i++
55
+ }
44
56
  while (i < n) {
45
57
  const c = src[i], d = src[i + 1]
58
+ if (holes.length && c === "{") { holes[holes.length - 1]++; out += c; i++; continue }
59
+ if (holes.length && c === "}") {
60
+ out += c; i++
61
+ if (holes[holes.length - 1]-- === 0) { holes.pop(); templateText() }
62
+ continue
63
+ }
64
+ if (c === "`") { out += c; i++; templateText(); continue }
46
65
  if (c === "/" && d === "/") { while (i < n && src[i] !== "\n") { out += " "; i++ } continue }
47
66
  if (c === "/" && d === "*") {
48
67
  out += " "; i += 2
49
68
  while (i < n && !(src[i] === "*" && src[i + 1] === "/")) { out += src[i] === "\n" ? "\n" : " "; i++ }
50
69
  out += " "; i += 2; continue
51
70
  }
52
- if (c === '"' || c === "'" || c === "`") {
71
+ if (c === '"' || c === "'") {
53
72
  const q = c
54
73
  out += q; i++
55
74
  while (i < n && src[i] !== q) {
@@ -112,6 +131,26 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
112
131
  return found.sort((a, b) => a.at - b.at).map(({ name, kind }) => ({ name, kind }))
113
132
  }
114
133
 
134
+ // ───────────────────────────── env reads ─────────────────────────────
135
+
136
+ /**
137
+ * The keys a server module reads: `env.NAME`, `env["NAME"]`, `env['NAME']` — by name, so a deploy can
138
+ * say which of them is not set before a call fails (`env` throws on a missing key, src/server/env.ts).
139
+ * A dynamic read (`env[name]`) is not seen and is checked at run time only. `process.env.X` and any
140
+ * other `.env.X` member chain is not a read of the global.
141
+ */
142
+ export const scanEnvReads = (source: string): string[] => {
143
+ const out = new Set<string>()
144
+ const src = blankCommentsAndStrings(source, { templateCode: true })
145
+ for (const m of src.matchAll(/(?<![\w$.])env\s*\.\s*([A-Z][A-Z0-9_]*)\b/g)) out.add(m[1])
146
+ // the bracket form names the key in a string: find the sites on the blanked text, read the name off the original
147
+ for (const m of src.matchAll(/(?<![\w$.])env\s*\[\s*(["'])\s*\1\s*\]/g)) {
148
+ const inner = /\[\s*(["'])([A-Z][A-Z0-9_]*)\1\s*\]/.exec(source.slice(m.index!, m.index! + m[0].length))
149
+ if (inner) out.add(inner[2])
150
+ }
151
+ return [...out].sort()
152
+ }
153
+
115
154
  // ───────────────────────────── client stubs ─────────────────────────────
116
155
 
117
156
  /** The client-side replacement of a `*.server.ts` module. `serverUrl` is baked in (draft/prod are
@@ -198,6 +237,8 @@ export type ServerManifest = {
198
237
  /** The `app.json` subset the server runtime reads (the app's name); the bundle itself gets it as the
199
238
  * compiled-in `appConfig` module (APP_CONFIG_PATH). */
200
239
  app?: AppConfig
240
+ /** The keys the server files read by name (`scanEnvReads`), sorted, unique. */
241
+ env?: string[]
201
242
  }
202
243
 
203
244
  /** The synthetic entry: imports every export by name (chisel has no namespace imports) and registers
@@ -292,7 +333,9 @@ export const compileServerBundle = async (opts: CompileServerOptions): Promise<{
292
333
  for (const w of derived.warnings) console.warn(`warning: ${w}`)
293
334
  params = derived.params
294
335
  }
295
- const manifest: ServerManifest = { modules: Object.fromEntries(Object.entries(modules).map(([p, ex]) => [serverModuleId(p), ex])), params, app: opts.app }
336
+ const envReads = new Set<string>()
337
+ for (const e of opts.entries) if (e.type === "text" && isServerPath(e.path)) for (const n of scanEnvReads(e.text ?? "")) envReads.add(n)
338
+ const manifest: ServerManifest = { modules: Object.fromEntries(Object.entries(modules).map(([p, ex]) => [serverModuleId(p), ex])), params, app: opts.app, env: [...envReads].sort() }
296
339
  if (opts.app) files[APP_CONFIG_PATH] = `export const app = ${JSON.stringify(opts.app)}\n`
297
340
  files[SERVER_ENTRY_PATH] = serverEntrySource(modules, manifest)
298
341
  const { code } = await bundleProjectWithMap(
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The project's keys as code reads them: `env.NAME` — one word on both sides. Server code
3
+ * (`*.server.ts`, src/server/env.ts) reads every key; the app (src/runtime/env.ts) only the
4
+ * `PUBLIC_*` ones, baked into its bundle at compile time — a bundle is public, so a key the app
5
+ * carries is public by its name, and the compile refuses any other name in app code.
6
+ *
7
+ * A key is a `string`, never `string | undefined`: reading one that is not set THROWS `EnvError`,
8
+ * with the fix in the message. `"NAME" in env` tells without throwing; `Object.keys(env)` lists
9
+ * what is set. Only a key's shape throws: `then`, symbols, inspection hooks read as undefined, so
10
+ * `await env`, `console.log(env)` and JSON.stringify behave.
11
+ */
12
+
13
+ export type Env = { readonly [name: string]: string }
14
+
15
+ /** How a key is named — in `.env`, in Settings → Keys, in code. */
16
+ export const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/
17
+ /** A key the app may carry: the only names the client bundle gets. */
18
+ export const PUBLIC_ENV_PREFIX = "PUBLIC_"
19
+ export const isPublicEnvName = (name: string) => name.startsWith(PUBLIC_ENV_PREFIX)
20
+
21
+ export class EnvError extends Error {
22
+ readonly key: string
23
+ constructor(key: string) {
24
+ super(`env.${key} is not set — add ${key} to the project's .env (on this machine) or to Settings → Keys (on le.codes)`)
25
+ this.name = "EnvError"
26
+ this.key = key
27
+ }
28
+ }
29
+
30
+ /** The `env` object over a source of values (read on every access: the server's are set by the host). */
31
+ export const makeEnv = (read: () => Record<string, string>): Env => new Proxy({} as Env, {
32
+ get(_t, name) {
33
+ if (typeof name !== "string" || !ENV_NAME.test(name)) return undefined
34
+ const values = read()
35
+ if (!(name in values)) throw new EnvError(name)
36
+ return values[name]
37
+ },
38
+ has(_t, name) { return typeof name === "string" && name in read() },
39
+ ownKeys() { return Object.keys(read()) },
40
+ getOwnPropertyDescriptor(_t, name) {
41
+ const values = read()
42
+ if (typeof name !== "string" || !(name in values)) return undefined
43
+ return { value: values[name], enumerable: true, configurable: true, writable: false }
44
+ },
45
+ set() { throw new TypeError("env is read-only: keys are set in .env or Settings → Keys") },
46
+ deleteProperty() { throw new TypeError("env is read-only: keys are set in .env or Settings → Keys") },
47
+ })