lecodes-sdk 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/global.d.ts +7 -7
  2. package/dist/types/inject.d.ts +1 -1
  3. package/dist/types/runtime/firstCall.d.ts +12 -0
  4. package/dist/types/runtime/rpc.d.ts +24 -0
  5. package/dist/types/runtime/wire.d.ts +11 -4
  6. package/dist/types/server/channel.d.ts +16 -0
  7. package/dist/types/server/computed.d.ts +59 -0
  8. package/dist/types/server/context.d.ts +13 -1
  9. package/dist/types/server/db/fields.d.ts +2 -1
  10. package/dist/types/server/db/marci/query.d.ts +53 -6
  11. package/dist/types/server/inject.d.ts +2 -0
  12. package/dist/types/server/ordered.d.ts +18 -0
  13. package/dist/types/version.d.ts +1 -1
  14. package/dist/types.json +1 -1
  15. package/package.json +2 -2
  16. package/src/compile/serverSplit.ts +8 -4
  17. package/src/compile/serverTypes.ts +17 -3
  18. package/src/inject.ts +3 -2
  19. package/src/runtime/firstCall.ts +28 -0
  20. package/src/runtime/rpc.ts +141 -12
  21. package/src/runtime/wire.ts +11 -6
  22. package/src/server/channel.ts +32 -0
  23. package/src/server/channelHub.ts +246 -16
  24. package/src/server/clock.ts +50 -0
  25. package/src/server/computed.ts +84 -0
  26. package/src/server/computedHost.ts +413 -0
  27. package/src/server/context.ts +14 -2
  28. package/src/server/db/defineDb.ts +47 -7
  29. package/src/server/db/fields.ts +2 -1
  30. package/src/server/db/httpTransport.ts +37 -2
  31. package/src/server/db/httpWatch.ts +180 -0
  32. package/src/server/db/marci/query.ts +112 -7
  33. package/src/server/db/watch.ts +160 -0
  34. package/src/server/files/host.ts +2 -1
  35. package/src/server/host.ts +19 -4
  36. package/src/server/inject.ts +6 -2
  37. package/src/server/ordered.ts +41 -0
  38. package/src/server/rateLimit.ts +72 -0
  39. package/src/server/runtime.ts +127 -9
  40. package/src/version.ts +1 -1
@@ -0,0 +1,72 @@
1
+ /**
2
+ * How often one caller may knock: a token bucket per key, written once for the hosts that serve a
3
+ * project's backend — the runner (tools/runner/src/http.ts, its main thread) and the dev backend of
4
+ * `lecodes dev` (tools/cli/src/dev/projectBackend.ts) — and the channel hub both of them run (./channelHub.ts).
5
+ *
6
+ * endpoint calls per (session, endpoint id): 20 a second, a burst of 40
7
+ * subscriptions per session (channels and computeds alike): 10 a second, a burst of 20
8
+ *
9
+ * The session is the caller's bearer token, or its ip when it has none yet (a first call). Over the limit
10
+ * a call is answered with the envelope `{ ok: false, status: 429, message: "Too many calls" }` — the app
11
+ * sees `RpcError(429)` — and a subscription with an `err` frame of the same status. The limits are
12
+ * constants for now (the docs' "The limits"), the same on le.codes and on the developer's machine.
13
+ *
14
+ * A bucket holds `burst` tokens and gains `rate` a second; a call takes one. A full bucket is the same as
15
+ * none, so the table forgets the ones that have filled up again (it is swept as it grows) and a host keeps
16
+ * only the callers of the last few seconds.
17
+ */
18
+
19
+ export type RateLimit = {
20
+ /** tokens gained a second */
21
+ rate: number
22
+ /** what the bucket holds: how many calls may come at once */
23
+ burst: number
24
+ }
25
+
26
+ export const RATE_LIMITS = {
27
+ /** an endpoint's calls by one session */
28
+ call: { rate: 20, burst: 40 },
29
+ /** the subscriptions one session opens */
30
+ subscribe: { rate: 10, burst: 20 },
31
+ } as const satisfies Record<string, RateLimit>
32
+
33
+ /** What a refused call answers (the transport's envelope; the app sees `RpcError(429)`). */
34
+ export const TOO_MANY_CALLS = { ok: false as const, status: 429, message: "Too many calls" }
35
+
36
+ export type RateLimiter = {
37
+ /** One call of `key`: true when it may go, false over the limit (nothing is taken then). */
38
+ take(key: string): boolean
39
+ }
40
+
41
+ /** The key of a caller: its session token, or its ip while it has none. */
42
+ export const callerKey = (session: string | null | undefined, ip: string | undefined): string =>
43
+ session ? `s:${session}` : `ip:${ip ?? "?"}`
44
+
45
+ /** A table of buckets. `now` (ms, monotonic) is a test's knob. */
46
+ export const createRateLimiter = (limit: RateLimit, opts: { now?: () => number } = {}): RateLimiter => {
47
+ const now = opts.now ?? (() => performance.now())
48
+ const buckets = new Map<string, { tokens: number, at: number }>()
49
+ /** how long an empty bucket takes to fill: one untouched for as long is the same as none */
50
+ const fullAfter = (limit.burst / limit.rate) * 1000
51
+ let sweepAt = 1024
52
+ const sweep = (t: number) => {
53
+ for (const [k, b] of buckets) if (t - b.at >= fullAfter) buckets.delete(k)
54
+ sweepAt = Math.max(1024, buckets.size * 2)
55
+ }
56
+ return {
57
+ take(key) {
58
+ const t = now()
59
+ let b = buckets.get(key)
60
+ if (!b) {
61
+ if (buckets.size >= sweepAt) sweep(t)
62
+ buckets.set(key, b = { tokens: limit.burst, at: t })
63
+ } else {
64
+ b.tokens = Math.min(limit.burst, b.tokens + ((t - b.at) / 1000) * limit.rate)
65
+ b.at = t
66
+ }
67
+ if (b.tokens < 1) return false
68
+ b.tokens -= 1
69
+ return true
70
+ },
71
+ }
72
+ }
@@ -1,21 +1,29 @@
1
1
  /**
2
2
  * Host side of a compiled server bundle — used by the runner (tools/runner) and by tests.
3
3
  * Imported normally (Node/Bun), NOT through chisel: the bundle carries its own SDK copy and talks
4
- * to this module only through globals (`__lecodesServer`, `__lecodesRequest`, `__lecodesPublish`).
4
+ * to this module only through globals (`__lecodesServer`, `__lecodesRequest`, `__lecodesPublish`,
5
+ * `__lecodesAsyncLocalStorage` — the class `ordered` keeps the keys a body holds in, ./ordered.ts;
6
+ * `__lecodesDbGate` — what the bundle's db client shows every operation to, ./computedHost.ts;
7
+ * `__lecodesPresence` — where `watchers` / `members` are answered, ./computedHost.ts `setPresenceProvider`).
5
8
  *
6
9
  * evaluate the bundle (it sets globalThis.__lecodesServer)
7
10
  * const server = loadServerModules() // classify exports, name channels
8
11
  * const invoke = createInvoker(server) // validate → run in request context → envelope
9
12
  * const subscribe = createSubscribeGuard(server) // a channel's hooks → the subscriber's group
13
+ * const computed = createComputedHost(server, { watch }) // computed subscriptions (./computedHost.ts)
10
14
  * await invoke("notes.server.ts#listNotes", [20], { headers, ip })
11
15
  */
12
16
 
13
17
  import { AsyncLocalStorage } from "node:async_hooks"
18
+ import { createHash, randomBytes } from "node:crypto"
14
19
  import type { ServerManifest } from "../compile/serverSplit"
15
20
  import type { AuthHost } from "./auth/host"
16
21
  import type { AuthState } from "./auth/types"
17
22
  import type { FilesHost, ResourceReader } from "./files/host"
18
23
  import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
24
+ import { isComputed, type ComputedRecord } from "./computed"
25
+ import { ComputedWriteError, computedSubscriptions, installDbGate, readOnly, requestRead, setPresenceProvider, type ComputedHost } from "./computedHost"
26
+ import type { WatchSource } from "./db/watch"
19
27
  import { isPushChannel, type PushChannelRecord } from "./pushChannel"
20
28
  import { setRequestProvider, type RequestContext } from "./context"
21
29
  import type { Db } from "./db/types"
@@ -27,6 +35,8 @@ export type ServerRegistration = { modules: Record<string, Record<string, unknow
27
35
  export type LoadedServer = {
28
36
  endpoints: Map<string, (...args: any[]) => unknown>
29
37
  channels: Map<string, ChannelRecord>
38
+ /** The computeds (`computed()` exports). Each is an endpoint too — its one-shot call, under the read-only rule. */
39
+ computeds: Map<string, ComputedRecord>
30
40
  /** The push channels (`pushChannel()` exports): their presence is what makes a response ask a signed-in device to register. */
31
41
  pushChannels: Map<string, PushChannelRecord>
32
42
  /** The project's `defineDb` handle — exported or not (the bundle registers it on globalThis); none or one. */
@@ -41,14 +51,19 @@ export const loadServerModules = (
41
51
  reg: ServerRegistration | undefined = (globalThis as any).__lecodesServer,
42
52
  ): LoadedServer => {
43
53
  if (!reg) throw new Error("loadServerModules: no server bundle registered (globalThis.__lecodesServer)")
44
- const out: LoadedServer = { endpoints: new Map(), channels: new Map(), pushChannels: new Map(), dbs: [], manifest: reg.manifest }
54
+ const out: LoadedServer = { endpoints: new Map(), channels: new Map(), computeds: new Map(), pushChannels: new Map(), dbs: [], manifest: reg.manifest }
45
55
  for (const [mod, exports] of Object.entries(reg.modules)) {
46
56
  // the compile's word on each export (serverSplit.ts): a function that is not async is the
47
57
  // server's helper, never an endpoint — an old manifest without kinds counts every function
48
58
  const kinds = new Map((reg.manifest?.modules?.[mod] ?? []).map(e => [e.name, e.kind]))
49
59
  for (const [name, value] of Object.entries(exports)) {
50
60
  const id = `${mod}#${name}`
51
- if (typeof value === "function") { if (kinds.get(name) !== "other") out.endpoints.set(id, value as (...args: any[]) => unknown) }
61
+ if (isComputed(value)) {
62
+ value.__id = id
63
+ out.computeds.set(id, value)
64
+ out.endpoints.set(id, (...args: unknown[]) => readOnly(id, () => value.__fn(...args)))
65
+ }
66
+ else if (typeof value === "function") { if (kinds.get(name) !== "other") out.endpoints.set(id, value as (...args: any[]) => unknown) }
52
67
  else if (isChannel(value)) { value.__id = id; out.channels.set(id, value) }
53
68
  else if (isPushChannel(value)) { value.__id = id; out.pushChannels.set(id, value) }
54
69
  else if (isDb(value)) out.dbs.push(value)
@@ -67,9 +82,12 @@ export const loadServerModules = (
67
82
 
68
83
  /** The wire envelope. `session` = a token the client must store from now on (new guest session / rotation).
69
84
  * `push` = the device is asked to register for notifications: its session is signed in, has no push address,
70
- * and the project has push channels (the transport registers, once per run — the OS prompt comes then). */
85
+ * and the project has push channels (the transport registers, once per run — the OS prompt comes then).
86
+ * `subscriber` = a subscription's only (the subscribe guard's, a computed's run), for the hub and never on
87
+ * the wire: the subscriber's `request.session` when it has one — who the subscription is present as
88
+ * (./channelHub.ts). A device that has none yet (no call or hook read it) is nobody, and not listed. */
71
89
  export type InvokeResult =
72
- | { ok: true, result: unknown, session?: string, push?: true }
90
+ | { ok: true, result: unknown, session?: string, push?: true, subscriber?: number }
73
91
  | { ok: false, status: number, message: string, session?: string, push?: true }
74
92
 
75
93
  export type InvokerOptions = {
@@ -108,14 +126,65 @@ const dbStatus = (e: unknown): number | null => {
108
126
  */
109
127
  // One request store for endpoints AND channel hooks (a second store would steal the provider).
110
128
  const als = new AsyncLocalStorage<RequestContext>()
111
- const installProvider = () => setRequestProvider(() => als.getStore())
129
+ const installProvider = () => {
130
+ // a read of the request in a computed's run makes that run its subscriber's (./computedHost.ts shared runs)
131
+ setRequestProvider((quiet) => {
132
+ if (!quiet) requestRead()
133
+ return als.getStore()
134
+ })
135
+ installDbGate()
136
+ ;(globalThis as { __lecodesAsyncLocalStorage?: unknown }).__lecodesAsyncLocalStorage = AsyncLocalStorage
137
+ }
138
+
139
+ /** A token the client transport may hold: what this file and the auth host mint (32 random bytes, base64url). */
140
+ const DEVICE_TOKEN = /^[A-Za-z0-9_-]{32,128}$/
141
+ /** A token minted by `request.session` (no sign-in): it rides on the call's envelope, as a guest session's does. */
142
+ const minted = new WeakMap<RequestContext, string>()
143
+
144
+ /**
145
+ * `request.session` — decided here only, for endpoints and channel hooks, on every host. With sign-in it is
146
+ * the request's session row (`db.auth.sessionId`, resolved before the call). Without, a number derived from
147
+ * the bearer token the transport keeps per backend: the same on every worker and host for as long as the
148
+ * device keeps the token, and nothing is stored. A hash, not the token: the id may be shown to other players,
149
+ * the token is the device's password. Read lazily: the first read in a call that carries no usable token
150
+ * mints one and the envelope hands it to the transport — a call that never asks changes nothing.
151
+ *
152
+ * Answers the session as it stands, minting nothing (undefined = none yet): what a subscription is present as.
153
+ */
154
+ const defineSession = (rc: RequestContext, auth: AuthHost | null): (() => number | undefined) => {
155
+ let id: number | undefined
156
+ const peek = () => {
157
+ if (auth?.enabled) return (rc.auth as AuthState | undefined)?.sessionId
158
+ if (id !== undefined) return id
159
+ if (!rc.sessionToken || !DEVICE_TOKEN.test(rc.sessionToken)) return undefined
160
+ // 52 bits of sha256: a safe integer, the way a row id is one
161
+ return id = Number.parseInt(createHash("sha256").update(rc.sessionToken).digest("hex").slice(0, 13), 16)
162
+ }
163
+ Object.defineProperty(rc, "session", {
164
+ configurable: true,
165
+ get: () => {
166
+ if (!auth?.enabled && id === undefined && (!rc.sessionToken || !DEVICE_TOKEN.test(rc.sessionToken))) {
167
+ rc.sessionToken = randomBytes(32).toString("base64url")
168
+ minted.set(rc, rc.sessionToken)
169
+ }
170
+ return peek()
171
+ },
172
+ })
173
+ return peek
174
+ }
175
+ /** A subscription's envelope names its subscriber, when there is one (`InvokeResult.subscriber`). */
176
+ const subscriberOf = (peek: () => number | undefined): { subscriber?: number } => {
177
+ const n = peek()
178
+ return n === undefined ? {} : { subscriber: n }
179
+ }
112
180
 
113
181
  // A token minted/rotated during the call rides on the envelope (the client transport stores it); so does the
114
182
  // request to register the device for notifications, when a signed-in session has no address and there are
115
183
  // push channels to hear.
116
184
  const envelope = (server: LoadedServer) => (r: InvokeResult, rc: RequestContext): InvokeResult => {
117
185
  const s = rc.auth as AuthState | undefined
118
- const out = s?.issuedToken ? { ...r, session: s.issuedToken } : r
186
+ const token = s?.issuedToken ?? minted.get(rc)
187
+ const out = token ? { ...r, session: token } : r
119
188
  return s && s.userId !== null && !s.pushAddress && server.pushChannels.size ? { ...out, push: true } : out
120
189
  }
121
190
 
@@ -133,6 +202,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
133
202
  if (!fn) return { ok: false, status: 404, message: `Unknown endpoint ${id}` }
134
203
  if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
135
204
  const rc: RequestContext = { ...ctx, id }
205
+ defineSession(rc, auth)
136
206
  try {
137
207
  // an upload is an argument like any other once it is adopted: validated, then passed
138
208
  if (files) args = await files.adopt(args, rc)
@@ -146,6 +216,8 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
146
216
  if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
147
217
  if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
148
218
  onError(id, e)
219
+ // a computed's one-shot call that wrote: the rule it broke, in its words
220
+ if (e instanceof ComputedWriteError) return withSession({ ok: false, status: 500, message: e.message }, rc)
149
221
  return withSession({ ok: false, status: 500, message: "Internal error" }, rc)
150
222
  } finally {
151
223
  // in the request's scope: the urls of what the call returned are already made, this is the cleanup
@@ -249,6 +321,7 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
249
321
  if (!ch) return { ok: false, status: 404, message: `Unknown channel ${channelId}` }
250
322
  if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
251
323
  const rc: RequestContext = { ...ctx, id: channelId }
324
+ const peek = defineSession(rc, auth)
252
325
  try {
253
326
  if (validate && ch.__groupBy && params?.[channelId]) validateArgs(params[channelId], args)
254
327
  if (auth) await auth.resolve(rc)
@@ -257,7 +330,7 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
257
330
  return ch.__groupBy ? await ch.__groupBy(...args) : null
258
331
  })
259
332
  if (group !== null && !isChannelGroup(group)) throw new Error(`groupBy answered ${group === undefined ? "nothing" : typeof group} — a group is a string or a number`)
260
- return withSession({ ok: true, result: group }, rc)
333
+ return withSession({ ok: true, result: group, ...subscriberOf(peek) }, rc)
261
334
  } catch (e) {
262
335
  const status = apiStatus(e) ?? dbStatus(e)
263
336
  if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
@@ -268,5 +341,50 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
268
341
  }
269
342
  }
270
343
 
271
- export { setChannelPublisher, setRequestProvider }
344
+ export type ComputedHostOptions = Pick<InvokerOptions, "validate" | "onError" | "auth"> & {
345
+ /** Where the reads of a computed are watched (./db/watch.ts): the database's source. `null` = this host
346
+ * watches nothing — a computed that reads the database cannot be subscribed to. */
347
+ watch: WatchSource | null
348
+ /** Where a new value goes: `(id, sub, envelope)`. Default: the host's channel publisher (`setChannelPublisher`). */
349
+ publish?: (ch: string, group: number, envelope: InvokeResult) => void
350
+ }
351
+
352
+ /**
353
+ * The computed subscriptions of a worker (./computedHost.ts): `start(id, args, ctx, sub)` runs computed
354
+ * `id` for the hub's subscription `sub` the way a channel's hooks run — `args` validated like an
355
+ * endpoint's, in the subscriber's request scope (`request.session`, `db.auth`) — and keeps it fresh from
356
+ * then on; `end(sub)` lets it go. The envelope's `result` is the value. A run that read nothing of the
357
+ * request is the run of every subscription of its key (the export and its arguments): one run per change.
358
+ */
359
+ export const createComputedHost = (server: LoadedServer, opts: ComputedHostOptions): ComputedHost => {
360
+ installProvider()
361
+ const validate = opts.validate ?? true
362
+ const onError = opts.onError ?? ((id, e) => console.error(`[server] ${id}:`, e))
363
+ const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
364
+ const auth = opts.auth ?? null
365
+ const withSession = envelope(server)
366
+ const run = async (id: string, args: unknown[], ctx: Omit<RequestContext, "id">, around: <T>(fn: () => T, me: () => number | undefined) => T): Promise<InvokeResult> => {
367
+ const c = server.computeds.get(id)
368
+ if (!c) return { ok: false, status: 404, message: `Unknown computed ${id}` }
369
+ if (!Array.isArray(args)) return { ok: false, status: 400, message: "args must be an array" }
370
+ const rc: RequestContext = { ...ctx, id }
371
+ const peek = defineSession(rc, auth)
372
+ try {
373
+ if (validate && params?.[id]) validateArgs(params[id], args)
374
+ if (auth) await auth.resolve(rc)
375
+ const value = await als.run(rc, () => around(() => c.__fn(...args), peek))
376
+ return withSession({ ok: true, result: value === undefined ? null : value, ...subscriberOf(peek) }, rc)
377
+ } catch (e) {
378
+ const status = apiStatus(e) ?? dbStatus(e)
379
+ if (status !== null) return withSession({ ok: false, status, message: (e as Error).message }, rc)
380
+ if (e instanceof ValidationError) return { ok: false, status: 400, message: e.message }
381
+ onError(id, e)
382
+ if (e instanceof ComputedWriteError) return withSession({ ok: false, status: 500, message: e.message }, rc)
383
+ return withSession({ ok: false, status: 500, message: "Internal error" }, rc)
384
+ }
385
+ }
386
+ return computedSubscriptions({ run, watch: opts.watch, publish: opts.publish })
387
+ }
388
+
389
+ export { setChannelPublisher, setRequestProvider, setPresenceProvider }
272
390
  export { setPushSender } from "./pushChannel"
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.6.0"
7
+ export const SDK_VERSION = "2.8.0"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {