lecodes-sdk 2.6.0 → 2.8.1
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/dist/global.d.ts +11 -7
- package/dist/types/inject.d.ts +2 -1
- package/dist/types/plugin.d.ts +1 -0
- package/dist/types/plugins/gen/ar/sdk/ar.d.ts +25 -5
- package/dist/types/runtime/firstCall.d.ts +12 -0
- package/dist/types/runtime/gestures.d.ts +52 -0
- package/dist/types/runtime/rpc.d.ts +24 -0
- package/dist/types/runtime/wire.d.ts +11 -4
- package/dist/types/server/channel.d.ts +16 -0
- package/dist/types/server/computed.d.ts +59 -0
- package/dist/types/server/context.d.ts +13 -1
- package/dist/types/server/db/fields.d.ts +2 -1
- package/dist/types/server/db/marci/query.d.ts +53 -6
- package/dist/types/server/inject.d.ts +2 -0
- package/dist/types/server/ordered.d.ts +18 -0
- package/dist/types/ui/UIModal.d.ts +5 -3
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/src/bridges/tree.d.ts +14 -4
- package/src/compile/serverSplit.ts +8 -4
- package/src/compile/serverTypes.ts +17 -3
- package/src/inject.ts +5 -2
- package/src/plugin.ts +3 -1
- package/src/plugins/gen/ar/sdk/ar.ts +59 -14
- package/src/runtime/firstCall.ts +28 -0
- package/src/runtime/gestures.ts +190 -0
- package/src/runtime/rpc.ts +141 -12
- package/src/runtime/wire.ts +11 -6
- package/src/server/channel.ts +32 -0
- package/src/server/channelHub.ts +246 -16
- package/src/server/clock.ts +50 -0
- package/src/server/computed.ts +84 -0
- package/src/server/computedHost.ts +413 -0
- package/src/server/context.ts +14 -2
- package/src/server/db/defineDb.ts +47 -7
- package/src/server/db/fields.ts +2 -1
- package/src/server/db/httpTransport.ts +37 -2
- package/src/server/db/httpWatch.ts +180 -0
- package/src/server/db/marci/query.ts +112 -7
- package/src/server/db/watch.ts +160 -0
- package/src/server/files/host.ts +2 -1
- package/src/server/host.ts +19 -4
- package/src/server/inject.ts +6 -2
- package/src/server/ordered.ts +41 -0
- package/src/server/rateLimit.ts +72 -0
- package/src/server/runtime.ts +127 -9
- package/src/ui/UIModal.ts +44 -9
- package/src/ui/UINode.ts +4 -0
- package/src/ui/tree.ts +8 -0
- package/src/version.ts +1 -1
package/src/runtime/rpc.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
200
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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(
|
|
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
|
|
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 ─────────────────────────────
|
package/src/runtime/wire.ts
CHANGED
|
@@ -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
|
-
*
|
|
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 }
|
package/src/server/channel.ts
CHANGED
|
@@ -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
|
},
|