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.
- package/dist/global.d.ts +35 -0
- package/dist/types/core/env.d.ts +25 -0
- package/dist/types/core/particlesConfig.d.ts +196 -0
- package/dist/types/g2/Font2D.d.ts +19 -0
- package/dist/types/g2/Material2D.d.ts +24 -0
- package/dist/types/g2/Node2D.d.ts +6 -5
- package/dist/types/g2/Particles2D.d.ts +169 -0
- package/dist/types/g2/Path2D.d.ts +95 -0
- package/dist/types/g2/Physics2D.d.ts +1 -1
- package/dist/types/g2/Scene2D.d.ts +15 -17
- package/dist/types/g2/Sprite.d.ts +12 -1
- package/dist/types/g2/SpriteSheet.d.ts +4 -1
- package/dist/types/g2/Text2D.d.ts +96 -0
- package/dist/types/g2/Tilemap.d.ts +8 -0
- package/dist/types/g2/defineScene2d.d.ts +69 -11
- package/dist/types/gl/Foliage.d.ts +0 -7
- package/dist/types/gl/Lightmap.d.ts +101 -2
- package/dist/types/gl/Material.d.ts +21 -15
- package/dist/types/gl/Model.d.ts +8 -3
- package/dist/types/gl/Particles.d.ts +3 -135
- package/dist/types/gl/Statics.d.ts +56 -0
- package/dist/types/gl/staticsFormat.d.ts +101 -0
- package/dist/types/inject.d.ts +11 -3
- package/dist/types/runtime/env.d.ts +10 -0
- package/dist/types/runtime/envValues.d.ts +8 -0
- package/dist/types/scene/defineScene.d.ts +17 -12
- package/dist/types/server/env.d.ts +14 -0
- package/dist/types/server/inject.d.ts +1 -0
- package/dist/types/ui/fonts.d.ts +4 -0
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/bridges/2d.d.ts +65 -5
- package/src/bridges/gl.d.ts +34 -15
- package/src/compile/bakeMacro.ts +3 -0
- package/src/compile/compileProject.ts +25 -4
- package/src/compile/envCompile.ts +74 -0
- package/src/compile/fontMacro.ts +11 -6
- package/src/compile/index.ts +6 -0
- package/src/compile/resourceSlots.ts +1 -0
- package/src/compile/serverSplit.ts +47 -4
- package/src/core/env.ts +47 -0
- package/src/core/particlesConfig.ts +422 -0
- package/src/g2/Font2D.ts +87 -0
- package/src/g2/Material2D.ts +64 -0
- package/src/g2/Node2D.ts +10 -6
- package/src/g2/Particles2D.ts +337 -0
- package/src/g2/Path2D.ts +215 -0
- package/src/g2/Physics2D.ts +1 -1
- package/src/g2/Scene2D.ts +16 -22
- package/src/g2/Sprite.ts +23 -4
- package/src/g2/SpriteSheet.ts +9 -5
- package/src/g2/Text2D.ts +178 -0
- package/src/g2/Tilemap.ts +10 -0
- package/src/g2/defineScene2d.ts +108 -21
- package/src/g2/groups2d.ts +2 -2
- package/src/gl/Foliage.ts +1 -16
- package/src/gl/Lightmap.ts +70 -26
- package/src/gl/Material.ts +51 -21
- package/src/gl/Model.ts +19 -5
- package/src/gl/Particles.ts +22 -361
- package/src/gl/Statics.ts +301 -0
- package/src/gl/nav/NavMesh.ts +4 -1
- package/src/gl/staticsFormat.ts +240 -0
- package/src/inject.ts +18 -3
- package/src/runtime/env.ts +13 -0
- package/src/runtime/envValues.ts +8 -0
- package/src/runtime/fetch.ts +18 -0
- package/src/scene/defineScene.ts +107 -27
- package/src/server/certs/russian-trusted-ca.pem +81 -0
- package/src/server/env.ts +25 -0
- package/src/server/host.ts +7 -0
- package/src/server/inject.ts +2 -1
- package/src/server/trustedCa.ts +23 -0
- package/src/ui/fonts.ts +46 -0
- package/src/version.ts +1 -1
package/package.json
CHANGED
package/src/bridges/2d.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
package/src/bridges/gl.d.ts
CHANGED
|
@@ -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
|
|
18
|
-
* `<key>@<ordinal
|
|
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
|
|
700
|
-
*
|
|
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
|
|
748
|
-
|
|
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
|
|
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
|
|
834
|
-
*
|
|
835
|
-
|
|
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
|
package/src/compile/bakeMacro.ts
CHANGED
|
@@ -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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/compile/fontMacro.ts
CHANGED
|
@@ -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
|
|
12
|
-
// host's `_creatorTree.fontFormat` (absent → ttf)
|
|
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
|
|
412
|
-
"// faces are declared before the importing module's body runs. Hosts dedupe
|
|
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 =
|
|
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(`
|
|
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)
|
package/src/compile/index.ts
CHANGED
|
@@ -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,
|
|
@@ -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
|
-
|
|
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 === "'"
|
|
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
|
|
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(
|
package/src/core/env.ts
ADDED
|
@@ -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
|
+
})
|