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.
- package/README.md +27 -4
- package/dist/index.js +170706 -72
- package/package.json +11 -6
- package/runtime/sdk/compile/bundler.ts +10 -2
- package/runtime/sdk/compile/compileProject.ts +10 -2
- package/runtime/sdk/compile/detectEntry.ts +5 -2
- package/runtime/sdk/compile/index.ts +17 -1
- package/runtime/sdk/compile/serverSplit.ts +233 -0
- package/runtime/sdk/compile/serverTypes.ts +227 -0
- package/runtime/sdk/gl/AnimationClip.ts +174 -0
- package/runtime/sdk/gl/Animator.ts +634 -0
- package/runtime/sdk/gl/CharacterController.ts +6 -1
- package/runtime/sdk/gl/IK.ts +170 -0
- package/runtime/sdk/gl/Material.ts +1 -1
- package/runtime/sdk/gl/ModelAnimation.ts +34 -3
- package/runtime/sdk/gl/Node.ts +21 -0
- package/runtime/sdk/inject.ts +10 -0
- package/runtime/sdk/plugins/oauth.ts +61 -0
- package/runtime/sdk/runtime/rpc.ts +218 -0
- package/runtime/sdk/server/auth/appConfig.ts +12 -0
- package/runtime/sdk/server/auth/global.ts +80 -0
- package/runtime/sdk/server/auth/host.ts +318 -0
- package/runtime/sdk/server/auth/models.ts +83 -0
- package/runtime/sdk/server/auth/types.ts +50 -0
- package/runtime/sdk/server/channel.ts +56 -0
- package/runtime/sdk/server/context.ts +36 -0
- package/runtime/sdk/server/db/defineDb.ts +237 -0
- package/runtime/sdk/server/db/fields.ts +132 -0
- package/runtime/sdk/server/db/httpTransport.ts +93 -0
- package/runtime/sdk/server/db/index.ts +7 -0
- package/runtime/sdk/server/db/marci/query.ts +412 -0
- package/runtime/sdk/server/db/types.ts +202 -0
- package/runtime/sdk/server/errors.ts +12 -0
- package/runtime/sdk/server/host.ts +74 -0
- package/runtime/sdk/server/inject.ts +13 -0
- package/runtime/sdk/server/runtime.ts +133 -0
- package/runtime/sdk/server/validate.ts +87 -0
- 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 =
|
|
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
|
-
|
|
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
|
}
|
package/runtime/sdk/gl/Node.ts
CHANGED
|
@@ -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
|
|
package/runtime/sdk/inject.ts
CHANGED
|
@@ -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 = {}
|