lecodes-sdk 2.2.13 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/global.d.ts +11 -0
  2. package/dist/types/gl/Scene.d.ts +0 -11
  3. package/dist/types/gl/postProcessing.d.ts +31 -2
  4. package/dist/types/inject.d.ts +3 -2
  5. package/dist/types/plugin.d.ts +3 -0
  6. package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
  7. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
  8. package/dist/types/runtime/device.d.ts +7 -19
  9. package/dist/types/runtime/pushSeam.d.ts +23 -0
  10. package/dist/types/runtime/rpc.d.ts +15 -1
  11. package/dist/types/server/auth/models.d.ts +13 -1
  12. package/dist/types/server/auth/types.d.ts +2 -0
  13. package/dist/types/server/inject.d.ts +1 -0
  14. package/dist/types/server/pushChannel.d.ts +79 -0
  15. package/dist/types/ui/UINode.d.ts +1 -1
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +1 -1
  19. package/src/animate/tween/Timeline.ts +175 -175
  20. package/src/bridges/gl.d.ts +5 -13
  21. package/src/compile/serverSplit.ts +29 -19
  22. package/src/compile/serverTypes.ts +1 -1
  23. package/src/gl/CameraPlace.ts +52 -52
  24. package/src/gl/Light.ts +257 -257
  25. package/src/gl/Mesh.ts +120 -120
  26. package/src/gl/Scene.ts +0 -13
  27. package/src/gl/animation/DynamicBone.ts +486 -486
  28. package/src/gl/postProcessing.ts +52 -9
  29. package/src/gl/scenarios.ts +291 -291
  30. package/src/inject.ts +3 -2
  31. package/src/plugin.ts +3 -0
  32. package/src/plugins/gen/push/contract.d.ts +24 -2
  33. package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
  34. package/src/plugins/gen/push/sdk/push.ts +159 -55
  35. package/src/runtime/device.ts +8 -37
  36. package/src/runtime/pushSeam.ts +22 -0
  37. package/src/runtime/rpc.ts +35 -1
  38. package/src/server/auth/host.ts +40 -9
  39. package/src/server/auth/models.ts +5 -1
  40. package/src/server/auth/types.ts +2 -0
  41. package/src/server/host.ts +6 -0
  42. package/src/server/inject.ts +3 -2
  43. package/src/server/push/host.ts +143 -0
  44. package/src/server/pushChannel.ts +121 -0
  45. package/src/server/runtime.ts +20 -8
  46. package/src/ui/UIInput.ts +2 -2
  47. package/src/ui/UINode.ts +1 -1
  48. 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.2.13",
46
+ "version": "2.3.0",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -1,175 +1,175 @@
1
- // `Timeline` (docs/timeline-plan.md §2.4): choreography over the keyframe core. Tracks on any
2
- // number of targets (UI elements, 3D / 2D nodes, lights, cameras) at absolute positions, JS calls
3
- // at times, labels, holds — one clock, one handle. Also the `animateTo` / `animateFrom` bag
4
- // builder every target class delegates to (`tweenBag`).
5
-
6
- import { TweenAnimation, type Animation } from "./Animation"
7
- import { makeValueTrack, type AnimateOptions, type AnimateValue } from "./animateValue"
8
- import { CLOCK_GAME, CLOCK_UI, bagProps, iterationsOf, makeTrack, type Track, type TweenMeta, type TweenSpec, type TweenTarget } from "./spec"
9
-
10
- /** Vector-valued props: an array value is ONE keyframe unless it is an array of vectors. */
11
- const VECTOR_PROPS = new Set(["position", "scale", "quaternion", "eulerAngles", "color"])
12
-
13
- const clockOf = (clock: TweenMeta["clock"], fallback: number): number =>
14
- clock === "game" ? CLOCK_GAME : clock === "ui" ? CLOCK_UI : fallback
15
-
16
- /** @internal Build the tracks of one options bag on one target. `fromCurrent` = animateTo shape. */
17
- export const bagTracks = (target: TweenTarget, bag: Record<string, unknown>, atMs: number, fromCurrent: boolean): Track[] => {
18
- const meta = bag as TweenMeta
19
- const tracks: Track[] = []
20
- for (const [prop, value] of bagProps(bag)) {
21
- const track = makeTrack(target, prop, value, meta, atMs, fromCurrent, VECTOR_PROPS.has(prop))
22
- if (track) tracks.push(track)
23
- }
24
- return tracks
25
- }
26
-
27
- /** @internal The `animateTo` / `animateFrom` implementation: one bag → one animation, played now. */
28
- export const tweenBag = (target: TweenTarget, bag: Record<string, unknown>, fromCurrent: boolean): Animation => {
29
- const meta = bag as TweenMeta
30
- const tracks = bagTracks(target, bag, 0, fromCurrent)
31
- let durationMs = 0
32
- for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
33
- const spec: TweenSpec = {
34
- clock: clockOf(meta.clock, target._tweenClock),
35
- durationMs, delayMs: 0,
36
- iterations: iterationsOf(meta.loop),
37
- pingPong: meta.loopMode !== "restart",
38
- rate: 1, tracks, calls: [],
39
- }
40
- return new TweenAnimation(spec).play()
41
- }
42
-
43
- export type TimelineOptions = {
44
- /** `'ui'` (default) = wall time; `'game'` follows `Time.scale` and pauses with the game. */
45
- clock?: "ui" | "game",
46
- /** Repeat the whole timeline: `true` = forever, a number = cycles. */
47
- loop?: boolean | number,
48
- loopMode?: "restart" | "ping-pong",
49
- /** Wait before the first play, ms. */
50
- delay?: number,
51
- }
52
-
53
- /** Where a track goes: ms, a label, or `[label, offsetMs]`. Default = the current end (sequencing). */
54
- export type TimelinePosition = number | string | [string, number]
55
-
56
- export type TimelineAddOptions = {
57
- at?: TimelinePosition,
58
- /** With several targets: each starts this many ms after the previous one. */
59
- stagger?: number,
60
- }
61
-
62
- /** A choreography: tracks on many targets at absolute times, JS calls, labels and holds, driven by
63
- * one clock. Build it once, `play()` on every entrance — `play()` restarts from t = 0 and the pose
64
- * at any time is fully defined by the tracks (the first keyframe of a track holds before it
65
- * starts, the last one after it ends). */
66
- export class TimelineImpl extends TweenAnimation {
67
- private _labels = new Map<string, number>()
68
- private _end = 0
69
-
70
- constructor(options: TimelineOptions = {}) {
71
- super({
72
- clock: clockOf(options.clock, CLOCK_UI),
73
- durationMs: 0,
74
- delayMs: options.delay ?? 0,
75
- iterations: iterationsOf(options.loop),
76
- pingPong: options.loopMode !== "restart",
77
- rate: 1, tracks: [], calls: [],
78
- })
79
- }
80
-
81
- private _at(at: TimelinePosition | undefined): number {
82
- if (at === undefined) return this._end
83
- if (typeof at === "number") return at
84
- const name = Array.isArray(at) ? at[0] : at
85
- const offset = Array.isArray(at) ? at[1] : 0
86
- const base = this._labels.get(name)
87
- if (base === undefined) throw new Error(`Timeline: unknown label "${name}"`)
88
- return base + offset
89
- }
90
-
91
- private _grow(endMs: number): void {
92
- if (endMs > this._end) this._end = endMs
93
- this._spec.durationMs = this._end
94
- }
95
-
96
- /** Animate `props` on one target or several (staggered), like `animateTo` — arrays are keyframes,
97
- * `duration` / `easing` / `times` / `commit` apply. The bag's `delay` shifts the track after `at`. */
98
- add(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
99
- const at = this._at(options.at)
100
- const targets = Array.isArray(target) ? target : [target]
101
- const stagger = options.stagger ?? 0
102
- targets.forEach((t, i) => {
103
- const tracks = bagTracks(t, props, at + i * stagger, true)
104
- for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
105
- })
106
- return this
107
- }
108
-
109
- /** Like `add` with the `animateFrom` shape: the target's own state is the implicit last keyframe. */
110
- addFrom(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
111
- const at = this._at(options.at)
112
- const targets = Array.isArray(target) ? target : [target]
113
- const stagger = options.stagger ?? 0
114
- targets.forEach((t, i) => {
115
- const tracks = bagTracks(t, props, at + i * stagger, false)
116
- for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
117
- })
118
- return this
119
- }
120
-
121
- /** A free-value track (see the `animate()` global): the host evaluates it and calls `onUpdate`
122
- * every frame with the value. */
123
- animate<T extends AnimateValue>(options: AnimateOptions<T>, at?: TimelinePosition): this {
124
- const { track, deliver, slot } = makeValueTrack(options, this._at(at))
125
- this._values.set(slot, deliver)
126
- this._spec.tracks.push(track)
127
- this._grow(track.atMs + track.durMs)
128
- return this
129
- }
130
-
131
- /** Call `fn` when playback crosses `at` (forward or backward), in time order, always before
132
- * finish, never from a previous run. */
133
- call(at: TimelinePosition, fn: () => void): this {
134
- const t = this._at(at)
135
- this._spec.calls.push(t)
136
- this._calls.push(fn)
137
- this._grow(t)
138
- return this
139
- }
140
-
141
- /** Name a time — for `at`, `seek` and callers (`tl.labels.landed`). Default = the current end. */
142
- label(name: string, at?: TimelinePosition): this {
143
- this._labels.set(name, this._at(at))
144
- return this
145
- }
146
-
147
- /** Named times, ms. */
148
- get labels(): Record<string, number> {
149
- const o: Record<string, number> = {}
150
- for (const [k, v] of this._labels) o[k] = v
151
- return o
152
- }
153
-
154
- /** Extend the timeline with stillness after its current end. */
155
- hold(ms: number): this {
156
- this._grow(this._end + Math.max(0, ms))
157
- return this
158
- }
159
-
160
- /** The current end, ms (tracks, calls and holds). */
161
- get end(): number { return this._end }
162
-
163
- /** `seek` also accepts a label. */
164
- override seek(at: number | string): this {
165
- return super.seek(typeof at === "string" ? this._at(at) : at)
166
- }
167
- }
168
-
169
- /** The timeline handle type (see {@link TimelineImpl} for the members). */
170
- export type Timeline = TimelineImpl
171
-
172
- /** Create a timeline — `Timeline()` / `Timeline({ clock: 'game', loop: true })`. */
173
- export function Timeline(options?: TimelineOptions): Timeline {
174
- return new TimelineImpl(options)
175
- }
1
+ // `Timeline` (docs/timeline-plan.md §2.4): choreography over the keyframe core. Tracks on any
2
+ // number of targets (UI elements, 3D / 2D nodes, lights, cameras) at absolute positions, JS calls
3
+ // at times, labels, holds — one clock, one handle. Also the `animateTo` / `animateFrom` bag
4
+ // builder every target class delegates to (`tweenBag`).
5
+
6
+ import { TweenAnimation, type Animation } from "./Animation"
7
+ import { makeValueTrack, type AnimateOptions, type AnimateValue } from "./animateValue"
8
+ import { CLOCK_GAME, CLOCK_UI, bagProps, iterationsOf, makeTrack, type Track, type TweenMeta, type TweenSpec, type TweenTarget } from "./spec"
9
+
10
+ /** Vector-valued props: an array value is ONE keyframe unless it is an array of vectors. */
11
+ const VECTOR_PROPS = new Set(["position", "scale", "quaternion", "eulerAngles", "color"])
12
+
13
+ const clockOf = (clock: TweenMeta["clock"], fallback: number): number =>
14
+ clock === "game" ? CLOCK_GAME : clock === "ui" ? CLOCK_UI : fallback
15
+
16
+ /** @internal Build the tracks of one options bag on one target. `fromCurrent` = animateTo shape. */
17
+ export const bagTracks = (target: TweenTarget, bag: Record<string, unknown>, atMs: number, fromCurrent: boolean): Track[] => {
18
+ const meta = bag as TweenMeta
19
+ const tracks: Track[] = []
20
+ for (const [prop, value] of bagProps(bag)) {
21
+ const track = makeTrack(target, prop, value, meta, atMs, fromCurrent, VECTOR_PROPS.has(prop))
22
+ if (track) tracks.push(track)
23
+ }
24
+ return tracks
25
+ }
26
+
27
+ /** @internal The `animateTo` / `animateFrom` implementation: one bag → one animation, played now. */
28
+ export const tweenBag = (target: TweenTarget, bag: Record<string, unknown>, fromCurrent: boolean): Animation => {
29
+ const meta = bag as TweenMeta
30
+ const tracks = bagTracks(target, bag, 0, fromCurrent)
31
+ let durationMs = 0
32
+ for (const t of tracks) durationMs = Math.max(durationMs, t.atMs + t.durMs)
33
+ const spec: TweenSpec = {
34
+ clock: clockOf(meta.clock, target._tweenClock),
35
+ durationMs, delayMs: 0,
36
+ iterations: iterationsOf(meta.loop),
37
+ pingPong: meta.loopMode !== "restart",
38
+ rate: 1, tracks, calls: [],
39
+ }
40
+ return new TweenAnimation(spec).play()
41
+ }
42
+
43
+ export type TimelineOptions = {
44
+ /** `'ui'` (default) = wall time; `'game'` follows `Time.scale` and pauses with the game. */
45
+ clock?: "ui" | "game",
46
+ /** Repeat the whole timeline: `true` = forever, a number = cycles. */
47
+ loop?: boolean | number,
48
+ loopMode?: "restart" | "ping-pong",
49
+ /** Wait before the first play, ms. */
50
+ delay?: number,
51
+ }
52
+
53
+ /** Where a track goes: ms, a label, or `[label, offsetMs]`. Default = the current end (sequencing). */
54
+ export type TimelinePosition = number | string | [string, number]
55
+
56
+ export type TimelineAddOptions = {
57
+ at?: TimelinePosition,
58
+ /** With several targets: each starts this many ms after the previous one. */
59
+ stagger?: number,
60
+ }
61
+
62
+ /** A choreography: tracks on many targets at absolute times, JS calls, labels and holds, driven by
63
+ * one clock. Build it once, `play()` on every entrance — `play()` restarts from t = 0 and the pose
64
+ * at any time is fully defined by the tracks (the first keyframe of a track holds before it
65
+ * starts, the last one after it ends). */
66
+ export class TimelineImpl extends TweenAnimation {
67
+ private _labels = new Map<string, number>()
68
+ private _end = 0
69
+
70
+ constructor(options: TimelineOptions = {}) {
71
+ super({
72
+ clock: clockOf(options.clock, CLOCK_UI),
73
+ durationMs: 0,
74
+ delayMs: options.delay ?? 0,
75
+ iterations: iterationsOf(options.loop),
76
+ pingPong: options.loopMode !== "restart",
77
+ rate: 1, tracks: [], calls: [],
78
+ })
79
+ }
80
+
81
+ private _at(at: TimelinePosition | undefined): number {
82
+ if (at === undefined) return this._end
83
+ if (typeof at === "number") return at
84
+ const name = Array.isArray(at) ? at[0] : at
85
+ const offset = Array.isArray(at) ? at[1] : 0
86
+ const base = this._labels.get(name)
87
+ if (base === undefined) throw new Error(`Timeline: unknown label "${name}"`)
88
+ return base + offset
89
+ }
90
+
91
+ private _grow(endMs: number): void {
92
+ if (endMs > this._end) this._end = endMs
93
+ this._spec.durationMs = this._end
94
+ }
95
+
96
+ /** Animate `props` on one target or several (staggered), like `animateTo` — arrays are keyframes,
97
+ * `duration` / `easing` / `times` / `commit` apply. The bag's `delay` shifts the track after `at`. */
98
+ add(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
99
+ const at = this._at(options.at)
100
+ const targets = Array.isArray(target) ? target : [target]
101
+ const stagger = options.stagger ?? 0
102
+ targets.forEach((t, i) => {
103
+ const tracks = bagTracks(t, props, at + i * stagger, true)
104
+ for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
105
+ })
106
+ return this
107
+ }
108
+
109
+ /** Like `add` with the `animateFrom` shape: the target's own state is the implicit last keyframe. */
110
+ addFrom(target: TweenTarget | TweenTarget[], props: Record<string, unknown> & TweenMeta, options: TimelineAddOptions = {}): this {
111
+ const at = this._at(options.at)
112
+ const targets = Array.isArray(target) ? target : [target]
113
+ const stagger = options.stagger ?? 0
114
+ targets.forEach((t, i) => {
115
+ const tracks = bagTracks(t, props, at + i * stagger, false)
116
+ for (const tr of tracks) { this._spec.tracks.push(tr); this._grow(tr.atMs + tr.durMs) }
117
+ })
118
+ return this
119
+ }
120
+
121
+ /** A free-value track (see the `animate()` global): the host evaluates it and calls `onUpdate`
122
+ * every frame with the value. */
123
+ animate<T extends AnimateValue>(options: AnimateOptions<T>, at?: TimelinePosition): this {
124
+ const { track, deliver, slot } = makeValueTrack(options, this._at(at))
125
+ this._values.set(slot, deliver)
126
+ this._spec.tracks.push(track)
127
+ this._grow(track.atMs + track.durMs)
128
+ return this
129
+ }
130
+
131
+ /** Call `fn` when playback crosses `at` (forward or backward), in time order, always before
132
+ * finish, never from a previous run. */
133
+ call(at: TimelinePosition, fn: () => void): this {
134
+ const t = this._at(at)
135
+ this._spec.calls.push(t)
136
+ this._calls.push(fn)
137
+ this._grow(t)
138
+ return this
139
+ }
140
+
141
+ /** Name a time — for `at`, `seek` and callers (`tl.labels.landed`). Default = the current end. */
142
+ label(name: string, at?: TimelinePosition): this {
143
+ this._labels.set(name, this._at(at))
144
+ return this
145
+ }
146
+
147
+ /** Named times, ms. */
148
+ get labels(): Record<string, number> {
149
+ const o: Record<string, number> = {}
150
+ for (const [k, v] of this._labels) o[k] = v
151
+ return o
152
+ }
153
+
154
+ /** Extend the timeline with stillness after its current end. */
155
+ hold(ms: number): this {
156
+ this._grow(this._end + Math.max(0, ms))
157
+ return this
158
+ }
159
+
160
+ /** The current end, ms (tracks, calls and holds). */
161
+ get end(): number { return this._end }
162
+
163
+ /** `seek` also accepts a label. */
164
+ override seek(at: number | string): this {
165
+ return super.seek(typeof at === "string" ? this._at(at) : at)
166
+ }
167
+ }
168
+
169
+ /** The timeline handle type (see {@link TimelineImpl} for the members). */
170
+ export type Timeline = TimelineImpl
171
+
172
+ /** Create a timeline — `Timeline()` / `Timeline({ clock: 'game', loop: true })`. */
173
+ export function Timeline(options?: TimelineOptions): Timeline {
174
+ return new TimelineImpl(options)
175
+ }
@@ -127,9 +127,11 @@ declare global {
127
127
  setVignetteOptions?(sceneId: u32, enabled: boolean, midPoint: f32, roundness: f32, feather: f32, color: u32): void
128
128
  /** Colour grading: the operator and every adjustment baked into the view's ONE 3D LUT. `params` is
129
129
  * the fixed layout of `sdk/src/gl/postProcessing.ts` (`CG_*`, mirrored by creator.h's
130
- * ColorGradingParam; 39 floats, the last one = apply the LUT of setColorLut). A change re-bakes a 32³ table on the CPU, so the SDK coalesces
131
- * its writes per tick and quantises; an identical array is a no-op in the engine. Optional: a
132
- * host that predates it renders filament's default (ACES legacy). */
130
+ * ColorGradingParam; 41 floats: [38] = apply the LUT of setColorLut, [39] / [40] = the HDR look,
131
+ * strength 0..1 and paper white 1..8, read only while the output is extended range). The engine
132
+ * takes an older bundle's 39 with the HDR look at its defaults. A change re-bakes a 32³ table on
133
+ * the CPU, so the SDK coalesces its writes per tick and quantises; an identical array is a no-op
134
+ * in the engine. Optional: a host that predates it renders filament's default (ACES legacy). */
133
135
  setColorGrading?(sceneId: u32, params: Float32Array): void
134
136
  /** A custom 3D LUT after the operator, in display-referred sRGB: the TEXT of a `.cube` file
135
137
  * (`LUT_3D_SIZE n` + n³ `r g b` lines, red fastest), parsed by the engine once and applied while
@@ -156,17 +158,7 @@ declare global {
156
158
  * (1 = SDR), constant once the surface exists: the value to branch content on. Optional: hosts
157
159
  * that predate it, or render 8-bit, are SDR. */
158
160
  getDisplayHeadroom?(): f64
159
- /** @c getDisplayHeadroom */
160
161
  getDisplayMaxHeadroom?(): f64
161
- /** HDR look, engine-wide (creator.h `setHdrStrength` / `setHdrPaperWhite`): `strength` 0..1 =
162
- * how much of the picture reaches for the headroom (0 only what SDR clipped, 1 nearly
163
- * everything), `paperWhite` 1..8 = where the operator's white lands as a multiple of SDR white
164
- * (the "HDR brightness" of a console calibration screen; clamped to the headroom). A host may
165
- * pin either from its environment — the getters report what is in force. Optional. */
166
- setHdrStrength?(strength: f32): void
167
- getHdrStrength?(): f64
168
- setHdrPaperWhite?(paperWhite: f32): void
169
- getHdrPaperWhite?(): f64
170
162
  /** Screen-space ambient occlusion: the contact darkening in creases and where objects meet the
171
163
  * ground. `radius` is world-space metres, `power` the falloff contrast, `quality` 0 LOW … 3
172
164
  * ULTRA (sample count — not the buffer resolution, which stays half-res).
@@ -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 }
@@ -1,52 +1,52 @@
1
- // CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
2
- // a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
3
- // side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
4
- // empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
5
- //
6
- // • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
7
- // `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
8
- // • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
9
- // owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
10
- // or anchors on it (`inst.alignTo(inst.nodes.eye)`).
11
- // • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
12
- // releases.
13
- // • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
14
- // See docs/scene-camera-place-plan.md.
15
-
16
- import { Aspect } from "../core/Aspect"
17
- import type { FieldMeta } from "../core/fields"
18
- import { Gizmos } from "../scene/gizmos"
19
- import type { Camera } from "./Camera"
20
- import type { Node } from "./Node"
21
-
22
- export class CameraPlace extends Aspect<"cameraPlace", Node> {
23
- static readonly aspect = "cameraPlace"
24
- /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
25
- fov = 60
26
- /** Near clip distance. */
27
- near = 0.01
28
- /** Far clip distance = view range. */
29
- far = 1000
30
- /** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
31
- * ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
32
- active = false
33
- /** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
34
- static fields: FieldMeta<CameraPlace> = {
35
- fov: { label: "FOV", min: 1, max: 179, step: 1 },
36
- near: { min: 0.001, max: 10, step: 0.01 },
37
- far: { min: 1, max: 100000, step: 1 },
38
- active: { label: "Active camera" },
39
- }
40
- static editor = { rebuild: true }
41
-
42
- /** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
43
- applyTo(camera: Camera): void {
44
- camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
45
- }
46
-
47
- /** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
48
- * clicking it selects the node. Play mode never calls this. */
49
- rebuild(): void {
50
- Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
51
- }
52
- }
1
+ // CameraPlace: "the camera as a place". A scene has ONE camera (`scene.camera`); a CameraPlace marks
2
+ // a node as somewhere that camera can be — with the lens it uses there. Nothing is created engine-
3
+ // side (no second camera, no ABI): it is a pose + a projection record on any node: an empty, an
4
+ // empty mounted on a bone (`mount:`), a node a path aspect moves (a dolly shot), a model node.
5
+ //
6
+ // • In a scene FILE that runs (`open()` / `load()`), the `active: true` place drives
7
+ // `scene.camera` (exactly `scene.camera.follow(node)`); exactly one per file.
8
+ // • Inside a prefab or an `instantiate()`d subtree a place is inert DATA — the hosting scene
9
+ // owns its camera. The host reads it (`inst.nodes.eye.get(CameraPlace)` → world pose + fov)
10
+ // or anchors on it (`inst.alignTo(inst.nodes.eye)`).
11
+ // • From code: `scene.camera.follow(dolly.get(CameraPlace))` — a cutscene shot; `follow(null)`
12
+ // releases.
13
+ // • Edit mode: draws an anchored frustum gizmo (the editor's camera preview reads the same place).
14
+ // See docs/scene-camera-place-plan.md.
15
+
16
+ import { Aspect } from "../core/Aspect"
17
+ import type { FieldMeta } from "../core/fields"
18
+ import { Gizmos } from "../scene/gizmos"
19
+ import type { Camera } from "./Camera"
20
+ import type { Node } from "./Node"
21
+
22
+ export class CameraPlace extends Aspect<"cameraPlace", Node> {
23
+ static readonly aspect = "cameraPlace"
24
+ /** Vertical field of view in degrees (default 60) — smaller is a longer lens. */
25
+ fov = 60
26
+ /** Near clip distance. */
27
+ near = 0.01
28
+ /** Far clip distance = view range. */
29
+ far = 1000
30
+ /** The place that drives `scene.camera` when the file declaring it RUNS. Exactly one per file;
31
+ * ignored inside prefabs / instantiated subtrees (the host scene owns its camera). */
32
+ active = false
33
+ /** Aspect ratio of the frustum gizmo only (the real aspect is the viewport's). */
34
+ static fields: FieldMeta<CameraPlace> = {
35
+ fov: { label: "FOV", min: 1, max: 179, step: 1 },
36
+ near: { min: 0.001, max: 10, step: 0.01 },
37
+ far: { min: 1, max: 100000, step: 1 },
38
+ active: { label: "Active camera" },
39
+ }
40
+ static editor = { rebuild: true }
41
+
42
+ /** Copy this place's projection onto a camera (`camera.follow(place)` does it for you). */
43
+ applyTo(camera: Camera): void {
44
+ camera.setProjection({ fov: this.fov, near: this.near, far: this.far })
45
+ }
46
+
47
+ /** Edit mode: the frustum marker (−Z = view), anchored on the node so it follows drags live and
48
+ * clicking it selects the node. Play mode never calls this. */
49
+ rebuild(): void {
50
+ Gizmos.frustum(this.fov, { node: this.node, color: this.active ? "#ffffff" : "#9aa3b2" })
51
+ }
52
+ }