lecodes-sdk 2.6.0 → 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 (40) hide show
  1. package/dist/global.d.ts +7 -7
  2. package/dist/types/inject.d.ts +1 -1
  3. package/dist/types/runtime/firstCall.d.ts +12 -0
  4. package/dist/types/runtime/rpc.d.ts +24 -0
  5. package/dist/types/runtime/wire.d.ts +11 -4
  6. package/dist/types/server/channel.d.ts +16 -0
  7. package/dist/types/server/computed.d.ts +59 -0
  8. package/dist/types/server/context.d.ts +13 -1
  9. package/dist/types/server/db/fields.d.ts +2 -1
  10. package/dist/types/server/db/marci/query.d.ts +53 -6
  11. package/dist/types/server/inject.d.ts +2 -0
  12. package/dist/types/server/ordered.d.ts +18 -0
  13. package/dist/types/version.d.ts +1 -1
  14. package/dist/types.json +1 -1
  15. package/package.json +2 -2
  16. package/src/compile/serverSplit.ts +8 -4
  17. package/src/compile/serverTypes.ts +17 -3
  18. package/src/inject.ts +3 -2
  19. package/src/runtime/firstCall.ts +28 -0
  20. package/src/runtime/rpc.ts +141 -12
  21. package/src/runtime/wire.ts +11 -6
  22. package/src/server/channel.ts +32 -0
  23. package/src/server/channelHub.ts +246 -16
  24. package/src/server/clock.ts +50 -0
  25. package/src/server/computed.ts +84 -0
  26. package/src/server/computedHost.ts +413 -0
  27. package/src/server/context.ts +14 -2
  28. package/src/server/db/defineDb.ts +47 -7
  29. package/src/server/db/fields.ts +2 -1
  30. package/src/server/db/httpTransport.ts +37 -2
  31. package/src/server/db/httpWatch.ts +180 -0
  32. package/src/server/db/marci/query.ts +112 -7
  33. package/src/server/db/watch.ts +160 -0
  34. package/src/server/files/host.ts +2 -1
  35. package/src/server/host.ts +19 -4
  36. package/src/server/inject.ts +6 -2
  37. package/src/server/ordered.ts +41 -0
  38. package/src/server/rateLimit.ts +72 -0
  39. package/src/server/runtime.ts +127 -9
  40. 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.6.0",
46
+ "version": "2.8.0",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -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/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
  },