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.
- package/dist/global.d.ts +7 -7
- 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/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +2 -2
- package/src/compile/serverSplit.ts +8 -4
- package/src/compile/serverTypes.ts +17 -3
- 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/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.
|
|
43
|
+
"marcidb-embedded": "^0.18.0",
|
|
44
44
|
"sharp": "^0.35.2"
|
|
45
45
|
},
|
|
46
|
-
"version": "2.
|
|
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 `
|
|
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; `
|
|
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
|
-
|
|
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
|
+
}
|
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
|
},
|