lecodes-cli 0.16.1 → 0.16.2

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 (38) hide show
  1. package/README.md +27 -4
  2. package/dist/index.js +170706 -72
  3. package/package.json +11 -6
  4. package/runtime/sdk/compile/bundler.ts +10 -2
  5. package/runtime/sdk/compile/compileProject.ts +10 -2
  6. package/runtime/sdk/compile/detectEntry.ts +5 -2
  7. package/runtime/sdk/compile/index.ts +17 -1
  8. package/runtime/sdk/compile/serverSplit.ts +233 -0
  9. package/runtime/sdk/compile/serverTypes.ts +227 -0
  10. package/runtime/sdk/gl/AnimationClip.ts +174 -0
  11. package/runtime/sdk/gl/Animator.ts +634 -0
  12. package/runtime/sdk/gl/CharacterController.ts +6 -1
  13. package/runtime/sdk/gl/IK.ts +170 -0
  14. package/runtime/sdk/gl/Material.ts +1 -1
  15. package/runtime/sdk/gl/ModelAnimation.ts +34 -3
  16. package/runtime/sdk/gl/Node.ts +21 -0
  17. package/runtime/sdk/inject.ts +10 -0
  18. package/runtime/sdk/plugins/oauth.ts +61 -0
  19. package/runtime/sdk/runtime/rpc.ts +218 -0
  20. package/runtime/sdk/server/auth/appConfig.ts +12 -0
  21. package/runtime/sdk/server/auth/global.ts +80 -0
  22. package/runtime/sdk/server/auth/host.ts +318 -0
  23. package/runtime/sdk/server/auth/models.ts +83 -0
  24. package/runtime/sdk/server/auth/types.ts +50 -0
  25. package/runtime/sdk/server/channel.ts +56 -0
  26. package/runtime/sdk/server/context.ts +36 -0
  27. package/runtime/sdk/server/db/defineDb.ts +237 -0
  28. package/runtime/sdk/server/db/fields.ts +132 -0
  29. package/runtime/sdk/server/db/httpTransport.ts +93 -0
  30. package/runtime/sdk/server/db/index.ts +7 -0
  31. package/runtime/sdk/server/db/marci/query.ts +412 -0
  32. package/runtime/sdk/server/db/types.ts +202 -0
  33. package/runtime/sdk/server/errors.ts +12 -0
  34. package/runtime/sdk/server/host.ts +74 -0
  35. package/runtime/sdk/server/inject.ts +13 -0
  36. package/runtime/sdk/server/runtime.ts +133 -0
  37. package/runtime/sdk/server/validate.ts +87 -0
  38. package/runtime/sdk-types.json +1 -1
@@ -0,0 +1,170 @@
1
+ // IK — precise character actions as late-phase aspects on BONES (docs/animation-plan.md §2.5).
2
+ // They run after the animation wrote the pose (base anim or Animator) and before the skin is
3
+ // flushed, so their bone writes land in the skin. Attach to the END bone of the chain; the chain is
4
+ // walked up through parents — one instance per end effector, any Node hierarchy works.
5
+ //
6
+ // const foot = hero.bone('LeftFoot')!
7
+ // foot.aspect(IK.TwoBone, { target: footTarget, pole: kneeHint }) // upLeg → leg → foot
8
+ // foot.ik.weight = grounded ? 1 : 0 // blend in/out
9
+ // hero.bone('Head')!.aspect(IK.LookAt, { target: camera, limit: 70 }) // head tracks the camera
10
+ //
11
+ // Pure SDK/JS: a handful of world-matrix reads + two quaternion writes per solve. A native solver can
12
+ // slot in later behind the same API.
13
+
14
+ import { Aspect } from "../core/Aspect"
15
+ import { Vec3, type Vec3Like } from "../math/vec"
16
+ import { Quat } from "../math/quat"
17
+ import type { Node } from "./Node"
18
+
19
+ type Target = Node | Vec3Like
20
+
21
+ const targetPos = (t: Target | undefined): Vec3 | null => {
22
+ if (t === undefined || t === null) return null
23
+ const n = t as Node
24
+ if (typeof (n as { worldPosition?: unknown }).worldPosition === "object" && typeof n.id === "number") return n.worldPosition
25
+ return new Vec3(t as Vec3Like)
26
+ }
27
+
28
+ /** World rotation of a node (parent chain composed natively). */
29
+ const worldRot = (n: Node): Quat => n.worldMatrix.rotation
30
+
31
+ /** Write a WORLD rotation to a node by converting through the parent's world rotation. */
32
+ const setWorldRot = (n: Node, q: Quat): void => {
33
+ const p = n.parent
34
+ n.quaternion = p ? worldRot(p).invert().mul(q).normalize() : q.normalize()
35
+ }
36
+
37
+ const clampAngle = (q: Quat, maxRad: number): Quat => {
38
+ // angle of a unit quaternion = 2·acos(|w|)
39
+ const w = Math.min(1, Math.abs(q.w))
40
+ const ang = 2 * Math.acos(w)
41
+ if (ang <= maxRad || ang < 1e-6) return q
42
+ const s = Math.sqrt(1 - w * w)
43
+ const axis = new Vec3(q.x / s, q.y / s, q.z / s)
44
+ return Quat.fromAxisAngle(axis, q.w < 0 ? -maxRad : maxRad)
45
+ }
46
+
47
+ /** Two-bone analytic IK (limbs). Attach to the END bone: `foot.aspect(IK.TwoBone, { target })`
48
+ * solves upper (grandparent) + mid (parent) so the end reaches `target`; `pole` steers the bend
49
+ * (knee/elbow) — a Node or world point. */
50
+ class TwoBone extends Aspect<"ik", Node> {
51
+ static readonly aspect = "ik"
52
+
53
+ /** Where the end bone should be (Node or world position). */
54
+ target?: Target
55
+ /** Bend hint — the mid joint is pulled toward it (Node or world position). */
56
+ pole?: Target
57
+ /** 0–1 contribution (blend in/out, e.g. foot planting only while grounded). */
58
+ weight = 1
59
+ /** Solve every frame (default). Set false to drive `solve()` yourself. */
60
+ enabled = true
61
+
62
+ update(): void {
63
+ if (this.enabled) this.solve()
64
+ }
65
+
66
+ /** One solve at the current pose. Safe to call manually (e.g. from a later-ordered aspect). */
67
+ solve(): void {
68
+ const t = targetPos(this.target)
69
+ if (!t || this.weight <= 0) return
70
+ const end = this.node
71
+ const mid = end.parent
72
+ const root = mid?.parent
73
+ if (!mid || !root) return
74
+
75
+ const w = Math.min(1, this.weight)
76
+ const midLocal0 = mid.quaternion
77
+ const rootLocal0 = root.quaternion
78
+
79
+ const a = root.worldPosition, b = mid.worldPosition, c = end.worldPosition
80
+ const l1 = a.distanceTo(b), l2 = b.distanceTo(c)
81
+ if (l1 < 1e-6 || l2 < 1e-6) return
82
+ const eps = 1e-4
83
+ const toT = t.sub(a)
84
+ const d = Math.min(Math.max(toT.length(), eps), l1 + l2 - eps)
85
+
86
+ // 1. bend the mid joint to the angle the target distance demands (law of cosines)
87
+ const ba = a.sub(b), bc = c.sub(b)
88
+ const cur = ba.angle(bc)
89
+ const want = Math.acos(Math.min(1, Math.max(-1, (l1 * l1 + l2 * l2 - d * d) / (2 * l1 * l2))))
90
+ let axis = bc.cross(ba)
91
+ if (axis.lengthSq() < 1e-10) {
92
+ // straight limb: bend toward the pole (or any perpendicular)
93
+ const p = targetPos(this.pole)
94
+ const ref = p ? p.sub(b) : new Vec3(0, 0, 1)
95
+ axis = ba.cross(ref)
96
+ if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(0, 1, 0))
97
+ if (axis.lengthSq() < 1e-10) axis = ba.cross(new Vec3(1, 0, 0))
98
+ }
99
+ axis = axis.normalize()
100
+ // rotating mid by (want - cur) about `axis` opens/closes the angle between ba and bc
101
+ const midWorld = worldRot(mid)
102
+ setWorldRot(mid, Quat.fromAxisAngle(axis, want - cur).mul(midWorld))
103
+
104
+ // 2. swing the root so the end lands on the target direction
105
+ const c2 = end.worldPosition
106
+ const rootWorld = worldRot(root)
107
+ setWorldRot(root, Quat.fromTo(c2.sub(a), toT).mul(rootWorld))
108
+
109
+ // 3. pole: spin the root about the root→target axis so the mid joint lies toward the pole
110
+ const p = targetPos(this.pole)
111
+ if (p) {
112
+ const dir = toT.normalize()
113
+ const b2 = mid.worldPosition
114
+ const midProj = b2.sub(a).sub(dir.scale(b2.sub(a).dot(dir)))
115
+ const poleProj = p.sub(a).sub(dir.scale(p.sub(a).dot(dir)))
116
+ if (midProj.lengthSq() > 1e-10 && poleProj.lengthSq() > 1e-10) {
117
+ setWorldRot(root, Quat.fromTo(midProj, poleProj).mul(worldRot(root)))
118
+ }
119
+ }
120
+
121
+ // 4. weight: blend from the animated pose to the solved one
122
+ if (w < 1) {
123
+ root.quaternion = rootLocal0.slerp(root.quaternion, w)
124
+ mid.quaternion = midLocal0.slerp(mid.quaternion, w)
125
+ }
126
+ }
127
+ }
128
+
129
+ /** Aim a bone at a target (head / eyes / turret). Attach to the bone itself:
130
+ * `head.aspect(IK.LookAt, { target: camera, limit: 70 })`. `axis` is the bone's LOCAL forward
131
+ * (the direction that should point at the target) — rigs differ; default −Z, Mixamo heads look
132
+ * along +Z of the head bone in most exports, so pass `axis: [0, 0, 1]` there if it faces backwards. */
133
+ class LookAt extends Aspect<"lookAt", Node> {
134
+ static readonly aspect = "lookAt"
135
+
136
+ target?: Target
137
+ /** The bone's local axis that should point at the target. */
138
+ axis: Vec3Like = [ 0, 0, -1 ]
139
+ /** Max deflection from the animated direction, in degrees (default 80). */
140
+ limit = 80
141
+ /** 0–1 contribution. */
142
+ weight = 1
143
+ enabled = true
144
+
145
+ update(): void {
146
+ if (this.enabled) this.solve()
147
+ }
148
+
149
+ solve(): void {
150
+ const t = targetPos(this.target)
151
+ if (!t || this.weight <= 0) return
152
+ const bone = this.node
153
+ const w = Math.min(1, this.weight)
154
+ const local0 = bone.quaternion
155
+ const from = bone.worldPosition
156
+ const dir = t.sub(from)
157
+ if (dir.lengthSq() < 1e-10) return
158
+ const rot = worldRot(bone)
159
+ const current = new Vec3(this.axis).rotate(rot)
160
+ let delta = Quat.fromTo(current, dir)
161
+ delta = clampAngle(delta, (this.limit * Math.PI) / 180)
162
+ setWorldRot(bone, delta.mul(rot))
163
+ if (w < 1) bone.quaternion = local0.slerp(bone.quaternion, w)
164
+ }
165
+ }
166
+
167
+ /** Inverse kinematics aspects — attach to bones (see file header). */
168
+ export const IK = { TwoBone, LookAt }
169
+ export type IKTwoBone = TwoBone
170
+ export type IKLookAt = LookAt
@@ -81,7 +81,7 @@ export class Material {
81
81
  readonly uniforms: Record<string, UniformValue>
82
82
 
83
83
  private _colorKey: string | null = null
84
- private _useMapFlag = true
84
+ private _useMapFlag = false
85
85
 
86
86
  constructor(shader: FetchResponse | number) {
87
87
  if (typeof shader === "number") {
@@ -1,8 +1,12 @@
1
1
  // GLB skeletal animation as an aspect. Auto-attached by Model (a glb always has its clips), so you
2
2
  // reach it directly: model.anim.play('Run'). Clips come baked in the glb; play by name or index.
3
+ // This is the BASE player (one clip at a time, Filament's Animator underneath). For crossfades,
4
+ // blend spaces, layers and clips from other files attach the Animator aspect — it takes the skeleton
5
+ // over while attached and this player is suspended (docs/animation-plan.md).
3
6
 
4
7
  import { Aspect } from "../core/Aspect"
5
8
  import { nodeRegistry } from "./Node"
9
+ import { AnimationClip } from "./AnimationClip"
6
10
  import type { Model } from "./Model"
7
11
 
8
12
  let animatorRegistered = false
@@ -19,6 +23,8 @@ export class ModelAnimation extends Aspect<"anim", Model> {
19
23
  private _clip = 0
20
24
  private _speed = 1
21
25
  private _loop = false
26
+ private _suspended = false
27
+ private _clips?: AnimationClip[]
22
28
 
23
29
  onAttach(): void {
24
30
  this.node._animComplete = (clipIndex) => {
@@ -29,17 +35,41 @@ export class ModelAnimation extends Aspect<"anim", Model> {
29
35
  }
30
36
  }
31
37
 
32
- /** The clips baked into the glb. */
33
- get clips(): { name: string, duration: number }[] { return _creator.getGlbAnimations(this.node.id) }
38
+ /** The clips baked into the glb, as AnimationClip objects (usable in an Animator's clip table —
39
+ * e.g. share one model's baked clip with another model of the same rig). */
40
+ get clips(): AnimationClip[] {
41
+ if (this._clips) return this._clips
42
+ const setId = typeof _creator.getGlbClipSet === "function" ? _creator.getGlbClipSet(this.node.id) : 0
43
+ if (setId) this._clips = AnimationClip._ofSet(setId)
44
+ else {
45
+ // no embedded set (older / lite hosts, or a GLB without animations): name + duration only
46
+ this._clips = ((_creator.getGlbAnimations(this.node.id) ?? []) as { name: string, duration: number }[])
47
+ .map((c) => ({ name: c.name, duration: c.duration, trackCount: 0 }) as unknown as AnimationClip)
48
+ }
49
+ return this._clips
50
+ }
51
+
52
+ /** @internal — an Animator owns the skeleton while attached; this player goes quiet. */
53
+ _suspend(on: boolean): void {
54
+ this._suspended = on
55
+ if (on && this._playing) { this._playing = false }
56
+ }
57
+ private _blocked(): boolean {
58
+ if (!this._suspended) return false
59
+ console.warn("model.anim is suspended while an Animator is attached — use model.animator")
60
+ return true
61
+ }
34
62
 
35
63
  get playing(): boolean { return this._playing }
36
64
  set playing(value: boolean) {
65
+ if (this._blocked()) return
37
66
  this._playing = value
38
67
  _creator.setAnimation(this.node.id, value, this._clip, this._speed)
39
68
  }
40
69
 
41
70
  /** Play a clip by name or index. Chainable. */
42
71
  play(clip?: string | number, options: { loop?: boolean } = {}): this {
72
+ if (this._blocked()) return this
43
73
  if (typeof clip === "number") this._clip = clip
44
74
  else if (typeof clip === "string") {
45
75
  const i = this.clips.findIndex((c) => c.name === clip)
@@ -57,8 +87,9 @@ export class ModelAnimation extends Aspect<"anim", Model> {
57
87
  get speed(): number { return this._speed }
58
88
  set speed(v: number) {
59
89
  this._speed = v
90
+ if (this._suspended) return
60
91
  _creator.setAnimation(this.node.id, this._playing, this._clip, v)
61
92
  }
62
93
  get time(): number { return _creator.getAnimationTime(this.node.id) }
63
- set time(v: number) { _creator.setAnimationTime(this.node.id, this._clip, v) }
94
+ set time(v: number) { if (!this._blocked()) _creator.setAnimationTime(this.node.id, this._clip, v) }
64
95
  }
@@ -47,6 +47,7 @@ export class Node extends AspectHost<NodeEvents> {
47
47
  private _lastSync = 0
48
48
  private _worldMatrix?: Float32Array
49
49
  private _scaleCache?: [number, number, number]
50
+ private _boneCache?: Map<string, Node | null>
50
51
 
51
52
  /** @internal — the active animation aspect's clip-complete handler (installed by ModelAnimation). */
52
53
  _animComplete?: (clipIndex: number) => boolean
@@ -178,6 +179,26 @@ export class Node extends AspectHost<NodeEvents> {
178
179
  callback(this)
179
180
  _creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
180
181
  }
182
+ /** Find a descendant by name — bones of a loaded Model included (`hero.bone('RightHand').add(sword)`).
183
+ * Exact name first, then the part after the last `:` / `|` (Mixamo `mixamorig:Hips` matches `Hips`).
184
+ * Cached per name; null if absent. */
185
+ bone(name: string): Node | null {
186
+ const cache = (this._boneCache ??= new Map<string, Node | null>())
187
+ const hit = cache.get(name)
188
+ if (hit !== undefined) return hit
189
+ let exact: Node | null = null
190
+ let loose: Node | null = null
191
+ const want = name.slice(name.search(/[^:|]*$/))
192
+ _creator.traverse(this.id, (id) => {
193
+ if (exact) return
194
+ const n = _creator.getName(id)
195
+ if (n === name) exact = nodeRegistry.get(id) ?? new Node(id)
196
+ else if (!loose && n.slice(n.search(/[^:|]*$/)) === want) loose = nodeRegistry.get(id) ?? new Node(id)
197
+ })
198
+ const found = exact ?? loose
199
+ cache.set(name, found)
200
+ return found
201
+ }
181
202
 
182
203
  // --- behaviors are aspects: node.aspect(Shape, …) / node.aspect(Physics, …) ---
183
204
 
@@ -34,6 +34,9 @@ export { localStorage } from "./runtime/storage"
34
34
  export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
35
35
  export { Input } from "./runtime/input"
36
36
  export { WebSocket } from "./runtime/net"
37
+ // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
38
+ // only ever imports its server functions (docs/backend-plan.md §4).
39
+ export { __rpc, __channel, __serverOnly, RpcError, type ClientChannel } from "./runtime/rpc"
37
40
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
38
41
  export { openFilePicker } from "./runtime/files"
39
42
  export { share } from "./runtime/share"
@@ -115,6 +118,7 @@ export { QRScanner } from "./plugins/qr"
115
118
  export { CameraView, type CameraFacing } from "./plugins/camera"
116
119
  export { Geolocation, type GeoPosition, type GeoOptions, type GeoWatch } from "./plugins/geolocation"
117
120
  export { Push, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/push"
121
+ export { OAuth, type OAuthCredential, type OAuthProviderName } from "./plugins/oauth"
118
122
  export { Service } from "./plugins/service"
119
123
 
120
124
  // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
@@ -139,6 +143,12 @@ export { Physics } from "./gl/Physics"
139
143
  export { Trigger } from "./gl/Trigger"
140
144
  export { CharacterController } from "./gl/CharacterController"
141
145
  export { ModelAnimation } from "./gl/ModelAnimation"
146
+ export { AnimationClip } from "./gl/AnimationClip"
147
+ export type { ClipDef, ClipTrackDef, ClipKey } from "./gl/AnimationClip"
148
+ export { Animator } from "./gl/Animator"
149
+ export type { AnimatorPlayOptions } from "./gl/Animator"
150
+ export { IK } from "./gl/IK"
151
+ export type { IKTwoBone, IKLookAt } from "./gl/IK"
142
152
  export { Light } from "./gl/Light"
143
153
  export { Material } from "./gl/Material"
144
154
  export { Geometry } from "./gl/Geometry"
@@ -0,0 +1,61 @@
1
+ // OAuth — native provider sign-in as a typed service plugin (docs/backend-plan.md §3.4). The
2
+ // browser broker (`auth.oauth.url()` on the server + `openURL` in the app) works everywhere; on
3
+ // hosts that ship a provider SDK (Yandex LoginSDK, VK ID, Sign in with Apple, Google Sign-In) the
4
+ // user shouldn't leave the app — the host registers an "oauth" service that runs the native sheet
5
+ // and hands back the provider's CREDENTIAL. The app then calls its own endpoint, which does
6
+ // `auth.oauth.signIn(provider, credential)`: the server verifies the credential with the provider
7
+ // (audience = the platform's app ids) and binds the device session. No token is ever trusted
8
+ // client-side; the credential is opaque to the app.
9
+ //
10
+ // Host-OPTIONAL — gate on `OAuth.isSupported(provider)`; fall back to the browser broker otherwise.
11
+ //
12
+ // Service contract ("oauth"): call `signIn(provider) → OAuthCredential` (rejects "cancelled" /
13
+ // "unavailable"), call `providers() → string[]` (which providers this host has SDKs for).
14
+
15
+ import { ServiceClient } from "../runtime/service"
16
+
17
+ export type OAuthProviderName = "google" | "apple" | "yandex" | "vk"
18
+
19
+ /** What a native provider SDK yields — pass it verbatim to your endpoint / `auth.oauth.signIn`. */
20
+ export type OAuthCredential = {
21
+ provider: OAuthProviderName
22
+ /** OAuth access token (Yandex, VK ID). */
23
+ accessToken?: string
24
+ /** OpenID identity token (Google, Apple). */
25
+ idToken?: string
26
+ /** Apple only sends the name once, on the first sign-in — forward it so the server can store it. */
27
+ name?: string
28
+ email?: string
29
+ }
30
+
31
+ /**
32
+ * Native provider sign-in. `OAuth.isSupported` reports whether this host registered the service;
33
+ * `OAuth.providers()` which providers it has SDKs for. Otherwise open `auth.oauth.url(provider)` (server)
34
+ * with `openURL` — the browser broker.
35
+ */
36
+ // PURE IIFE so an app that never uses it tree-shakes the plugin away (see Geolocation).
37
+ export const OAuth: {
38
+ /** Whether this host registered an "oauth" service. */
39
+ readonly isSupported: boolean
40
+ /** The providers this host can sign in with natively. */
41
+ providers(): Promise<OAuthProviderName[]>
42
+ /** Run the provider's native sheet; resolves with the credential for `auth.oauth.signIn`. Rejects "cancelled" / "unavailable". */
43
+ signIn(provider: OAuthProviderName): Promise<OAuthCredential>
44
+ } = /*#__PURE__*/ (() => {
45
+ let client: ServiceClient | null = null
46
+ const service = (): ServiceClient => client ??= new ServiceClient("oauth")
47
+ const assertSupported = (): void => {
48
+ if (!ServiceClient.isSupported("oauth")) {
49
+ throw new Error("OAuth isn't available on this host — it needs a registered \"oauth\" service; open auth.oauth.url(provider) in the browser instead.")
50
+ }
51
+ }
52
+ return {
53
+ get isSupported() { return ServiceClient.isSupported("oauth") },
54
+ providers: async () => { assertSupported(); const p = await service().call("providers"); return Array.isArray(p) ? p : [] },
55
+ signIn: async (provider) => {
56
+ assertSupported()
57
+ const r = await service().call("signIn", provider)
58
+ return { provider, ...(r ?? {}) }
59
+ },
60
+ }
61
+ })()
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Client transport for the app backend (docs/backend-plan.md §3.2 / §3.3). The compiler replaces
3
+ * every `*.server.ts` module in the client bundle with a stub whose exports are built by the three
4
+ * globals below — user code never calls them; it imports the server function and calls it.
5
+ *
6
+ * export const listNotes = __rpc("<serverUrl>", "notes.server.ts#listNotes")
7
+ * export const chat = __channel("<serverUrl>", "chat.server.ts#chat")
8
+ * export const db = __serverOnly("schema.server.ts#db")
9
+ *
10
+ * Wire (implemented by the runner):
11
+ * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
12
+ * → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
13
+ * WS <serverUrl>/ws frames: → {t:"hello",session} {t:"sub",id,ch,topic} {t:"unsub",id}
14
+ * ← {t:"session",session} {t:"ok",id} {t:"err",id,status,message} {t:"ev",ch,topic,event,data}
15
+ * (`ev` is addressed by channel + topic, not by subscription id, so the server can fan one publish out
16
+ * to every socket of a topic natively; the client delivers it to each matching subscription.)
17
+ *
18
+ * The session token is transport-owned (cookie-like): stored under `lecodes.session:<serverUrl>` in
19
+ * localStorage, sent on every call and on the socket, replaced whenever a response carries `session`.
20
+ * User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
21
+ */
22
+
23
+ import { fetch } from "./fetch"
24
+ import { localStorage } from "./storage"
25
+ import { WebSocket } from "./net"
26
+
27
+ /** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
28
+ * (0 = the request itself failed: no network / no server). */
29
+ export class RpcError extends Error {
30
+ readonly status: number
31
+ constructor(status: number, message: string) {
32
+ super(message)
33
+ this.name = "RpcError"
34
+ this.status = status
35
+ }
36
+ }
37
+
38
+ // ───────────────────────────── session token ─────────────────────────────
39
+
40
+ const sessionKey = (serverUrl: string) => `lecodes.session:${serverUrl}`
41
+ const getSession = (serverUrl: string): string | null => {
42
+ try { return localStorage.getItem(sessionKey(serverUrl)) } catch { return null }
43
+ }
44
+ const setSession = (serverUrl: string, token: string | null) => {
45
+ try {
46
+ if (token) localStorage.setItem(sessionKey(serverUrl), token)
47
+ else localStorage.removeItem(sessionKey(serverUrl))
48
+ } catch { /* storage-less host: the session lives for this run only */ }
49
+ sockets.get(serverUrl)?.onSessionChanged(token)
50
+ }
51
+
52
+ // ───────────────────────────── endpoints ─────────────────────────────
53
+
54
+ const noServer = (id: string) => new RpcError(0, `Endpoint ${id}: the project has no deployed server (compile with serverUrl)`)
55
+
56
+ /** Build the client proxy of endpoint `id`. Positional args travel as a JSON array. */
57
+ export const __rpc = (serverUrl: string, id: string) => {
58
+ const call = async (...args: unknown[]): Promise<any> => {
59
+ if (!serverUrl) throw noServer(id)
60
+ const headers: Record<string, string> = { "content-type": "application/json" }
61
+ const session = getSession(serverUrl)
62
+ if (session) headers.authorization = `Bearer ${session}`
63
+ let res
64
+ try {
65
+ res = await fetch(`${serverUrl}/api/${encodeURIComponent(id)}`, { method: "POST", headers, body: JSON.stringify({ args }) })
66
+ } catch (e) {
67
+ throw new RpcError(0, `Endpoint ${id}: request failed (${(e as Error)?.message ?? e})`)
68
+ }
69
+ let body: any
70
+ try { body = res.json() } catch { body = null }
71
+ res.dispose()
72
+ if (body && typeof body === "object" && "session" in body) setSession(serverUrl, body.session ?? null)
73
+ if (body && body.ok === true) return body.result
74
+ if (body && body.ok === false) throw new RpcError(body.status ?? res.status, body.message ?? "")
75
+ throw new RpcError(res.status, `Endpoint ${id}: unexpected response (HTTP ${res.status})`)
76
+ }
77
+ return call
78
+ }
79
+
80
+ /** A server-only export (db, models, helpers) referenced from client code: any use throws. */
81
+ export const __serverOnly = (id: string): any =>
82
+ new Proxy(function () {} as any, {
83
+ get: (_t, prop) => { if (prop === "then") return undefined; throw new RpcError(0, `${id} is server-only — it can't be used in client code`) },
84
+ apply: () => { throw new RpcError(0, `${id} is server-only — it can't be used in client code`) },
85
+ })
86
+
87
+ // ───────────────────────────── channels ─────────────────────────────
88
+
89
+ type Handlers = Record<string, ((payload: any) => void) | undefined> & {
90
+ /** The socket dropped and came back: subscriptions were re-sent, but events in between are lost — refetch. */
91
+ reconnect?: () => void
92
+ /** The subscription was refused (`onJoin` threw) or the socket can't connect. */
93
+ error?: (e: RpcError) => void
94
+ }
95
+ type Sub = { id: number, ch: string, topic: string, handlers: Handlers, ready: boolean }
96
+
97
+ const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000]
98
+
99
+ /** One multiplexed socket per server URL, shared by every channel of the app. */
100
+ class ChannelSocket {
101
+ private ws: WebSocket | null = null
102
+ private open = false
103
+ private subs = new Map<number, Sub>()
104
+ private nextId = 1
105
+ private attempts = 0
106
+ private timer: ReturnType<typeof setTimeout> | null = null
107
+ private closedByUs = false
108
+ private everOpened = false
109
+ private serverUrl: string
110
+
111
+ constructor(serverUrl: string) { this.serverUrl = serverUrl }
112
+
113
+ subscribe(ch: string, topic: string, handlers: Handlers): { close(): void } {
114
+ const sub: Sub = { id: this.nextId++, ch, topic, handlers, ready: false }
115
+ this.subs.set(sub.id, sub)
116
+ if (this.open) this.send({ t: "sub", id: sub.id, ch, topic })
117
+ else this.connect()
118
+ return {
119
+ close: () => {
120
+ if (!this.subs.delete(sub.id)) return
121
+ if (this.open) this.send({ t: "unsub", id: sub.id })
122
+ if (this.subs.size === 0) this.shutdown()
123
+ },
124
+ }
125
+ }
126
+
127
+ onSessionChanged(token: string | null) {
128
+ if (this.open) this.send({ t: "hello", session: token })
129
+ }
130
+
131
+ private connect() {
132
+ if (this.ws || this.timer !== null) return
133
+ this.closedByUs = false
134
+ const url = this.serverUrl.replace(/^http/, "ws") + "/ws"
135
+ const session = getSession(this.serverUrl)
136
+ const ws = new WebSocket(url, session ? { authorization: `Bearer ${session}` } : undefined)
137
+ this.ws = ws
138
+ ws.addEventListener("open", () => {
139
+ this.open = true
140
+ this.attempts = 0
141
+ this.send({ t: "hello", session: getSession(this.serverUrl) })
142
+ const reconnected = this.everOpened
143
+ this.everOpened = true
144
+ for (const sub of this.subs.values()) {
145
+ sub.ready = false
146
+ this.send({ t: "sub", id: sub.id, ch: sub.ch, topic: sub.topic })
147
+ if (reconnected) sub.handlers.reconnect?.()
148
+ }
149
+ })
150
+ ws.addEventListener("message", (data) => {
151
+ if (typeof data !== "string") return
152
+ let msg: any
153
+ try { msg = JSON.parse(data) } catch { return }
154
+ this.onFrame(msg)
155
+ })
156
+ const onDown = () => {
157
+ if (this.ws !== ws) return
158
+ this.ws = null
159
+ this.open = false
160
+ if (this.closedByUs || this.subs.size === 0) return
161
+ const delay = BACKOFF_MS[Math.min(this.attempts++, BACKOFF_MS.length - 1)]
162
+ this.timer = setTimeout(() => { this.timer = null; this.connect() }, delay)
163
+ }
164
+ ws.addEventListener("close", onDown)
165
+ ws.addEventListener("error", onDown)
166
+ }
167
+
168
+ private onFrame(msg: any) {
169
+ switch (msg?.t) {
170
+ case "session": setSession(this.serverUrl, msg.session ?? null); break
171
+ case "ok": { const s = this.subs.get(msg.id); if (s) s.ready = true; break }
172
+ case "err": {
173
+ const s = this.subs.get(msg.id)
174
+ if (!s) break
175
+ this.subs.delete(msg.id)
176
+ s.handlers.error?.(new RpcError(msg.status ?? 0, msg.message ?? "subscription refused"))
177
+ break
178
+ }
179
+ case "ev": {
180
+ for (const s of this.subs.values()) {
181
+ if (s.ch === msg.ch && s.topic === msg.topic) s.handlers[msg.event]?.(msg.data)
182
+ }
183
+ break
184
+ }
185
+ }
186
+ }
187
+
188
+ private send(frame: unknown) { this.ws?.send(JSON.stringify(frame)) }
189
+
190
+ private shutdown() {
191
+ this.closedByUs = true
192
+ if (this.timer !== null) { clearTimeout(this.timer); this.timer = null }
193
+ this.ws?.close()
194
+ this.ws = null
195
+ this.open = false
196
+ sockets.delete(this.serverUrl)
197
+ }
198
+ }
199
+
200
+ const sockets = new Map<string, ChannelSocket>()
201
+ const socketFor = (serverUrl: string) => {
202
+ let s = sockets.get(serverUrl)
203
+ if (!s) { s = new ChannelSocket(serverUrl); sockets.set(serverUrl, s) }
204
+ return s
205
+ }
206
+
207
+ export interface ClientChannel<E extends Record<string, unknown> = Record<string, unknown>> {
208
+ /** Subscribe to `topic`; handlers are keyed by event name (+ `reconnect`/`error`). Returns `{ close }`. */
209
+ subscribe(topic: string, handlers: { [K in keyof E]?: (payload: E[K]) => void } & { reconnect?: () => void, error?: (e: RpcError) => void }): { close(): void }
210
+ }
211
+
212
+ /** Build the client proxy of channel `id`. */
213
+ export const __channel = (serverUrl: string, id: string): ClientChannel<any> => ({
214
+ subscribe(topic, handlers) {
215
+ if (!serverUrl) throw noServer(id)
216
+ return socketFor(serverUrl).subscribe(id, topic, handlers as Handlers)
217
+ },
218
+ })
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The project's `app.json` subset the server runtime reads (name, auth providers).
3
+ *
4
+ * This module is a placeholder the COMPILER REPLACES: `compileServerBundle({ app })` puts a module with the
5
+ * real values at this same path in chisel's file map (project files win over SDK files), so every SDK module
6
+ * that imports `app` gets the project's config as a plain dependency — no globals, no evaluation-order games.
7
+ * Outside a compiled bundle (tests, tooling) `globalThis.__lecodesApp` can stand in.
8
+ */
9
+ export type AuthProvider = "email" | "google" | "apple" | "yandex" | "vk"
10
+ export type AppConfig = { name?: string, auth?: { providers?: AuthProvider[] } }
11
+
12
+ export const app: AppConfig = {}