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
|
@@ -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
|
+
}
|
package/src/server/runtime.ts
CHANGED
|
@@ -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 (
|
|
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 = () =>
|
|
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
|
|
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
|
|
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/ui/UIModal.ts
CHANGED
|
@@ -23,6 +23,14 @@ export type UIModalShow = (() => boolean) | Signal<boolean>
|
|
|
23
23
|
const MODAL_ANIM_MS = 200
|
|
24
24
|
const SHOW_BINDING = "#show"
|
|
25
25
|
|
|
26
|
+
// The transition period's `display` on a modal: said once per run, not per write.
|
|
27
|
+
let displayWarned = false
|
|
28
|
+
function warnDisplayDeprecated(): void {
|
|
29
|
+
if (displayWarned) return
|
|
30
|
+
displayWarned = true
|
|
31
|
+
console.warn("deprecated: a modal's `display` — show it with show() / hide(), or bind it with bindShow(() => …) (+ onDismiss for the scrim tap / back); `display` on a modal will throw again")
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
/**
|
|
27
35
|
* A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
|
|
28
36
|
* (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), is HIDDEN until shown, animates on
|
|
@@ -38,9 +46,10 @@ const SHOW_BINDING = "#show"
|
|
|
38
46
|
* request, `onDismiss`. By default it hides an unbound modal and writes `false` into a bound
|
|
39
47
|
* signal; a bound function has nowhere to write, so a modal bound to one without `onDismiss`
|
|
40
48
|
* cannot be dismissed (a forced choice — the same as `dismissible(false)`). `display` is not a
|
|
41
|
-
* modal's switch
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* modal's switch (a bound display died on the first dismissal, which wrote a static
|
|
50
|
+
* `display: "none"` over the binding — the dialog never opened again). Deprecated, for the
|
|
51
|
+
* transition period a `.style({ display })` is still accepted, with a warning: a function becomes
|
|
52
|
+
* `bindShow(() => fn() !== "none")`, a value a show() / hide(); it will throw again.
|
|
44
53
|
*/
|
|
45
54
|
export interface UIModal extends UIWidget {
|
|
46
55
|
/**
|
|
@@ -107,14 +116,40 @@ export class ModalElement extends WidgetElement {
|
|
|
107
116
|
this._syncPose()
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
/** @internal `display` is not a modal's switch
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
119
|
+
/** @internal `display` is not a modal's switch — DEPRECATED, accepted for the transition period
|
|
120
|
+
* and translated: a function → `bindShow(() => fn() !== "none")`, a value → the start state of
|
|
121
|
+
* an unplaced modal or a show() / hide() of a placed one (replacing a binding, as a static write
|
|
122
|
+
* did). The widget's own writes (`_ownWrite`) pass untouched. During the constructor the field is
|
|
123
|
+
* still undefined (it is initialised after `super()`), which lets the default `display: "none"`
|
|
124
|
+
* through. */
|
|
114
125
|
_mapStyle(style: any): void {
|
|
115
|
-
if ("display" in style
|
|
116
|
-
|
|
126
|
+
if (!("display" in style) || this._ownWrite !== false) return
|
|
127
|
+
const value = style.display
|
|
128
|
+
delete style.display
|
|
129
|
+
warnDisplayDeprecated()
|
|
130
|
+
if (typeof value === "function") { this._bindDisplay(value); return }
|
|
131
|
+
if (this._bound) {
|
|
132
|
+
disposeBinding(this, SHOW_BINDING)
|
|
133
|
+
this._bound = false
|
|
134
|
+
this._showSignal = undefined
|
|
135
|
+
this._syncDismissible()
|
|
117
136
|
}
|
|
137
|
+
const shown = value !== "none"
|
|
138
|
+
if (this._h.id === 0) return
|
|
139
|
+
if (tree().parent(this._h.id) === 0 && !this._shown) this._writeDisplay(shown ? "flex" : "none") // the start state
|
|
140
|
+
else this._setOpen(shown)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** @internal `el.style.display = () => …` (the style proxy's binding form) — DEPRECATED, as `_mapStyle`. */
|
|
144
|
+
_bindStyle(key: string, fn: () => any): boolean {
|
|
145
|
+
if (key !== "display") return false
|
|
146
|
+
warnDisplayDeprecated()
|
|
147
|
+
this._bindDisplay(fn)
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private _bindDisplay(fn: () => any): void {
|
|
152
|
+
this.bindShow(() => fn() !== "none")
|
|
118
153
|
}
|
|
119
154
|
|
|
120
155
|
// The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
|
package/src/ui/UINode.ts
CHANGED
|
@@ -399,6 +399,7 @@ const handler: ProxyHandler<any> = {
|
|
|
399
399
|
// dispose the binding (this is the hot path) — override statically via `.style({ prop })`.
|
|
400
400
|
const el = target._el
|
|
401
401
|
const fn = value as unknown as () => any
|
|
402
|
+
if (el._bindStyle?.(key, fn) === true) return true // the element took the binding itself
|
|
402
403
|
createBinding(el, key, () => { el.style[key] = fn() })
|
|
403
404
|
return true
|
|
404
405
|
}
|
|
@@ -518,6 +519,9 @@ export class Element<T extends string> implements TreeElement {
|
|
|
518
519
|
* `type: "phone"` → `"tel"`), mutating the style in place before it is stored or sent. Runs on
|
|
519
520
|
* every entry point: the constructor's style, `.style()`, and single-key `el.style.k =` writes. */
|
|
520
521
|
_mapStyle?(style: any): void
|
|
522
|
+
/** @internal A function written as `el.style.key = () => …`: true when the element bound it
|
|
523
|
+
* itself (a modal's deprecated `display`), false for the ordinary per-key binding. */
|
|
524
|
+
_bindStyle?(key: string, fn: () => any): boolean
|
|
521
525
|
|
|
522
526
|
// Per-type default styles are inlined at each component's construction site (spread UNDER the
|
|
523
527
|
// user's style — e.g. UIRow's `flexDirection: "row"`), so every node reaches the host with
|
package/src/ui/tree.ts
CHANGED
|
@@ -233,6 +233,14 @@ export const unregisterNativeView = (viewId: number): void => { nativeViews.dele
|
|
|
233
233
|
// ---- gestures -----------------------------------------------------------------------------------------------
|
|
234
234
|
// The track handler of every pointer a listener claimed (`ev.track(...)`): the runtime feeds the
|
|
235
235
|
// pointer's moves / end / cancel here by pointer id — nothing of it is retained natively.
|
|
236
|
+
//
|
|
237
|
+
// Several fingers on one node — the rule every host keeps (renderers/uikit + renderers/android
|
|
238
|
+
// TouchPipeline, renderers/tgfx ui_internal.h Gesture, renderers/web-lite events.ts, web-canvas
|
|
239
|
+
// TreeUI): every finger is its own pointer — its own touchStart and so its own `ev.track`, its own
|
|
240
|
+
// moves and end / cancel at its own point, keyed here by its id. The click and the long press are the
|
|
241
|
+
// node's PRIMARY finger's, the one that pressed it while no other held it: a two-finger tap is ONE
|
|
242
|
+
// click, a second finger landing cancels a hold still pending (a pinch is not a hold). `$pressed`
|
|
243
|
+
// holds until the last finger lifts.
|
|
236
244
|
const trackers = new Map<number, TrackLike>()
|
|
237
245
|
|
|
238
246
|
// The hover's own slot — the cursor is one, and it is NOT a gesture: a mouse drag keeps its touch
|
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.
|
|
7
|
+
export const SDK_VERSION = "2.8.1"
|
|
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 => {
|