lecodes-sdk 2.5.1 → 2.8.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 (50) hide show
  1. package/dist/global.d.ts +7 -7
  2. package/dist/types/gl/Foliage.d.ts +6 -0
  3. package/dist/types/inject.d.ts +1 -1
  4. package/dist/types/runtime/firstCall.d.ts +12 -0
  5. package/dist/types/runtime/rpc.d.ts +24 -0
  6. package/dist/types/runtime/wire.d.ts +11 -4
  7. package/dist/types/server/channel.d.ts +16 -0
  8. package/dist/types/server/computed.d.ts +59 -0
  9. package/dist/types/server/context.d.ts +13 -1
  10. package/dist/types/server/db/fields.d.ts +2 -1
  11. package/dist/types/server/db/marci/query.d.ts +53 -6
  12. package/dist/types/server/inject.d.ts +2 -0
  13. package/dist/types/server/ordered.d.ts +18 -0
  14. package/dist/types/ui/UIBottomSheet.d.ts +21 -14
  15. package/dist/types/ui/UIModal.d.ts +50 -22
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +2 -2
  19. package/src/bridges/gl.d.ts +4 -1
  20. package/src/chisel.ts +1 -1
  21. package/src/compile/compileProject.ts +7 -2
  22. package/src/compile/serverSplit.ts +8 -4
  23. package/src/compile/serverTypes.ts +17 -3
  24. package/src/gl/Foliage.ts +11 -1
  25. package/src/inject.ts +3 -2
  26. package/src/runtime/firstCall.ts +28 -0
  27. package/src/runtime/rpc.ts +141 -12
  28. package/src/runtime/wire.ts +11 -6
  29. package/src/server/channel.ts +32 -0
  30. package/src/server/channelHub.ts +246 -16
  31. package/src/server/clock.ts +50 -0
  32. package/src/server/computed.ts +84 -0
  33. package/src/server/computedHost.ts +413 -0
  34. package/src/server/context.ts +14 -2
  35. package/src/server/db/defineDb.ts +47 -7
  36. package/src/server/db/fields.ts +2 -1
  37. package/src/server/db/httpTransport.ts +37 -2
  38. package/src/server/db/httpWatch.ts +180 -0
  39. package/src/server/db/marci/query.ts +112 -7
  40. package/src/server/db/watch.ts +160 -0
  41. package/src/server/files/host.ts +2 -1
  42. package/src/server/host.ts +19 -4
  43. package/src/server/inject.ts +6 -2
  44. package/src/server/ordered.ts +41 -0
  45. package/src/server/rateLimit.ts +72 -0
  46. package/src/server/runtime.ts +127 -9
  47. package/src/ui/UIBottomSheet.ts +39 -30
  48. package/src/ui/UIModal.ts +128 -31
  49. package/src/ui/UIWidget.ts +21 -8
  50. package/src/version.ts +1 -1
package/package.json CHANGED
@@ -40,10 +40,10 @@
40
40
  "@types/node": "^25.9.1",
41
41
  "typescript": "~5.8.3",
42
42
  "gl-matrix": "^3.4.4",
43
- "marcidb-embedded": "^0.15.0",
43
+ "marcidb-embedded": "^0.18.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.5.1",
46
+ "version": "2.8.0",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -58,6 +58,9 @@ declare global {
58
58
  stopVR(): void
59
59
  addEntityToScene(sceneId: u32, entityId: u32): void
60
60
  removeEntityFromScene(sceneId: u32, entityId: u32): void
61
+ /** The renderables the scene holds right now (Filament's Scene::getRenderableCount): a destroyed subtree must take
62
+ * its own back out — checks/owned asserts it. Optional: a host without it leaves the method absent. */
63
+ sceneRenderableCount?(sceneId: u32): u32
61
64
  /** @custom */
62
65
  warmRender(sceneId: u32, onComplete: () => void): void
63
66
  /** Compile every shader variant the scene's materials can need — sun / shadows / fog / skinning
@@ -852,7 +855,7 @@ declare global {
852
855
  foliageSetLeaf?(shiver: f32, translucency: f32, leafShadow: f32, skyAo: f32): void
853
856
  /** The look of the cards: coverage = alpha × (1 + coverage × mip level) so a distant crown keeps its density as its
854
857
  * atlas mips away. */
855
- foliageSetLook?(coverage: f32): void
858
+ foliageSetLook?(coverage: f32, skyThrough: f32): void
856
859
  /** An entity whose live world position bends the vegetation within `radius` metres; <= 0 removes it. The host
857
860
  * writes the 8 benders nearest the camera to the shader each frame. */
858
861
  foliageSetBender?(entityId: u32, radius: f32): void
package/src/chisel.ts CHANGED
@@ -128,7 +128,7 @@ export const chiselVocab: ChiselVocab = {
128
128
  "style", "class", "named", "theme",
129
129
  "onClick", "onTouchStart", "onLongPress", "onMouseEnter", "onLayout", "onOpen", "onClose", "onBack",
130
130
  "onScroll", "onScrollRelease", "onOverscroll", "onRefresh", "onSubmit",
131
- "onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent",
131
+ "onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent", "onDismiss", "bindShow",
132
132
  "onEndReached", "onStartReached",
133
133
  "append", "insert", "remove", "setContent", "widgets", "keepAlive", "url",
134
134
  "detents", "dismissible", "transition", "setSourceRect",
@@ -112,17 +112,22 @@ export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop(
112
112
  * order, i.e. on nothing. (2026-09-17: a viewer project carried two asset packs that each ship
113
113
  * `SM_Sign_01..03.glb`; the school's floor-standing "CLEANING IN PROGRESS" A-frame was drawn inside
114
114
  * the shooting range's wall, where a flat 4 mm plate belonged.)
115
+ *
116
+ * SHARING IS CASE-BLIND: the staging directory lives on Windows / macOS file systems, where `Tire.glb`
117
+ * and `tire.glb` are one file. Compared by exact name both were "unique", staged into the same slot,
118
+ * and the second copy went THROUGH the first one's hard link into the project — lightcheck's Chernobyl
119
+ * `Tire.glb` became the yard's `props/tire.glb` on disk (2026-11-04).
115
120
  */
116
121
  export const localAssetNames = (entries: readonly { path: string, type: string }[]): Map<string, string> => {
117
122
  const seen = new Map<string, number>()
118
123
  for (const e of entries) if (e.type === "resource") {
119
- const b = localAssetBase(e.path)
124
+ const b = localAssetBase(e.path).toLowerCase()
120
125
  seen.set(b, (seen.get(b) ?? 0) + 1)
121
126
  }
122
127
  const names = new Map<string, string>()
123
128
  for (const e of entries) if (e.type === "resource") {
124
129
  const b = localAssetBase(e.path)
125
- names.set(e.path, (seen.get(b) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
130
+ names.set(e.path, (seen.get(b.toLowerCase()) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
126
131
  }
127
132
  return names
128
133
  }
@@ -2,14 +2,15 @@
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`, `__pushChannel`, `__serverOnly` — client SDK
5
+ * module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__computed`, `__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, a `pushChannel<…>()` export a push channel, anything else is server-only.
12
+ * endpoint, a `channel<…>()…` export is a channel, a `computed(…)` export a computed (an endpoint the app
13
+ * may also subscribe to), a `pushChannel<…>()` export a push channel, anything else is server-only.
13
14
  */
14
15
 
15
16
  import type { AppConfig } from "../server/auth/appConfig"
@@ -26,11 +27,12 @@ export const serverModuleId = (path: string) => path.split("\\").join("/").repla
26
27
 
27
28
  /**
28
29
  * What an export of a server module is to the app: `fn` = an endpoint — an ASYNC function, called
29
- * over the network; `channel` = a channel; `other` = server-only — a model, the db, a constant, or
30
+ * over the network; `channel` = a channel; `computed` = a computed (../server/computed.ts): called like
31
+ * an endpoint, or subscribed to; `push` = a push channel; `other` = server-only — a model, the db, a constant, or
30
32
  * a helper: a function that is not async is used by server files alone, and the app's import of
31
33
  * it is refused at compile time (serverTypes.ts), never a call that silently holds a promise.
32
34
  */
33
- export type ServerExportKind = "fn" | "channel" | "push" | "other"
35
+ export type ServerExportKind = "fn" | "channel" | "computed" | "push" | "other"
34
36
  export type ServerExport = { name: string, kind: ServerExportKind }
35
37
 
36
38
  // ───────────────────────────── static export scan ─────────────────────────────
@@ -86,6 +88,7 @@ const IDENT = "[A-Za-z_$][\\w$]*"
86
88
  const classifyInit = (init: string): ServerExportKind => {
87
89
  const s = init.trimStart()
88
90
  if (/^channel\s*[<(]/.test(s)) return "channel"
91
+ if (/^computed\s*[<(]/.test(s)) return "computed"
89
92
  if (/^pushChannel\s*[<(]/.test(s)) return "push"
90
93
  if (/^async\s+function\b/.test(s)) return "fn"
91
94
  if (/^async\b/.test(s)) return "fn" // async (…) => / async x =>
@@ -164,6 +167,7 @@ export const serverStubSource = (path: string, exports: ServerExport[], serverUr
164
167
  const id = JSON.stringify(`${mod}#${name}`)
165
168
  if (kind === "fn") return `export const ${name} = __rpc(${url}, ${id})`
166
169
  if (kind === "channel") return `export const ${name} = __channel(${url}, ${id})`
170
+ if (kind === "computed") return `export const ${name} = __computed(${url}, ${id})`
167
171
  if (kind === "push") return `export const ${name} = __pushChannel(${url}, ${id})`
168
172
  return `export const ${name} = __serverOnly(${id})`
169
173
  })
@@ -14,7 +14,8 @@
14
14
  * "endpoint args must be JSON-serializable". Return types are not validated.
15
15
  *
16
16
  * A channel's `groupBy(fn)` is the same case: `fn` is called with what the app passed to
17
- * `subscribe`, so its parameters get a schema under the channel's id.
17
+ * `subscribe`, so its parameters get a schema under the channel's id; and so is a `computed(fn)` —
18
+ * called and subscribed to with the same arguments, under the computed's id.
18
19
  *
19
20
  * `typescript` is loaded lazily (it's a devDependency of the SDK and a dependency of the platform
20
21
  * compile step); the browser-safe parts of the compiler never import this module's runtime.
@@ -252,6 +253,11 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
252
253
  if (!hook) { params[id] = []; continue }
253
254
  type = checker.getTypeAtLocation(hook)
254
255
  where = `${id}.groupBy`
256
+ } else if (ex.kind === "computed") {
257
+ // `computed` too: its function is the call's first argument
258
+ const fn = computedFn(ts, decl)
259
+ if (!fn) { params[id] = []; continue }
260
+ type = checker.getTypeAtLocation(fn)
255
261
  } else {
256
262
  type = checker.getTypeOfSymbolAtLocation(sym, decl)
257
263
  }
@@ -261,8 +267,8 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
261
267
  for (const p of sig.getParameters()) {
262
268
  const pd = p.valueDeclaration as import("typescript").ParameterDeclaration | undefined
263
269
  // nothing here gives a hook's parameter a type but its own annotation — without one it is `any`, unchecked
264
- if (ex.kind === "channel" && pd && !pd.type && !pd.initializer) {
265
- problems.push(`${where}(${p.name}): give the parameter a type — it is what the app passes to subscribe, and it is checked against it`)
270
+ if ((ex.kind === "channel" || ex.kind === "computed") && pd && !pd.type && !pd.initializer) {
271
+ problems.push(`${where}(${p.name}): give the parameter a type — it is what the app passes to ${ex.kind === "channel" ? "subscribe" : "the call and to subscribe"}, and it is checked against it`)
266
272
  continue
267
273
  }
268
274
  const ptype = checker.getTypeOfSymbolAtLocation(p, pd ?? decl)
@@ -294,6 +300,14 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
294
300
  return { params, warnings }
295
301
  }
296
302
 
303
+ /** The function a computed export is made of (`computed(async (…) => …)`). */
304
+ const computedFn = (ts: TS, decl: import("typescript").Declaration): import("typescript").Expression | null => {
305
+ let node: import("typescript").Expression | undefined = ts.isVariableDeclaration(decl) ? decl.initializer : undefined
306
+ while (node && (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node))) node = node.expression
307
+ if (node && ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "computed") return node.arguments[0] ?? null
308
+ return null
309
+ }
310
+
297
311
  /** The function given to `.groupBy(…)` in a channel export's initializer (`channel<M>().authorize(…).groupBy(fn)`). */
298
312
  const groupByHook = (ts: TS, decl: import("typescript").Declaration): import("typescript").Expression | null => {
299
313
  let node: import("typescript").Expression | undefined = ts.isVariableDeclaration(decl) ? decl.initializer : undefined
package/src/gl/Foliage.ts CHANGED
@@ -64,6 +64,12 @@ export type FoliageOptions = {
64
64
  /** Coverage held over distance: the cards' alpha × (1 + coverage × mip level), so a far crown does not thin out as its
65
65
  * atlas mips away. Default 0.25, 0 = none; past ~0.5 a crown turns into a solid mass. */
66
66
  coverage?: number
67
+ /** 0..1: THE SKY THROUGH A THIN LEAF. A crown's cards are lit by the sky on their hull normal (out of the crown's centre),
68
+ * so a card below the crown's middle faces DOWN and reads the sky's lower hemisphere — the ground of the environment
69
+ * map, which under an overcast sky is a tenth of the zenith: the lower half of every plant went black (2026-11-04). A
70
+ * leaf is thin: this share of the sky on the OTHER side of the card is added, so the underside of a crown takes this
71
+ * fraction of what its top takes. Default 0.3, 0 = the hull alone. */
72
+ skyThrough?: number
67
73
  /** THE PLANTS' CASTER DISTANCE, metres: a plant casts the sun's shadow only this close to the camera (the LOD pass takes
68
74
  * the farther ones out of the shadow map; 3 m of hysteresis). Meant for a level with a BAKED ground, which keeps the
69
75
  * far shadows while the shadow map holds a fraction of the casters (the Forest's Radeon frame: 6 ms of 39 at 25 m).
@@ -83,6 +89,7 @@ const state: Required<Omit<FoliageOptions, "wind">> & { wind: Required<FoliageWi
83
89
  leafShadow: 0.6,
84
90
  skyAo: 0.7,
85
91
  coverage: 0.25,
92
+ skyThrough: 0.3,
86
93
  castDistance: 0,
87
94
  }
88
95
 
@@ -108,7 +115,10 @@ export const Foliage = {
108
115
  if (options.leafShadow !== undefined) { state.leafShadow = options.leafShadow; leaf = true }
109
116
  if (options.skyAo !== undefined) { state.skyAo = options.skyAo; leaf = true }
110
117
  if (leaf) _creator.foliageSetLeaf?.(state.shiver, state.translucency, state.leafShadow, state.skyAo)
111
- if (options.coverage !== undefined) { state.coverage = options.coverage; _creator.foliageSetLook?.(state.coverage) }
118
+ let look = false
119
+ if (options.coverage !== undefined) { state.coverage = options.coverage; look = true }
120
+ if (options.skyThrough !== undefined) { state.skyThrough = options.skyThrough; look = true }
121
+ if (look) _creator.foliageSetLook?.(state.coverage, state.skyThrough)
112
122
  if (options.castDistance !== undefined) { state.castDistance = options.castDistance; _creator.foliageSetCast?.(state.castDistance) }
113
123
  },
114
124
 
package/src/inject.ts CHANGED
@@ -43,8 +43,9 @@ export { WebSocket } from "./runtime/net"
43
43
  // Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
44
44
  export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
45
45
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
46
- // only ever imports its server functions (docs/backend-plan.md §4).
47
- export { __rpc, __channel, __pushChannel, __serverOnly, RpcError } from "./runtime/rpc"
46
+ // only ever imports its server functions (docs/backend-plan.md §4) — and reads `online`, whether the
47
+ // transport reaches the backend.
48
+ export { __rpc, __channel, __computed, __pushChannel, __serverOnly, RpcError, online } from "./runtime/rpc"
48
49
  // The font() compile macro's generated `.__fonts.ts` modules call this per face (UI tree + Font2D).
49
50
  export { __registerFont } from "./ui/fonts"
50
51
  // What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The client transport's first-call rule (./rpc.ts), kept apart so a host that calls a backend as a
3
+ * device — a flow's `call` step, `lecodes call` (tools/cli/src/dev/backendCall.ts) — follows the very
4
+ * same rule: a device with no token yet sends its first call alone and the calls behind it wait, so a
5
+ * backend that mints a token on first contact (`request.session`, a guest session) mints ONE — two
6
+ * calls in flight would each get their own and the device would end up with whichever landed last.
7
+ * Once per device per run: the gate opens for good when the first call is back, whether or not it
8
+ * brought a token. Pure: no bridges, no globals.
9
+ */
10
+
11
+ export type FirstCallGate = <T>(hasToken: () => boolean, send: () => Promise<T>) => Promise<T>
12
+
13
+ /** One gate per device (and backend): `gate(hasToken, send)` runs `send` under the rule. */
14
+ export const firstCallGate = (): FirstCallGate => {
15
+ /** undefined: no call yet; a promise: the first call is out; null: it came back */
16
+ let first: Promise<void> | null | undefined
17
+ return async (hasToken, send) => {
18
+ if (!hasToken()) {
19
+ if (first) await first
20
+ else if (first === undefined) {
21
+ let release!: () => void
22
+ first = new Promise<void>((r) => { release = r })
23
+ try { return await send() } finally { first = null; release() }
24
+ }
25
+ }
26
+ return send()
27
+ }
28
+ }
@@ -5,6 +5,7 @@
5
5
  *
6
6
  * export const listNotes = __rpc("<serverUrl>", "notes.server.ts#listNotes")
7
7
  * export const chat = __channel("<serverUrl>", "chat.server.ts#chat")
8
+ * export const roomView = __computed("<serverUrl>", "game.server.ts#roomView")
8
9
  * export const db = __serverOnly("schema.server.ts#db")
9
10
  *
10
11
  * Wire (implemented by the runner):
@@ -18,14 +19,20 @@
18
19
  * The session token is transport-owned (cookie-like): stored under `lecodes.session:<serverUrl>` in
19
20
  * localStorage, sent on every call and on the socket, replaced whenever a response carries `session`.
20
21
  * User code never sees it — "am I logged in" is an endpoint (`me()`), see the plan.
22
+ *
23
+ * `online` (the app's global) is what this transport last learned of its backends: both the calls and
24
+ * the socket go through here, so it is kept here (docs/plans/multiplayer-plan.md D5a).
21
25
  */
22
26
 
23
27
  import { fetch, File, FormData } from "./fetch"
28
+ import { signal, type Computed } from "../core/signals"
24
29
  import { localStorage } from "./storage"
25
30
  import { WebSocket } from "./net"
26
31
  import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
27
32
  import type { ChannelSubscription } from "../server/channel"
33
+ import type { ComputedOptions } from "../server/computed"
28
34
  import { _pushRuntime } from "./pushSeam"
35
+ import { firstCallGate, type FirstCallGate } from "./firstCall"
29
36
 
30
37
  /** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
31
38
  * (0 = the request itself failed: no network / no server). */
@@ -38,6 +45,32 @@ export class RpcError extends Error {
38
45
  }
39
46
  }
40
47
 
48
+ // ───────────────────────────── online ─────────────────────────────
49
+
50
+ /** The backends the transport did not reach the last time it tried — a call that never got there, a
51
+ * socket that dropped and is trying again — by server URL. */
52
+ const unreachable = new Set<string>()
53
+ const reachable = signal(true)
54
+ /** What a call or the socket just learned of one backend: it answered (an error answer is an answer),
55
+ * or it could not be reached. */
56
+ const heard = (serverUrl: string, answered: boolean) => {
57
+ if (answered) unreachable.delete(serverUrl)
58
+ else unreachable.add(serverUrl)
59
+ reachable.value = unreachable.size === 0
60
+ }
61
+
62
+ /**
63
+ * Does the app reach its backend: `true` until a call fails without reaching it (`RpcError` status 0)
64
+ * or the channel socket drops and starts reconnecting; `true` again once the socket opens, a message
65
+ * comes over it, or a call is answered — with an error too. With several backends, all of them. Read
66
+ * `.value` in a binding, a `computed` or an `effect`; it is not the device's network (`device.online`)
67
+ * and says nothing of whether what a screen shows is old: a computed re-delivers by itself.
68
+ */
69
+ export const online: Computed<boolean> = reachable
70
+
71
+ /** @internal forget what was learned of every backend (a test's teardown: `online` is module state). */
72
+ export const _resetOnline = () => { unreachable.clear(); reachable.value = true }
73
+
41
74
  // ───────────────────────────── session token ─────────────────────────────
42
75
 
43
76
  const sessionKey = (serverUrl: string) => `lecodes.session:${serverUrl}`
@@ -61,6 +94,15 @@ const setSession = (serverUrl: string, token: string | null) => {
61
94
 
62
95
  const noServer = (id: string) => new RpcError(0, `Endpoint ${id}: the project has no deployed server (compile with serverUrl)`)
63
96
 
97
+ /** A device with no token yet: its first call to a backend goes alone and the calls behind it wait
98
+ * (./firstCall.ts — the rule a host that calls as a device follows too). One gate per backend per run. */
99
+ const firstCall = new Map<string, FirstCallGate>()
100
+ const gateOf = (serverUrl: string) => {
101
+ let gate = firstCall.get(serverUrl)
102
+ if (!gate) { gate = firstCallGate(); firstCall.set(serverUrl, gate) }
103
+ return gate
104
+ }
105
+
64
106
  /** Build the client proxy of endpoint `id`. Positional args travel as a JSON array. */
65
107
  export const __rpc = (serverUrl: string, id: string) => {
66
108
  const call = async (...args: unknown[]): Promise<any> => {
@@ -68,6 +110,22 @@ export const __rpc = (serverUrl: string, id: string) => {
68
110
  // an optional argument left out is `undefined` in its place — JSON has no such value (it would
69
111
  // travel as null and fail the parameter's type), and it is the last ones
70
112
  while (args.length && args[args.length - 1] === undefined) args.pop()
113
+ return gateOf(serverUrl)(() => !!getSession(serverUrl), () => send(args))
114
+ }
115
+ // what the call tells of the backend goes to `online`: an RpcError of status 0 never reached it, any
116
+ // other end of the call (an error answer too) did — an error thrown before sending (an argument JSON
117
+ // cannot carry) says nothing of it
118
+ const send = async (args: unknown[]): Promise<any> => {
119
+ try {
120
+ const result = await post(args)
121
+ heard(serverUrl, true)
122
+ return result
123
+ } catch (e) {
124
+ if (e instanceof RpcError) heard(serverUrl, e.status !== 0)
125
+ throw e
126
+ }
127
+ }
128
+ const post = async (args: unknown[]): Promise<any> => {
71
129
  const headers: Record<string, string> = {}
72
130
  const session = getSession(serverUrl)
73
131
  if (session) headers.authorization = `Bearer ${session}`
@@ -118,10 +176,21 @@ type Options = {
118
176
  error?: (e: RpcError) => void
119
177
  }
120
178
  /** `group` is the server's answer (the `ok` frame): undefined until it came. `refused` = the server
121
- * said no under that session: the subscription is kept and asked again under another one. */
179
+ * said no under that session: the subscription is kept and asked again under another one. `computed`
180
+ * = a computed's (`__computed`): the `ok` carries a value, and `last` is the JSON of the one handed on. */
122
181
  type Sub = {
123
182
  id: number, ch: string, args: unknown[], handler: (message: any) => void, options: Options,
124
183
  group: ChannelGroup | null | undefined, refused: { session: string | null } | null,
184
+ computed: boolean, last: string | undefined,
185
+ }
186
+ const subFrame = (s: Sub): ClientFrame => s.computed ? { t: "sub", id: s.id, ch: s.ch, args: s.args, computed: true } : { t: "sub", id: s.id, ch: s.ch, args: s.args }
187
+ /** A computed's value goes on only when it differs from the last one (a new socket, a new worker, a
188
+ * new session answer what the app may already have). */
189
+ const handOn = (s: Sub, value: unknown) => {
190
+ const json = JSON.stringify(value === undefined ? null : value)
191
+ if (json === s.last) return
192
+ s.last = json
193
+ s.handler(value === undefined ? null : value)
125
194
  }
126
195
 
127
196
  const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000]
@@ -140,15 +209,18 @@ class ChannelSocket {
140
209
  private linger: ReturnType<typeof setTimeout> | null = null
141
210
  private closedByUs = false
142
211
  private everOpened = false
212
+ /** The token this socket's last `hello` said: a `session` frame answers that hello, and is taken
213
+ * only while the storage still holds it — a call may have stored a newer one meanwhile. */
214
+ private hello: string | null = null
143
215
  private serverUrl: string
144
216
 
145
217
  constructor(serverUrl: string) { this.serverUrl = serverUrl }
146
218
 
147
- subscribe(ch: string, args: unknown[], handler: (message: any) => void, options: Options): ChannelSubscription {
148
- const sub: Sub = { id: this.nextId++, ch, args, handler, options, group: undefined, refused: null }
219
+ subscribe(ch: string, args: unknown[], handler: (message: any) => void, options: Options, computed = false): ChannelSubscription {
220
+ const sub: Sub = { id: this.nextId++, ch, args, handler, options, group: undefined, refused: null, computed, last: undefined }
149
221
  this.subs.set(sub.id, sub)
150
222
  if (this.linger !== null) { clearTimeout(this.linger); this.linger = null }
151
- if (this.open) this.send({ t: "sub", id: sub.id, ch, args })
223
+ if (this.open) this.send(subFrame(sub))
152
224
  else this.connect()
153
225
  return {
154
226
  close: () => {
@@ -166,6 +238,7 @@ class ChannelSocket {
166
238
  * ones are sent too: the new user may be let in. */
167
239
  onSessionChanged(token: string | null) {
168
240
  if (!this.open) return
241
+ this.hello = token
169
242
  this.send({ t: "hello", session: token })
170
243
  const subs = [...this.subs.values()]
171
244
  this.subs.clear()
@@ -174,7 +247,7 @@ class ChannelSocket {
174
247
  sub.group = undefined
175
248
  sub.refused = null
176
249
  this.subs.set(sub.id, sub)
177
- this.send({ t: "sub", id: sub.id, ch: sub.ch, args: sub.args })
250
+ this.send(subFrame(sub))
178
251
  }
179
252
  }
180
253
 
@@ -187,7 +260,9 @@ class ChannelSocket {
187
260
  ws.addEventListener("open", () => {
188
261
  this.open = true
189
262
  this.attempts = 0
263
+ heard(this.serverUrl, true)
190
264
  const session = getSession(this.serverUrl)
265
+ this.hello = session
191
266
  this.send({ t: "hello", session })
192
267
  const reconnected = this.everOpened
193
268
  this.everOpened = true
@@ -196,11 +271,14 @@ class ChannelSocket {
196
271
  if (sub.refused && sub.refused.session === session) continue
197
272
  sub.group = undefined
198
273
  sub.refused = null
199
- this.send({ t: "sub", id: sub.id, ch: sub.ch, args: sub.args })
200
- if (reconnected) sub.options.reconnect?.()
274
+ this.send(subFrame(sub))
275
+ // a computed's `ok` brings the value again: nothing was missed
276
+ if (reconnected && !sub.computed) sub.options.reconnect?.()
201
277
  }
202
278
  })
203
279
  ws.addEventListener("message", (data) => {
280
+ // the backend spoke (a socket the app has let go says nothing)
281
+ if (this.ws === ws) heard(this.serverUrl, true)
204
282
  if (typeof data !== "string") return
205
283
  let msg: ServerFrame
206
284
  try { msg = JSON.parse(data) } catch { return }
@@ -212,6 +290,9 @@ class ChannelSocket {
212
290
  this.open = false
213
291
  if (this.closedByUs) return
214
292
  if (this.subs.size === 0) { this.shutdown(); return }
293
+ // down while the app listens: it is trying again, and until then the backend is out of reach. A
294
+ // close the app asked for (the last subscription gone, a teardown) says nothing of it
295
+ heard(this.serverUrl, false)
215
296
  const delay = BACKOFF_MS[Math.min(this.attempts++, BACKOFF_MS.length - 1)]
216
297
  this.timer = setTimeout(() => { this.timer = null; this.connect() }, delay)
217
298
  }
@@ -221,24 +302,46 @@ class ChannelSocket {
221
302
 
222
303
  private onFrame(msg: ServerFrame) {
223
304
  switch (msg?.t) {
224
- // the server already serves this socket under the token it sends: stored, nothing re-sent
225
- case "session": storeSession(this.serverUrl, msg.session ?? null); break
226
- case "ok": { const s = this.subs.get(msg.id); if (s) s.group = msg.group ?? null; break }
305
+ // the server already serves this socket under the token it sends: stored, nothing re-sent — unless a
306
+ // call stored another token since the hello this answers: then the socket follows the storage (a
307
+ // device has one identity, the one its calls write rows under)
308
+ case "session": {
309
+ const token = msg.session ?? null
310
+ if (getSession(this.serverUrl) === this.hello) { storeSession(this.serverUrl, token); this.hello = token }
311
+ else this.onSessionChanged(getSession(this.serverUrl))
312
+ break
313
+ }
314
+ case "ok": {
315
+ const s = this.subs.get(msg.id)
316
+ if (!s) break
317
+ s.group = msg.group ?? null
318
+ if (s.computed) handOn(s, msg.data)
319
+ break
320
+ }
227
321
  case "err": {
228
322
  const s = this.subs.get(msg.id)
229
323
  if (!s) break
324
+ // a computed's later run failed: the subscription stands, the last value too
325
+ if (s.computed && s.group !== undefined) {
326
+ const e = new RpcError(msg.status ?? 0, msg.message ?? "the computed failed")
327
+ if (s.options.error) s.options.error(e)
328
+ else console.error(`Computed ${s.ch}: a run failed (${e.status}) — ${e.message}`)
329
+ break
330
+ }
230
331
  // kept until close(): another session may be let in (onSessionChanged)
231
332
  s.group = undefined
232
333
  s.refused = { session: getSession(this.serverUrl) }
233
334
  const e = new RpcError(msg.status ?? 0, msg.message ?? "subscription refused")
234
335
  if (s.options.error) s.options.error(e)
235
- else console.error(`Channel ${s.ch}: the subscription was refused (${e.status}) — ${e.message}`)
336
+ else console.error(`${s.computed ? "Computed" : "Channel"} ${s.ch}: the subscription was refused (${e.status}) — ${e.message}`)
236
337
  break
237
338
  }
238
339
  case "ev": {
239
340
  const group = msg.group ?? null
240
341
  for (const s of [...this.subs.values()]) {
241
- if (s.ch === msg.ch && s.group === group) s.handler(msg.data)
342
+ if (s.ch !== msg.ch || s.group !== group) continue
343
+ if (s.computed) handOn(s, msg.data)
344
+ else s.handler(msg.data)
242
345
  }
243
346
  break
244
347
  }
@@ -293,6 +396,32 @@ export const __channel = (serverUrl: string, id: string) => ({
293
396
  publish(): never {
294
397
  throw new RpcError(0, `Channel ${id}: the server publishes, the app subscribes — call a server function that publishes`)
295
398
  },
399
+ members(): never {
400
+ throw new RpcError(0, `Channel ${id}: members() is read on the server — call a server function that reads it`)
401
+ },
402
+ })
403
+
404
+ // ───────────────────────────── computeds ─────────────────────────────
405
+
406
+ /**
407
+ * Build the client proxy of computed `id` — what a `computed()` export of a `*.server.ts` file is in the
408
+ * app (its type is the server's: src/server/computed.ts). Called, it is an endpoint call;
409
+ * `subscribe([...args,] handler, { error? })` hears its value now (the `ok`) and on every change (an `ev`
410
+ * to the subscription's own group), over the channel socket.
411
+ */
412
+ export const __computed = (serverUrl: string, id: string) => Object.assign(__rpc(serverUrl, id), {
413
+ subscribe(...all: unknown[]): ChannelSubscription {
414
+ if (!serverUrl) throw noServer(id)
415
+ const at = all.findIndex(a => typeof a === "function")
416
+ if (at < 0) throw new RpcError(0, `Computed ${id}: subscribe([...args,] handler) — no handler given`)
417
+ const args = all.slice(0, at)
418
+ while (args.length && args[args.length - 1] === undefined) args.pop()
419
+ const options = (all[at + 1] as ComputedOptions | undefined) ?? {}
420
+ return socketFor(serverUrl).subscribe(id, args, all[at] as (value: any) => void, { error: options.error as Options["error"] }, true)
421
+ },
422
+ watchers(): never {
423
+ throw new RpcError(0, `Computed ${id}: watchers() is read on the server — call a server function that reads it`)
424
+ },
296
425
  })
297
426
 
298
427
  // ───────────────────────────── push channels ─────────────────────────────
@@ -7,15 +7,20 @@
7
7
  * then DROPS the socket's subscriptions (they were granted to someone else) and the
8
8
  * client sends them again, under new ids.
9
9
  * → sub subscribe to channel `ch`; `args` are what the app passed to `subscribe` before the
10
- * handler — the channel's `groupBy` reads them.
10
+ * handler — the channel's `groupBy` reads them. With `computed: true`, to the computed
11
+ * `ch` (../server/computed.ts): `args` are its arguments.
11
12
  * → unsub
12
13
  * ← session a token minted while the socket was served (the first contact of a guest): the client
13
14
  * stores it; nothing is dropped, the server already has it.
14
15
  * ← ok the subscription stands; `group` is where the server put it (null = a channel without
15
- * groups). The client learns its group only here — it never names one.
16
- * ← err refused (a hook threw) — the subscription is gone.
16
+ * groups). The client learns its group only here — it never names one. A computed's
17
+ * group is a number the server made for that one subscription, and `data` its value.
18
+ * ← err refused (a hook threw) — the subscription is gone. For a computed: refused when it comes
19
+ * before the `ok` (the first run threw); after it, a later run failed and the
20
+ * subscription stands.
17
21
  * ← ev one published message, addressed by channel + group so the server sends one frame to
18
- * every socket of a group; the client hands it to each of its subscriptions there.
22
+ * every socket of a group; the client hands it to each of its subscriptions there. For a
23
+ * computed: its next value, sent only when it differs from the last one.
19
24
  *
20
25
  * A group's value is not a secret: its subscriber is told it.
21
26
  */
@@ -25,11 +30,11 @@ export type ChannelGroup = string | number
25
30
 
26
31
  export type ClientFrame =
27
32
  | { t: "hello", session: string | null }
28
- | { t: "sub", id: number, ch: string, args: unknown[] }
33
+ | { t: "sub", id: number, ch: string, args: unknown[], computed?: true }
29
34
  | { t: "unsub", id: number }
30
35
 
31
36
  export type ServerFrame =
32
37
  | { t: "session", session: string }
33
- | { t: "ok", id: number, group: ChannelGroup | null }
38
+ | { t: "ok", id: number, group: ChannelGroup | null, data?: unknown }
34
39
  | { t: "err", id: number, status: number, message: string }
35
40
  | { t: "ev", ch: string, group: ChannelGroup | null, data: unknown }
@@ -22,6 +22,11 @@
22
22
  * `globalThis.__lecodesPublish` seam the host installs (same reason as ./context.ts: the bundle
23
23
  * carries its own SDK copy). In the app the export is another object altogether — the proxy
24
24
  * `__channel` of src/runtime/rpc.ts — which is why `subscribe` here only throws.
25
+ *
26
+ * Presence: `roomChannel.members(code)` (and a computed's `watchers(...args)`, ./computed.ts) — the
27
+ * sessions subscribed to a group now. The hub (./channelHub.ts) holds that table; the read goes to it
28
+ * through the `globalThis.__lecodesPresence` seam the host installs (./computedHost.ts
29
+ * `setPresenceProvider`), keyed by the export and the subscription's key as JSON (`presenceKey`).
25
30
  */
26
31
 
27
32
  import type { ChannelGroup } from "../runtime/wire"
@@ -49,6 +54,9 @@ export interface Channel<M> {
49
54
  groupBy<K extends ChannelGroup, A extends unknown[] = []>(group: (...args: A) => K | Promise<K>): GroupedChannel<M, K, A>
50
55
  /** Server: send `message` to every subscriber (at most once — nothing is kept for a socket that is away). */
51
56
  publish(message: M): void
57
+ /** Server: the sessions (`request.session`) subscribed now, each once. Inside a `computed`, a join or a
58
+ * leave runs it again. */
59
+ members(): Promise<number[]>
52
60
  /** App: listen until `close()`. */
53
61
  subscribe(handler: (message: M) => void, options?: SubscribeOptions): ChannelSubscription
54
62
  }
@@ -57,6 +65,9 @@ export interface Channel<M> {
57
65
  export interface GroupedChannel<M, K extends ChannelGroup, A extends unknown[]> {
58
66
  /** Server: send `message` to the subscribers of `group`. */
59
67
  publish(group: K, message: M): void
68
+ /** Server: the sessions (`request.session`) subscribed to `group` now, each once. Inside a `computed`,
69
+ * a join or a leave runs it again. */
70
+ members(group: K): Promise<number[]>
60
71
  /** App: listen until `close()`; the arguments before the handler go to the channel's `groupBy`. */
61
72
  subscribe(...args: [...args: A, handler: (message: M) => void, options?: SubscribeOptions]): ChannelSubscription
62
73
  }
@@ -81,6 +92,22 @@ export const setChannelPublisher = (publisher: Publisher | null) => {
81
92
  else delete seam.__lecodesPublish
82
93
  }
83
94
 
95
+ /** What a presence read asks the host: the sessions subscribed to export `id` under `key` (`presenceKey`). */
96
+ export type PresenceAsk = (id: string, key: string) => Promise<number[]>
97
+ const presenceSeam = globalThis as unknown as { __lecodesPresence?: PresenceAsk }
98
+
99
+ /** The key a subscription is present under, the hub's and the reads' alike: a computed's arguments, a
100
+ * channel's group (null without groups) — as JSON. */
101
+ export const presenceKey = (what: unknown): string => JSON.stringify(what === undefined ? null : what)
102
+
103
+ /** @internal `members` / `watchers` of export `id` (null = not registered): the host's answer. */
104
+ export const askPresence = async (what: string, id: string | null, key: unknown): Promise<number[]> => {
105
+ if (!id) throw new Error(`${what}: not registered — export it from a *.server.ts file`)
106
+ const ask = presenceSeam.__lecodesPresence
107
+ if (!ask) throw new Error(`${what}: no host presence installed`)
108
+ return ask(id, presenceKey(key))
109
+ }
110
+
84
111
  export const isChannelGroup = (v: unknown): v is ChannelGroup =>
85
112
  typeof v === "string" || (typeof v === "number" && Number.isFinite(v))
86
113
 
@@ -113,6 +140,11 @@ export const channel = <M = unknown>(): Channel<M> => {
113
140
  if (!isChannelGroup(args[0])) throw new Error(`channel.publish: a group is a string or a number, got ${args[0] === null ? "null" : typeof args[0]}`)
114
141
  deliver(args[0], args[1])
115
142
  },
143
+ async members(...args: unknown[]) {
144
+ if (!ch.__groupBy) return askPresence("channel.members", ch.__id, null)
145
+ if (!isChannelGroup(args[0])) throw new Error(`channel.members: this channel has groups — members(group), a string or a number, got ${args[0] === null ? "null" : typeof args[0]}`)
146
+ return askPresence("channel.members", ch.__id, args[0])
147
+ },
116
148
  subscribe() {
117
149
  throw new Error("channel.subscribe: the app subscribes, the server publishes")
118
150
  },