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.
- package/dist/global.d.ts +7 -7
- package/dist/types/gl/Foliage.d.ts +6 -0
- package/dist/types/inject.d.ts +1 -1
- package/dist/types/runtime/firstCall.d.ts +12 -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/UIBottomSheet.d.ts +21 -14
- package/dist/types/ui/UIModal.d.ts +50 -22
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/src/bridges/gl.d.ts +4 -1
- package/src/chisel.ts +1 -1
- package/src/compile/compileProject.ts +7 -2
- package/src/compile/serverSplit.ts +8 -4
- package/src/compile/serverTypes.ts +17 -3
- package/src/gl/Foliage.ts +11 -1
- package/src/inject.ts +3 -2
- package/src/runtime/firstCall.ts +28 -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/UIBottomSheet.ts +39 -30
- package/src/ui/UIModal.ts +128 -31
- package/src/ui/UIWidget.ts +21 -8
- package/src/version.ts +1 -1
package/src/server/channelHub.ts
CHANGED
|
@@ -19,12 +19,43 @@
|
|
|
19
19
|
* for the new one. A socket's frames are served ONE AT A TIME, in the order they came: a `hello`
|
|
20
20
|
* never overtakes the subscription sent before it, and two subscriptions of a guest do not mint two
|
|
21
21
|
* sessions.
|
|
22
|
+
*
|
|
23
|
+
* A computed subscription (a `sub` frame with `computed: true`, ./computed.ts) goes the same way, with
|
|
24
|
+
* a number the hub mints for it alone: `subscribe(scope, id, args, ctx, sub)` runs it where the bundle
|
|
25
|
+
* lives and answers its value (the `ok` carries it), the bundle's side keeps it fresh and publishes each
|
|
26
|
+
* new value as an envelope to (`id`, `sub`) — the host's `hub.publish`, as a channel's — and
|
|
27
|
+
* `unsubscribe(scope, sub)` tells it the subscription is over (an unsub, a closed socket, another
|
|
28
|
+
* session). The hub sends a value only when it differs from the last one sent (JSON), and a host whose
|
|
29
|
+
* bundle starts in a new process (a deploy, a cold start) calls `hub.rerun(scope)`: every live computed
|
|
30
|
+
* subscription of it runs there again, in its socket's turn. A first run that fails is a refusal (`err`,
|
|
31
|
+
* the subscription is gone); a later one that fails is an `err` too, and the subscription stands.
|
|
32
|
+
*
|
|
33
|
+
* Presence: a subscription is presence. The hub keeps who is subscribed where — (scope, export id, key)
|
|
34
|
+
* → sessions, the key a computed's arguments or a channel's group as JSON (./channel.ts `presenceKey`) —
|
|
35
|
+
* and answers `presence(scope, id, key)`: what `watchers` / `members` read in the bundle, asked over the
|
|
36
|
+
* host's IPC. A subscription's session is its subscriber's `request.session`, which the bundle's side
|
|
37
|
+
* decides: the envelope of its guard / first run names it (`subscriber`); one without a session is not
|
|
38
|
+
* listed. A session is listed once however many subscriptions it has there (two screens, two sockets).
|
|
39
|
+
* A leave is not at once: a session whose last subscription of a key ended (an unsub, a closed socket,
|
|
40
|
+
* a `hello` with another session) stays listed for PRESENCE_GRACE_MS, and is gone only if none of its
|
|
41
|
+
* subscriptions of that key came back meanwhile — a socket that drops on a Wi-Fi → LTE switch and
|
|
42
|
+
* comes back does not flicker. When the sessions of a key change (a join at once, a leave after its
|
|
43
|
+
* grace), `presenceChanged(scope, id, key)` tells the bundle's side, which runs the computeds that read
|
|
44
|
+
* them again. The table is the hub's, in the host's main process: a new worker loses nothing of it.
|
|
45
|
+
*
|
|
46
|
+
* A session opens at most RATE_LIMITS.subscribe subscriptions (./rateLimit.ts) — a `sub` past it is refused
|
|
47
|
+
* with an `err` of status 429, on every host alike.
|
|
22
48
|
*/
|
|
23
49
|
|
|
50
|
+
import { presenceKey } from "./channel"
|
|
51
|
+
import { callerKey, createRateLimiter, RATE_LIMITS, TOO_MANY_CALLS, type RateLimiter } from "./rateLimit"
|
|
24
52
|
import type { RequestContext } from "./context"
|
|
25
53
|
import type { InvokeResult } from "./runtime"
|
|
26
54
|
import type { ChannelGroup, ServerFrame } from "../runtime/wire"
|
|
27
55
|
|
|
56
|
+
/** How long a session that left a key stays listed (ms): a socket that comes back within it is no leave. */
|
|
57
|
+
export const PRESENCE_GRACE_MS = 5_000
|
|
58
|
+
|
|
28
59
|
type Ctx = Omit<RequestContext, "id">
|
|
29
60
|
|
|
30
61
|
/** Where a group's frames go. The default keeps the sockets of a key in a set; a host whose
|
|
@@ -38,8 +69,20 @@ export type HubFanout<S> = {
|
|
|
38
69
|
|
|
39
70
|
export type ChannelHubOptions<S> = {
|
|
40
71
|
send: (socket: S, frame: string) => void
|
|
41
|
-
/** Run the hooks of channel `ch` (createSubscribeGuard, wherever the bundle lives): `result` = the group.
|
|
42
|
-
|
|
72
|
+
/** Run the hooks of channel `ch` (createSubscribeGuard, wherever the bundle lives): `result` = the group.
|
|
73
|
+
* With `sub`: start computed `ch` for that subscription (createComputedHost's `start`): `result` = its value. */
|
|
74
|
+
subscribe: (scope: string, ch: string, args: unknown[], ctx: Ctx, sub?: number) => Promise<InvokeResult>
|
|
75
|
+
/** A computed subscription is over: the bundle's side lets it go (createComputedHost's `end`). Without
|
|
76
|
+
* it the hub serves channels only, and a computed subscription is refused. */
|
|
77
|
+
unsubscribe?: (scope: string, sub: number) => void
|
|
78
|
+
/** The sessions subscribed to (`id`, `key`) changed — a join (at once) or a leave (after its grace): the
|
|
79
|
+
* bundle's side runs the computeds that read them again (createComputedHost's `presenceChanged`). */
|
|
80
|
+
presenceChanged?: (scope: string, id: string, key: string) => void
|
|
81
|
+
/** How long a leave waits (default PRESENCE_GRACE_MS; a test's knob). */
|
|
82
|
+
graceMs?: number
|
|
83
|
+
/** How often a session may subscribe (default RATE_LIMITS.subscribe, per scope and session — ./rateLimit.ts;
|
|
84
|
+
* `null`: no limit). A test's knob. */
|
|
85
|
+
subscribeLimiter?: RateLimiter | null
|
|
43
86
|
fanout?: HubFanout<S>
|
|
44
87
|
}
|
|
45
88
|
|
|
@@ -49,11 +92,19 @@ export type HubConnection = {
|
|
|
49
92
|
}
|
|
50
93
|
|
|
51
94
|
export type ChannelHub<S> = {
|
|
52
|
-
|
|
95
|
+
/** `filesBase`: what a stored file's url starts with for this socket (RequestContext's). */
|
|
96
|
+
connect(socket: S, info: { scope?: string, ip?: string, session?: string | null, filesBase?: string }): HubConnection
|
|
97
|
+
/** A channel's message to a group — or, for a computed subscription's (`ch`, `sub`), its next envelope. */
|
|
53
98
|
publish(scope: string, ch: string, group: ChannelGroup | null, message: unknown): void
|
|
99
|
+
/** The bundle of `scope` runs in a new process: its live computed subscriptions run there again. */
|
|
100
|
+
rerun(scope: string): Promise<void>
|
|
101
|
+
/** Who is subscribed to export `id` under `key` (`presenceKey`) in `scope` now: their sessions, each once,
|
|
102
|
+
* in the order they came — one that left counts until its grace is over. */
|
|
103
|
+
presence(scope: string, id: string, key: string): number[]
|
|
54
104
|
}
|
|
55
105
|
|
|
56
106
|
const keyOf = (scope: string, ch: string, group: ChannelGroup | null) => `${scope}|${ch}|${JSON.stringify(group)}`
|
|
107
|
+
const tableKey = (scope: string, id: string, key: string) => `${scope}|${id}|${key}`
|
|
57
108
|
|
|
58
109
|
const setFanout = <S>(send: (socket: S, frame: string) => void): HubFanout<S> => {
|
|
59
110
|
const sockets = new Map<string, Set<S>>()
|
|
@@ -73,25 +124,143 @@ const setFanout = <S>(send: (socket: S, frame: string) => void): HubFanout<S> =>
|
|
|
73
124
|
}
|
|
74
125
|
}
|
|
75
126
|
|
|
127
|
+
/** One socket, as the computed subscriptions on it need it after its frames are served. */
|
|
128
|
+
type Conn<S> = {
|
|
129
|
+
scope: string
|
|
130
|
+
socket: S
|
|
131
|
+
ip?: string
|
|
132
|
+
filesBase?: string
|
|
133
|
+
session: string | null
|
|
134
|
+
closed: boolean
|
|
135
|
+
/** Run `task` in this socket's turn (after the frames before it). */
|
|
136
|
+
enqueue(task: () => Promise<void>): Promise<void>
|
|
137
|
+
}
|
|
138
|
+
type Computed<S> = {
|
|
139
|
+
conn: Conn<S>
|
|
140
|
+
ch: string
|
|
141
|
+
args: unknown[]
|
|
142
|
+
/** the client's id of the subscription */
|
|
143
|
+
id: number
|
|
144
|
+
/** the hub's: its group */
|
|
145
|
+
sub: number
|
|
146
|
+
/** the JSON of the last value sent */
|
|
147
|
+
last: string | undefined
|
|
148
|
+
/** a run of it is on its way (the first, a rerun): what is published meanwhile is newer, and waits */
|
|
149
|
+
starting: boolean
|
|
150
|
+
queued: InvokeResult | null
|
|
151
|
+
/** the session it is present as (its runs say so), once its first run is answered */
|
|
152
|
+
here: number | undefined
|
|
153
|
+
}
|
|
154
|
+
/** A session under one key: how many of its subscriptions are there; with none, the timer of its leave. */
|
|
155
|
+
type Holder = { n: number, leaving: ReturnType<typeof setTimeout> | null }
|
|
156
|
+
|
|
157
|
+
const ctxOf = <S>(conn: Conn<S>): Ctx => ({
|
|
158
|
+
headers: conn.session ? { authorization: `Bearer ${conn.session}` } : {},
|
|
159
|
+
ip: conn.ip,
|
|
160
|
+
sessionToken: conn.session ?? undefined,
|
|
161
|
+
...(conn.filesBase !== undefined ? { filesBase: conn.filesBase } : {}),
|
|
162
|
+
})
|
|
163
|
+
|
|
76
164
|
export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> => {
|
|
77
165
|
const fanout = opts.fanout ?? setFanout(opts.send)
|
|
78
166
|
const frame = (f: ServerFrame) => JSON.stringify(f)
|
|
167
|
+
/** live computed subscriptions, by the hub's number */
|
|
168
|
+
const computeds = new Map<number, Computed<S>>()
|
|
169
|
+
const conns = new Set<Conn<S>>()
|
|
170
|
+
let seq = 0
|
|
171
|
+
const grace = opts.graceMs ?? PRESENCE_GRACE_MS
|
|
172
|
+
const subscribeLimiter = opts.subscribeLimiter === undefined ? createRateLimiter(RATE_LIMITS.subscribe) : opts.subscribeLimiter
|
|
173
|
+
/** presence: tableKey → session → its holder, in the order the sessions came */
|
|
174
|
+
const table = new Map<string, Map<number, Holder>>()
|
|
175
|
+
|
|
176
|
+
const join = (scope: string, id: string, key: string, session: number) => {
|
|
177
|
+
const k = tableKey(scope, id, key)
|
|
178
|
+
let here = table.get(k)
|
|
179
|
+
if (!here) table.set(k, here = new Map())
|
|
180
|
+
const h = here.get(session)
|
|
181
|
+
if (h) {
|
|
182
|
+
// back within the grace (a new socket, a re-subscription): it never left
|
|
183
|
+
h.n++
|
|
184
|
+
if (h.leaving) { clearTimeout(h.leaving); h.leaving = null }
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
here.set(session, { n: 1, leaving: null })
|
|
188
|
+
opts.presenceChanged?.(scope, id, key)
|
|
189
|
+
}
|
|
190
|
+
const leave = (scope: string, id: string, key: string, session: number) => {
|
|
191
|
+
const k = tableKey(scope, id, key)
|
|
192
|
+
const here = table.get(k)
|
|
193
|
+
const h = here?.get(session)
|
|
194
|
+
if (!here || !h || --h.n > 0) return
|
|
195
|
+
h.leaving = setTimeout(() => {
|
|
196
|
+
if (here.get(session) !== h || h.n > 0) return
|
|
197
|
+
here.delete(session)
|
|
198
|
+
if (here.size === 0 && table.get(k) === here) table.delete(k)
|
|
199
|
+
opts.presenceChanged?.(scope, id, key)
|
|
200
|
+
}, grace)
|
|
201
|
+
;(h.leaving as { unref?: () => void }).unref?.()
|
|
202
|
+
}
|
|
203
|
+
/** a computed subscription is present as `session` from now on (a run said so) */
|
|
204
|
+
const presentAs = (c: Computed<S>, session: number | undefined) => {
|
|
205
|
+
if (session === undefined || session === c.here) return
|
|
206
|
+
const key = presenceKey(c.args)
|
|
207
|
+
join(c.conn.scope, c.ch, key, session)
|
|
208
|
+
if (c.here !== undefined) leave(c.conn.scope, c.ch, key, c.here)
|
|
209
|
+
c.here = session
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// a token minted by a run (the first contact of a guest): the socket is that session from now on
|
|
213
|
+
const adopt = (conn: Conn<S>, token: string) => {
|
|
214
|
+
conn.session = token
|
|
215
|
+
opts.send(conn.socket, frame({ t: "session", session: token }))
|
|
216
|
+
}
|
|
217
|
+
const deliver = (c: Computed<S>, r: InvokeResult) => {
|
|
218
|
+
if (c.starting) { c.queued = r; return }
|
|
219
|
+
if (!r.ok) { opts.send(c.conn.socket, frame({ t: "err", id: c.id, status: r.status, message: r.message })); return }
|
|
220
|
+
const data = r.result === undefined ? null : r.result
|
|
221
|
+
const json = JSON.stringify(data)
|
|
222
|
+
if (json === c.last) return
|
|
223
|
+
c.last = json
|
|
224
|
+
opts.send(c.conn.socket, frame({ t: "ev", ch: c.ch, group: c.sub, data }))
|
|
225
|
+
}
|
|
226
|
+
/** a run of `c` is back: its answer, then what was published while it ran */
|
|
227
|
+
const settle = (c: Computed<S>, r: InvokeResult) => {
|
|
228
|
+
c.starting = false
|
|
229
|
+
deliver(c, r)
|
|
230
|
+
const q = c.queued
|
|
231
|
+
c.queued = null
|
|
232
|
+
if (q) deliver(c, q)
|
|
233
|
+
}
|
|
79
234
|
|
|
80
235
|
return {
|
|
81
236
|
connect(socket, info) {
|
|
82
237
|
const scope = info.scope ?? ""
|
|
83
|
-
let session = info.session ?? null
|
|
84
|
-
let closed = false
|
|
85
238
|
let queue: Promise<void> = Promise.resolve()
|
|
86
|
-
|
|
87
|
-
|
|
239
|
+
const conn: Conn<S> = {
|
|
240
|
+
scope, socket, ip: info.ip, filesBase: info.filesBase, session: info.session ?? null, closed: false,
|
|
241
|
+
enqueue: (task) => (queue = queue.then(() => conn.closed ? undefined : task()).catch(() => {})),
|
|
242
|
+
}
|
|
243
|
+
conns.add(conn)
|
|
244
|
+
/** client sub id → fan-out key, or the hub's number of a computed subscription */
|
|
245
|
+
const subs = new Map<number, string | number>()
|
|
88
246
|
/** key → how many of this socket's subs are on it (the fan-out hears of the first and the last) */
|
|
89
247
|
const counts = new Map<string, number>()
|
|
248
|
+
/** client sub id of a channel subscription → where it is present, and as whom */
|
|
249
|
+
const held = new Map<number, { ch: string, key: string, session: number }>()
|
|
90
250
|
|
|
91
251
|
const drop = (id: number) => {
|
|
92
252
|
const key = subs.get(id)
|
|
93
253
|
if (key === undefined) return
|
|
94
254
|
subs.delete(id)
|
|
255
|
+
if (typeof key === "number") {
|
|
256
|
+
const c = computeds.get(key)
|
|
257
|
+
computeds.delete(key)
|
|
258
|
+
if (c?.here !== undefined) leave(scope, c.ch, presenceKey(c.args), c.here)
|
|
259
|
+
opts.unsubscribe?.(scope, key)
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
const p = held.get(id)
|
|
263
|
+
if (p) { held.delete(id); leave(scope, p.ch, p.key, p.session) }
|
|
95
264
|
const n = (counts.get(key) ?? 1) - 1
|
|
96
265
|
if (n > 0) { counts.set(key, n); return }
|
|
97
266
|
counts.delete(key)
|
|
@@ -99,24 +268,53 @@ export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> =
|
|
|
99
268
|
}
|
|
100
269
|
const dropAll = () => { for (const id of [...subs.keys()]) drop(id) }
|
|
101
270
|
|
|
271
|
+
/** A computed subscription: its first run where the bundle lives; the `ok` carries the value. */
|
|
272
|
+
const compute = async (id: number, ch: string, args: unknown[]) => {
|
|
273
|
+
if (!opts.unsubscribe) { opts.send(socket, frame({ t: "err", id, status: 404, message: `${ch}: this server keeps no computed values` })); return }
|
|
274
|
+
const c: Computed<S> = { conn, ch, args, id, sub: ++seq, last: undefined, starting: true, queued: null, here: undefined }
|
|
275
|
+
computeds.set(c.sub, c)
|
|
276
|
+
const r = await opts.subscribe(scope, ch, args, ctxOf(conn), c.sub)
|
|
277
|
+
if (conn.closed) { computeds.delete(c.sub); opts.unsubscribe(scope, c.sub); return }
|
|
278
|
+
if (r.session) adopt(conn, r.session)
|
|
279
|
+
if (!r.ok) {
|
|
280
|
+
// a first run that fails is a refusal: nothing is kept
|
|
281
|
+
computeds.delete(c.sub)
|
|
282
|
+
opts.unsubscribe(scope, c.sub)
|
|
283
|
+
opts.send(socket, frame({ t: "err", id, status: r.status, message: r.message }))
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
drop(id)
|
|
287
|
+
const data = r.result === undefined ? null : r.result
|
|
288
|
+
c.last = JSON.stringify(data)
|
|
289
|
+
opts.send(socket, frame({ t: "ok", id, group: c.sub, data }))
|
|
290
|
+
subs.set(id, c.sub)
|
|
291
|
+
presentAs(c, r.subscriber)
|
|
292
|
+
settle(c, { ok: true, result: data })
|
|
293
|
+
}
|
|
294
|
+
|
|
102
295
|
const serve = async (msg: any) => {
|
|
103
296
|
switch (msg?.t) {
|
|
104
297
|
case "hello": {
|
|
105
298
|
const next = typeof msg.session === "string" && msg.session ? msg.session : null
|
|
106
|
-
if (next === session) return
|
|
107
|
-
session = next
|
|
299
|
+
if (next === conn.session) return
|
|
300
|
+
conn.session = next
|
|
108
301
|
dropAll()
|
|
109
302
|
return
|
|
110
303
|
}
|
|
111
304
|
case "sub": {
|
|
112
305
|
const id = Number(msg.id), ch = String(msg.ch ?? "")
|
|
113
306
|
if (!Number.isFinite(id) || !ch) return
|
|
307
|
+
// a session subscribing too often is refused, the subscription with it (the client hears an RpcError(429))
|
|
308
|
+
if (subscribeLimiter && !subscribeLimiter.take(`${scope}|${callerKey(conn.session, conn.ip)}`)) {
|
|
309
|
+
opts.send(socket, frame({ t: "err", id, status: TOO_MANY_CALLS.status, message: TOO_MANY_CALLS.message }))
|
|
310
|
+
return
|
|
311
|
+
}
|
|
114
312
|
const args = Array.isArray(msg.args) ? msg.args : []
|
|
115
|
-
|
|
116
|
-
const r = await opts.subscribe(scope, ch, args,
|
|
117
|
-
if (closed) return
|
|
313
|
+
if (msg.computed === true) return compute(id, ch, args)
|
|
314
|
+
const r = await opts.subscribe(scope, ch, args, ctxOf(conn))
|
|
315
|
+
if (conn.closed) return
|
|
118
316
|
// a token minted by the hooks (the first contact of a guest) → the client stores it
|
|
119
|
-
if (r.session)
|
|
317
|
+
if (r.session) adopt(conn, r.session)
|
|
120
318
|
if (!r.ok) { opts.send(socket, frame({ t: "err", id, status: r.status, message: r.message })); return }
|
|
121
319
|
const group = (r.result ?? null) as ChannelGroup | null
|
|
122
320
|
const key = keyOf(scope, ch, group)
|
|
@@ -125,6 +323,11 @@ export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> =
|
|
|
125
323
|
subs.set(id, key)
|
|
126
324
|
counts.set(key, (counts.get(key) ?? 0) + 1)
|
|
127
325
|
if (counts.get(key) === 1) fanout.subscribe(socket, key)
|
|
326
|
+
if (r.subscriber !== undefined) {
|
|
327
|
+
const p = { ch, key: presenceKey(group), session: r.subscriber }
|
|
328
|
+
held.set(id, p)
|
|
329
|
+
join(scope, ch, p.key, p.session)
|
|
330
|
+
}
|
|
128
331
|
return
|
|
129
332
|
}
|
|
130
333
|
case "unsub":
|
|
@@ -137,17 +340,44 @@ export const createChannelHub = <S>(opts: ChannelHubOptions<S>): ChannelHub<S> =
|
|
|
137
340
|
message(raw) {
|
|
138
341
|
let msg: unknown
|
|
139
342
|
try { msg = JSON.parse(raw) } catch { return queue }
|
|
140
|
-
|
|
141
|
-
return queue
|
|
343
|
+
return conn.enqueue(() => serve(msg))
|
|
142
344
|
},
|
|
143
345
|
close() {
|
|
144
|
-
closed = true
|
|
346
|
+
conn.closed = true
|
|
347
|
+
conns.delete(conn)
|
|
145
348
|
dropAll()
|
|
146
349
|
},
|
|
147
350
|
}
|
|
148
351
|
},
|
|
149
352
|
publish(scope, ch, group, message) {
|
|
353
|
+
const c = typeof group === "number" ? computeds.get(group) : undefined
|
|
354
|
+
if (c && c.conn.scope === scope && c.ch === ch) { deliver(c, message as InvokeResult); return }
|
|
150
355
|
fanout.publish(keyOf(scope, ch, group), frame({ t: "ev", ch, group, data: message === undefined ? null : message }))
|
|
151
356
|
},
|
|
357
|
+
async rerun(scope) {
|
|
358
|
+
const runs: Promise<void>[] = []
|
|
359
|
+
for (const conn of conns) {
|
|
360
|
+
if (conn.scope !== scope) continue
|
|
361
|
+
runs.push(conn.enqueue(async () => {
|
|
362
|
+
// one after another on a socket, in its turn: a first run still ahead in the queue is done by
|
|
363
|
+
// now (and runs again here — it may have run in the old process), and a token the first one
|
|
364
|
+
// mints is the next ones' session
|
|
365
|
+
for (const c of [...computeds.values()]) {
|
|
366
|
+
if (c.conn !== conn || c.starting) continue
|
|
367
|
+
c.starting = true
|
|
368
|
+
let r: InvokeResult
|
|
369
|
+
try { r = await opts.subscribe(scope, c.ch, c.args, ctxOf(conn), c.sub) } catch (e) { r = { ok: false, status: 500, message: (e as Error)?.message ?? "Internal error" } }
|
|
370
|
+
if (conn.closed || computeds.get(c.sub) !== c) continue
|
|
371
|
+
if (r.session) adopt(conn, r.session)
|
|
372
|
+
if (r.ok) presentAs(c, r.subscriber)
|
|
373
|
+
settle(c, r)
|
|
374
|
+
}
|
|
375
|
+
}))
|
|
376
|
+
}
|
|
377
|
+
await Promise.all(runs)
|
|
378
|
+
},
|
|
379
|
+
presence(scope, id, key) {
|
|
380
|
+
return [...table.get(tableKey(scope, id, key))?.keys() ?? []]
|
|
381
|
+
},
|
|
152
382
|
}
|
|
153
383
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The server's clock under a test: `setServerClock(offsetMs)` moves `Date.now()`, `new Date()` (no
|
|
3
|
+
* arguments), `Date()` and `performance.now()` of this process that many ms ahead of the machine's clock
|
|
4
|
+
* — the backend worker of `lecodes test`, where a case has one clock: a flow's `wait` and `clock` steps
|
|
5
|
+
* move the apps' virtual clocks and this one together (tools/cli/src/dev/backendWorker.ts, set over the
|
|
6
|
+
* dev backend's IPC). The clock keeps running in real time; only its reading is shifted, and the offset
|
|
7
|
+
* only grows (the test runner never moves it back), so the server's time never goes backwards.
|
|
8
|
+
*
|
|
9
|
+
* Host-side, process-wide on purpose: the server bundle is evaluated in this process and reads the
|
|
10
|
+
* global `Date` at call time, so one swap covers the bundle, the SDK's host code (sessions, codes) and
|
|
11
|
+
* every module alike. Installed on the first call; an offset of 0 reads the machine's clock as before.
|
|
12
|
+
* Timers are not touched: a deadline is data, and a `setTimeout` on the server is not a scheduler.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
let offset = 0
|
|
16
|
+
let installed = false
|
|
17
|
+
|
|
18
|
+
const install = () => {
|
|
19
|
+
if (installed) return
|
|
20
|
+
installed = true
|
|
21
|
+
const g = globalThis as unknown as { Date: DateConstructor, performance?: { now(): number } }
|
|
22
|
+
const Real = g.Date
|
|
23
|
+
// whole ms, as the real one reads: a date field takes an integer
|
|
24
|
+
const now = () => Math.floor(Real.now() + offset)
|
|
25
|
+
// A function, not a class: `Date()` called without `new` answers a string, as the real one does.
|
|
26
|
+
function ShiftedDate(this: unknown, ...args: unknown[]) {
|
|
27
|
+
if (!new.target) return new Real(now()).toString()
|
|
28
|
+
return Reflect.construct(Real, args.length === 0 ? [now()] : args, new.target)
|
|
29
|
+
}
|
|
30
|
+
Object.setPrototypeOf(ShiftedDate, Real) // Date.UTC, Date.parse, … are the real ones
|
|
31
|
+
ShiftedDate.prototype = Real.prototype // `instanceof Date` holds both ways
|
|
32
|
+
Object.defineProperty(ShiftedDate, "now", { value: now, writable: true, configurable: true })
|
|
33
|
+
Object.defineProperty(ShiftedDate, "name", { value: "Date" })
|
|
34
|
+
g.Date = ShiftedDate as unknown as DateConstructor
|
|
35
|
+
const perf = g.performance
|
|
36
|
+
if (perf && typeof perf.now === "function") {
|
|
37
|
+
const realNow = perf.now.bind(perf)
|
|
38
|
+
try { Object.defineProperty(perf, "now", { value: () => realNow() + offset, writable: true, configurable: true }) } catch { /* a frozen performance: Date is enough */ }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read the clock this many ms ahead of the machine's from now on (a test's case clock). */
|
|
43
|
+
export const setServerClock = (offsetMs: number): void => {
|
|
44
|
+
if (!Number.isFinite(offsetMs)) throw new Error(`setServerClock: ${offsetMs} is not a number of ms`)
|
|
45
|
+
if (offsetMs !== 0) install()
|
|
46
|
+
offset = offsetMs
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The offset the clock reads with now (ms). */
|
|
50
|
+
export const serverClockOffset = (): number => offset
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `computed(fn)` — a server function the runtime keeps fresh (server global). The app calls it once,
|
|
3
|
+
* like an endpoint, or subscribes to it: the value now, and again whenever the data it read changes.
|
|
4
|
+
*
|
|
5
|
+
* export const roomView = computed(async (code: string) => { // game.server.ts
|
|
6
|
+
* const room = await db.room.where({ code }).first()
|
|
7
|
+
* const players = await db.player.where({ room: code }).select({ name: true, device: true })
|
|
8
|
+
* return { room, players, me: request.session }
|
|
9
|
+
* })
|
|
10
|
+
*
|
|
11
|
+
* const sub = roomView.subscribe(code, (view) => { room.value = view }) // app: now, then on every change
|
|
12
|
+
* sub.close()
|
|
13
|
+
* const view = await roomView(code) // app: one call
|
|
14
|
+
*
|
|
15
|
+
* The app's `computed` (src/core/signals.ts) is the same idea: a value derived from its inputs, made
|
|
16
|
+
* again when they change. The app's inputs are signals; the server's are the database's rows. Hence its
|
|
17
|
+
* two rules: it only READS (a write inside is an error), and its value follows from its data and its
|
|
18
|
+
* arguments alone (`Math.random()` inside = a value that is new at every run).
|
|
19
|
+
*
|
|
20
|
+
* How it stays fresh (./computedHost.ts): a subscription runs `fn` in the subscriber's request scope
|
|
21
|
+
* (`request.session`, `db.auth`), and every read the db client sends meanwhile becomes a WATCH of the
|
|
22
|
+
* database (./db/watch.ts). When the database says a watched result may have changed, `fn` runs again —
|
|
23
|
+
* once for any number of signals during a run — and the subscriber hears the value only when it differs
|
|
24
|
+
* from the last one (JSON equality). The subscription rides the channel socket (a `sub` frame with
|
|
25
|
+
* `computed: true`, ../runtime/wire.ts); the hub (./channelHub.ts) holds it across a new worker (a deploy,
|
|
26
|
+
* an eviction) and runs it there again.
|
|
27
|
+
*
|
|
28
|
+
* Identity, as a channel's: the export (`<path>#<name>`), set by the runtime when the bundle is loaded
|
|
29
|
+
* (`loadServerModules`). In the bundle the export is callable — another server function may
|
|
30
|
+
* `await roomView(code)`, its reads are then that function's own — and `subscribe` only throws; in the
|
|
31
|
+
* app it is the proxy `__computed` of src/runtime/rpc.ts.
|
|
32
|
+
*
|
|
33
|
+
* Who is here: `roomView.watchers(code)` — the sessions subscribed to it with these arguments now (the
|
|
34
|
+
* hub's table, read as a channel's `members` is: ./channel.ts `askPresence`). A read, not a hook: inside
|
|
35
|
+
* a computed it is one more input, and a join or a leave there runs that computed again (./computedHost.ts).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { askPresence, type ChannelSubscription } from "./channel"
|
|
39
|
+
|
|
40
|
+
export type ComputedOptions = {
|
|
41
|
+
/** The server refused the subscription (its first run threw), or a later run failed: an `RpcError`,
|
|
42
|
+
* its `status` the thrown one. Without it the failure is logged. A refused subscription is asked
|
|
43
|
+
* again when the session changes; after a failed later run the last value stands and the next change
|
|
44
|
+
* runs it again. */
|
|
45
|
+
error?: (e: Error & { readonly status: number }) => void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A server function the runtime keeps fresh: `computed(async (…args) => value)`. */
|
|
49
|
+
export interface ServerComputed<A extends unknown[], R> {
|
|
50
|
+
/** One call, as an endpoint's. */
|
|
51
|
+
(...args: A): Promise<R>
|
|
52
|
+
/** App: `handler` gets the value now, and again each time it changes, until `close()`; the arguments
|
|
53
|
+
* before it are the function's. */
|
|
54
|
+
subscribe(...args: [...args: A, handler: (value: R) => void, options?: ComputedOptions]): ChannelSubscription
|
|
55
|
+
/** Server: the sessions (`request.session`) subscribed to it with these arguments now, each once — the
|
|
56
|
+
* arguments as the app passed them, matched as JSON. Inside a `computed`, a join or a leave runs it again. */
|
|
57
|
+
watchers(...args: A): Promise<number[]>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @internal what the host reads of a computed (./runtime.ts, ./computedHost.ts) */
|
|
61
|
+
export type ComputedRecord = ((...args: unknown[]) => Promise<unknown>) & {
|
|
62
|
+
/** set by the runtime: `<path>#<export>` */
|
|
63
|
+
__id: string | null
|
|
64
|
+
readonly __computed: true
|
|
65
|
+
readonly __fn: (...args: unknown[]) => Promise<unknown>
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export const computed = <A extends unknown[], R>(fn: (...args: A) => Promise<R>): ServerComputed<A, R> => {
|
|
69
|
+
if (typeof fn !== "function") throw new Error("computed(fn): fn is the async function that reads the value")
|
|
70
|
+
const c = ((...args: A) => fn(...args)) as unknown as Record<string, unknown>
|
|
71
|
+
c.__id = null
|
|
72
|
+
c.__computed = true
|
|
73
|
+
c.__fn = fn
|
|
74
|
+
c.subscribe = () => { throw new Error("computed.subscribe: the app subscribes — in server code, call it: await view(...args)") }
|
|
75
|
+
c.watchers = async (...args: unknown[]) => {
|
|
76
|
+
// the app's transport leaves out the trailing arguments that are undefined: so does the key
|
|
77
|
+
while (args.length && args[args.length - 1] === undefined) args.pop()
|
|
78
|
+
return askPresence("computed.watchers", c.__id as string | null, args)
|
|
79
|
+
}
|
|
80
|
+
return c as unknown as ServerComputed<A, R>
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const isComputed = (v: unknown): v is ComputedRecord =>
|
|
84
|
+
typeof v === "function" && (v as Partial<ComputedRecord>).__computed === true
|