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
|
@@ -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/UIBottomSheet.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ModalElement, type UIModalStyle } from "./UIModal"
|
|
1
|
+
import { ModalElement, type UIModalShow, type UIModalStyle } from "./UIModal"
|
|
2
2
|
import type { UIWidget } from "./UIWidget"
|
|
3
3
|
import { buildUI, type ChildrenFn, type UIChildArg, type UINodeChild } from "./UINode"
|
|
4
4
|
import { TREE_FLAG_DETENT } from "./tree"
|
|
@@ -15,14 +15,18 @@ const SHEET_ANIM_MS = 250
|
|
|
15
15
|
* node type — docs/bottom-sheet-plan.md): hosts without sheet support show a static bottom panel.
|
|
16
16
|
*
|
|
17
17
|
* With detents, content lays out at the HIGHEST detent's size; lower detents show the top
|
|
18
|
-
* slice. The scrim, `
|
|
19
|
-
*
|
|
18
|
+
* slice. The scrim, `bindShow` / `onDismiss`, `onOpen` / `onClose` all come from the modal
|
|
19
|
+
* machinery; the drag below the lowest detent is the sheet's third dismissal gesture, routed like
|
|
20
|
+
* the scrim tap. The host hears whether a drag may dismiss (`sheetDismissible`):
|
|
21
|
+
* `dismissible(false)`, or a bound sheet with no `onDismiss`, resists instead.
|
|
20
22
|
*
|
|
21
23
|
* ```ts
|
|
22
|
-
* const actions = UIBottomSheet(rows).style({ bgColor: "#1c1c1e", borderRadius: 16 }) // content-sized
|
|
24
|
+
* const actions = UIBottomSheet(rows).style({ bgColor: "#1c1c1e", borderRadius: 16 }) // content-sized, show()
|
|
25
|
+
*
|
|
26
|
+
* const card = UIBottomSheet(place).bindShow(() => data.value.pick != null).onDismiss(on.unpick) // the data's
|
|
23
27
|
*
|
|
24
28
|
* const mapSheet = UIBottomSheet(results).style({ bgColor: "#1c1c1e", borderRadius: 16 })
|
|
25
|
-
* .detents([0.3, 0.6, 1])
|
|
29
|
+
* .detents([0.3, 0.6, 1]).dismissible(false) // persistent: show() once placed
|
|
26
30
|
* .onDetent(i => map.setPadding(...))
|
|
27
31
|
* ```
|
|
28
32
|
*/
|
|
@@ -38,16 +42,21 @@ export interface UIBottomSheet extends UIWidget {
|
|
|
38
42
|
/** Fires on every settle: a finger snap on native hosts, or a `setDetent()` call. */
|
|
39
43
|
onDetent(callback: (index: number) => void): this,
|
|
40
44
|
|
|
41
|
-
/**
|
|
45
|
+
/** Bind the shown state, as on `UIModal`: a function of the data (`bindShow(() =>
|
|
46
|
+
* data.value.pick != null)`) or a signal. */
|
|
47
|
+
bindShow(value: UIModalShow): this,
|
|
48
|
+
/** Slide in to the current detent. No-op while already shown. Bound: as on `UIModal`. */
|
|
42
49
|
show(): void,
|
|
43
|
-
/** Slide out, fade the scrim
|
|
50
|
+
/** Slide out, fade the scrim. No-op while hidden. */
|
|
44
51
|
hide(): void,
|
|
45
|
-
/** True from `show()` until `hide()` starts. */
|
|
46
|
-
readonly isOpen: boolean,
|
|
47
52
|
|
|
48
|
-
/**
|
|
53
|
+
/** The user asked to close — a scrim tap, the back press, a drag below the lowest detent. The
|
|
54
|
+
* one hook for all three; a bound sheet needs it to be dismissible at all (it resists the drag
|
|
55
|
+
* otherwise). When the callback leaves the sheet open, it slides back to its detent. */
|
|
56
|
+
onDismiss(callback: () => void): this,
|
|
57
|
+
/** Called when the sheet opens. */
|
|
49
58
|
onOpen(callback: () => void): this,
|
|
50
|
-
/** Called when the sheet starts closing —
|
|
59
|
+
/** Called when the sheet starts closing — a dismissal, `hide()`, the data. */
|
|
51
60
|
onClose(callback: () => void): this,
|
|
52
61
|
/** `dismissible(false)`: scrim taps, the back button and dragging below the lowest detent no
|
|
53
62
|
* longer close the sheet (a drag collapses to the lowest detent instead). Default `true`. */
|
|
@@ -72,13 +81,23 @@ export class BottomSheetElement extends ModalElement {
|
|
|
72
81
|
}
|
|
73
82
|
|
|
74
83
|
/** @internal The host's GESTURE settles only (programmatic `setDetent` fires JS-side); `-1` =
|
|
75
|
-
* dragged below the lowest detent while dismissible. */
|
|
84
|
+
* dragged below the lowest detent while dismissible — a dismissal like the scrim tap. */
|
|
76
85
|
_emitDetent(index: number): void {
|
|
77
|
-
if (index < 0) { this.
|
|
86
|
+
if (index < 0) { this._dismiss(); return }
|
|
78
87
|
this._detent = index
|
|
79
88
|
for (const callback of this._detentListeners) callback(index)
|
|
80
89
|
}
|
|
81
90
|
|
|
91
|
+
/** @internal The dismiss callbacks left the sheet open (the data said so): the host slid it out
|
|
92
|
+
* on the drag, so put it back on its detent. */
|
|
93
|
+
override _afterDismiss(): void {
|
|
94
|
+
if (this._shown) this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
override _syncDismissible(): void {
|
|
98
|
+
this.style({ sheetDismissible: this._dismissAllowed() ? 1 : 0 } as UIBottomSheetStyle)
|
|
99
|
+
}
|
|
100
|
+
|
|
82
101
|
detents(list: number[]): this {
|
|
83
102
|
const sorted = [...list].filter(d => d > 0).sort((a, b) => a - b)
|
|
84
103
|
if (sorted.length === 0) return this
|
|
@@ -108,23 +127,13 @@ export class BottomSheetElement extends ModalElement {
|
|
|
108
127
|
return this
|
|
109
128
|
}
|
|
110
129
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
override hide(): void {
|
|
119
|
-
if (!this._shown) return
|
|
120
|
-
this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; the hide follows
|
|
121
|
-
super.hide()
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
override dismissible(enabled: boolean): this {
|
|
125
|
-
super.dismissible(enabled)
|
|
126
|
-
this.style({ sheetDismissible: enabled ? 1 : 0 } as UIBottomSheetStyle)
|
|
127
|
-
return this
|
|
130
|
+
/** @internal The display write is the moment the host enters or leaves: the detent rides along —
|
|
131
|
+
* written BEFORE the display, so the node carries the target when the host plays the entrance
|
|
132
|
+
* to it (`-1` on a hide: the host slides out, the hide follows). Every way of opening goes
|
|
133
|
+
* through here: show() / hide(), `open()`, the binding. */
|
|
134
|
+
override _writeDisplay(display: "flex" | "none"): void {
|
|
135
|
+
this.style({ sheetDetent: display === "flex" ? this._detent : -1 } as UIBottomSheetStyle)
|
|
136
|
+
super._writeDisplay(display)
|
|
128
137
|
}
|
|
129
138
|
}
|
|
130
139
|
|
package/src/ui/UIModal.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { buildUI, type BaseStyle, type ChildrenFn, type Color, type DrawableStyl
|
|
|
3
3
|
import type { TransitionPose } from "./presentable"
|
|
4
4
|
import { _widgetPoseId } from "./transitions"
|
|
5
5
|
import { tree } from "./tree"
|
|
6
|
+
import { createBinding, disposeBinding, flushEffects, type Signal } from "../core/signals"
|
|
6
7
|
|
|
7
8
|
export type UIModalStyle = UIWidgetStyle
|
|
8
9
|
|
|
@@ -15,27 +16,49 @@ export type UIModalTransition = DrawableStyle & BaseStyle & {
|
|
|
15
16
|
duration?: number,
|
|
16
17
|
}
|
|
17
18
|
|
|
19
|
+
/** What `bindShow()` takes: a function of the data, or a signal the modal also writes (`false` on
|
|
20
|
+
* a dismissal, `show()` / `hide()` set it). */
|
|
21
|
+
export type UIModalShow = (() => boolean) | Signal<boolean>
|
|
22
|
+
|
|
18
23
|
const MODAL_ANIM_MS = 200
|
|
24
|
+
const SHOW_BINDING = "#show"
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
27
|
* A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
|
|
22
|
-
* (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden),
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* `
|
|
30
|
-
*
|
|
28
|
+
* (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), is HIDDEN until shown, animates on
|
|
29
|
+
* every show / hide (a 200 ms fade by default — swap the pose via `transition()`; the runtime
|
|
30
|
+
* plays it as tracks that never touch the style).
|
|
31
|
+
*
|
|
32
|
+
* One state, shown or not, driven one of two ways:
|
|
33
|
+
* - `show()` / `hide()` from code — an action sheet opened by a button;
|
|
34
|
+
* - `bindShow(…)`: a function of the data (`bindShow(() => data.value.confirm)` — the data is the
|
|
35
|
+
* truth, `show()` / `hide()` are refused) or a `Signal<boolean>` the modal reads AND writes.
|
|
36
|
+
*
|
|
37
|
+
* A dismissal — the scrim tap, the back press, a sheet dragged below its lowest detent — is ONE
|
|
38
|
+
* request, `onDismiss`. By default it hides an unbound modal and writes `false` into a bound
|
|
39
|
+
* signal; a bound function has nowhere to write, so a modal bound to one without `onDismiss`
|
|
40
|
+
* cannot be dismissed (a forced choice — the same as `dismissible(false)`). `display` is not a
|
|
41
|
+
* modal's switch: a `.style({ display })` on it throws (a bound display died on the first
|
|
42
|
+
* dismissal, which wrote a static `display: "none"` over the binding — the dialog never opened
|
|
43
|
+
* again).
|
|
31
44
|
*/
|
|
32
45
|
export interface UIModal extends UIWidget {
|
|
33
|
-
/**
|
|
46
|
+
/**
|
|
47
|
+
* Bind the shown state. A function: the modal follows the data, and `show()` / `hide()` are
|
|
48
|
+
* errors — flip the data (`onDismiss` is where a dismissal goes). A signal: read and written —
|
|
49
|
+
* a dismissal sets it `false`, `show()` / `hide()` set it.
|
|
50
|
+
*
|
|
51
|
+
* ```ts
|
|
52
|
+
* UIModal(...).bindShow(() => data.value.confirm).onDismiss(on.cancel) // the data is the truth
|
|
53
|
+
* UIModal(...).bindShow(confirmOpen) // a Signal<boolean>
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
bindShow(value: UIModalShow): this,
|
|
57
|
+
/** Show and play the entrance transition. No-op while already shown. Bound to a signal: writes
|
|
58
|
+
* `true` into it; bound to a function: an error (flip the data). */
|
|
34
59
|
show(): void,
|
|
35
|
-
/** Play the exit transition, then hide. No-op while
|
|
60
|
+
/** Play the exit transition, then hide. No-op while hidden. Bound: as `show()`. */
|
|
36
61
|
hide(): void,
|
|
37
|
-
/** True from `show()` until `hide()` starts (already false during the exit animation). */
|
|
38
|
-
readonly isOpen: boolean,
|
|
39
62
|
|
|
40
63
|
/**
|
|
41
64
|
* Replace the show/hide animation. `hidden` is the off-screen pose — `show()` animates from
|
|
@@ -49,13 +72,17 @@ export interface UIModal extends UIWidget {
|
|
|
49
72
|
*/
|
|
50
73
|
transition(hidden: UIModalTransition): this,
|
|
51
74
|
|
|
52
|
-
/**
|
|
75
|
+
/** The user asked to close: a scrim tap, the back press, a sheet dragged below its lowest
|
|
76
|
+
* detent. Replaces the default (hide, or `false` into the signal): the callback decides —
|
|
77
|
+
* usually the action that flips the data the modal is bound to. */
|
|
78
|
+
onDismiss(callback: () => void): this,
|
|
79
|
+
/** Called when the modal is shown — whoever showed it. */
|
|
53
80
|
onOpen(callback: () => void): this,
|
|
54
|
-
/** Called when the modal starts
|
|
55
|
-
* block flipping its display. */
|
|
81
|
+
/** Called when the modal starts hiding — whoever hid it. */
|
|
56
82
|
onClose(callback: () => void): this,
|
|
57
|
-
/** `dismissible(false)
|
|
58
|
-
*
|
|
83
|
+
/** `dismissible(false)`: the dismissal gestures are off — the scrim tap does nothing, the back
|
|
84
|
+
* press is swallowed, a sheet resists below its lowest detent (a forced choice, the persistent
|
|
85
|
+
* map sheet). Default `true`. */
|
|
59
86
|
dismissible(enabled: boolean): this,
|
|
60
87
|
}
|
|
61
88
|
|
|
@@ -64,16 +91,32 @@ export class ModalElement extends WidgetElement {
|
|
|
64
91
|
_transition: UIModalTransition = { opacity: 0 }
|
|
65
92
|
readonly _openListeners: (() => void)[] = []
|
|
66
93
|
readonly _closeListeners: (() => void)[] = []
|
|
94
|
+
readonly _dismissListeners: (() => void)[] = []
|
|
95
|
+
/** @internal `bindShow` was called: show() / hide() are not the modal's to call (a signal's: they write it). */
|
|
96
|
+
_bound = false
|
|
97
|
+
/** @internal The signal `bindShow()` was given, which a dismissal writes. */
|
|
98
|
+
_showSignal?: Signal<boolean>
|
|
67
99
|
private _poseObj?: TransitionPose
|
|
68
100
|
private _poseKey = ""
|
|
69
101
|
|
|
70
102
|
constructor(style: UIModalStyle, children: UINodeChild[] | ChildrenFn) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.
|
|
103
|
+
// Hidden by default: a dialog is shown, never found open — show() / bindShow() flip it.
|
|
104
|
+
super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", display: "none", ...style }, children)
|
|
105
|
+
this.onOverlayTap(() => this._dismiss())
|
|
106
|
+
this.onBack(() => this._dismiss())
|
|
74
107
|
this._syncPose()
|
|
75
108
|
}
|
|
76
109
|
|
|
110
|
+
/** @internal `display` is not a modal's switch. Refused on every write of the app's — `.style({
|
|
111
|
+
* display })`, `el.style.display =` — and let through for the widget's own (`_ownWrite`). During
|
|
112
|
+
* the constructor the field is still undefined (it is initialised after `super()`), which lets
|
|
113
|
+
* the default `display: "none"` through. */
|
|
114
|
+
_mapStyle(style: any): void {
|
|
115
|
+
if ("display" in style && this._ownWrite === false) {
|
|
116
|
+
throw new Error("a modal's `display` is not its switch — a dialog, a sheet or a menu is shown with show() / hide(), or bound to the data with bindShow(…), never by its display")
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
77
120
|
// The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
|
|
78
121
|
// fade — only when the modal actually has a scrim (overlayColor: null opts out; a track on a
|
|
79
122
|
// null scrim would create the intercepting layer) and the pose doesn't drive the scrim itself.
|
|
@@ -106,27 +149,80 @@ export class ModalElement extends WidgetElement {
|
|
|
106
149
|
return this
|
|
107
150
|
}
|
|
108
151
|
|
|
152
|
+
bindShow(value: UIModalShow): this {
|
|
153
|
+
if (this._fx !== undefined) disposeBinding(this, SHOW_BINDING)
|
|
154
|
+
this._bound = true
|
|
155
|
+
let read: () => boolean
|
|
156
|
+
if (typeof value === "function") { this._showSignal = undefined; read = value }
|
|
157
|
+
else { this._showSignal = value; read = () => value.value }
|
|
158
|
+
// The first value is the START state (bound before the modal is placed, it shows with its
|
|
159
|
+
// place); every later one is a flip, played like a show() / hide().
|
|
160
|
+
let initial = true
|
|
161
|
+
createBinding(this, SHOW_BINDING, () => {
|
|
162
|
+
const shown = !!read()
|
|
163
|
+
if (this._h.id !== 0) {
|
|
164
|
+
if (initial && tree().parent(this._h.id) === 0) this._writeDisplay(shown ? "flex" : "none")
|
|
165
|
+
else this._setOpen(shown)
|
|
166
|
+
}
|
|
167
|
+
initial = false
|
|
168
|
+
})
|
|
169
|
+
this._syncDismissible()
|
|
170
|
+
return this
|
|
171
|
+
}
|
|
172
|
+
|
|
109
173
|
override show(): void {
|
|
110
|
-
if (this.
|
|
111
|
-
this.
|
|
112
|
-
|
|
174
|
+
if (this._showSignal) { this._showSignal.value = true; return }
|
|
175
|
+
if (this._bound) { console.error("show() on a modal bound with bindShow(() => …) — flip the data it follows"); return }
|
|
176
|
+
this._setOpen(true)
|
|
113
177
|
}
|
|
114
178
|
|
|
115
179
|
override hide(): void {
|
|
116
|
-
if (
|
|
180
|
+
if (this._showSignal) { this._showSignal.value = false; return }
|
|
181
|
+
if (this._bound) { console.error("hide() on a modal bound with bindShow(() => …) — flip the data it follows"); return }
|
|
182
|
+
this._setOpen(false)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
override _setOpen(open: boolean): void {
|
|
117
186
|
this._syncPose()
|
|
118
|
-
super.
|
|
187
|
+
super._setOpen(open)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** @internal A dismissal gesture — the scrim tap, the back press, a sheet dragged below its
|
|
191
|
+
* lowest detent — asks to close. The default hides an unbound modal and writes `false` into a
|
|
192
|
+
* bound signal; a bound function has no default (the data decides, through `onDismiss`). */
|
|
193
|
+
_dismiss(): void {
|
|
194
|
+
if (!this._dismissible) return
|
|
195
|
+
if (this._dismissListeners.length > 0) {
|
|
196
|
+
for (const callback of this._dismissListeners.slice()) callback()
|
|
197
|
+
flushEffects() // the data's flip lands now, so the sheet below knows whether it stays
|
|
198
|
+
this._afterDismiss()
|
|
199
|
+
return
|
|
200
|
+
}
|
|
201
|
+
if (this._showSignal) { this._showSignal.value = false; return }
|
|
202
|
+
if (!this._bound) this._setOpen(false)
|
|
119
203
|
}
|
|
120
204
|
|
|
121
|
-
|
|
122
|
-
|
|
205
|
+
/** @internal After the dismiss callbacks: a sheet still shown puts itself back on its detent. */
|
|
206
|
+
_afterDismiss(): void {}
|
|
207
|
+
|
|
208
|
+
/** @internal Can a gesture close it? What the host is told (a sheet's drag resists otherwise). */
|
|
209
|
+
_dismissAllowed(): boolean {
|
|
210
|
+
return this._dismissible && (!this._bound || this._showSignal !== undefined || this._dismissListeners.length > 0)
|
|
123
211
|
}
|
|
124
212
|
|
|
213
|
+
/** @internal The host's copy of `_dismissAllowed()` (a sheet writes `sheetDismissible`). */
|
|
214
|
+
_syncDismissible(): void {}
|
|
215
|
+
|
|
125
216
|
/** @internal onOpen / onClose follow the intent, whoever flipped it. */
|
|
126
217
|
override _shownChanged(shown: boolean): void {
|
|
127
218
|
for (const callback of (shown ? this._openListeners : this._closeListeners).slice()) callback()
|
|
128
219
|
}
|
|
129
220
|
|
|
221
|
+
onDismiss(callback: () => void): this {
|
|
222
|
+
this._dismissListeners.push(callback)
|
|
223
|
+
this._syncDismissible()
|
|
224
|
+
return this
|
|
225
|
+
}
|
|
130
226
|
onOpen(callback: () => void): this {
|
|
131
227
|
this._openListeners.push(callback)
|
|
132
228
|
return this
|
|
@@ -137,15 +233,16 @@ export class ModalElement extends WidgetElement {
|
|
|
137
233
|
}
|
|
138
234
|
dismissible(enabled: boolean): this {
|
|
139
235
|
this._dismissible = enabled
|
|
236
|
+
this._syncDismissible()
|
|
140
237
|
return this
|
|
141
238
|
}
|
|
142
239
|
}
|
|
143
240
|
|
|
144
241
|
const makeModal = (style: any, children: any) => new ModalElement(style, children)
|
|
145
242
|
|
|
146
|
-
/** Create a modal dialog (see {@link UIModal}):
|
|
147
|
-
* `
|
|
148
|
-
* Same argument forms as `UIColumn`. */
|
|
243
|
+
/** Create a modal dialog (see {@link UIModal}): give it to its screen (`.widgets(dialog)`) and
|
|
244
|
+
* `show()` it from a tap, bind it to the data with `bindShow(() => …)`, or `show()` a parentless
|
|
245
|
+
* one from anywhere. Same argument forms as `UIColumn`. */
|
|
149
246
|
export function UIModal(...children: UIChildArg[]): UIModal;
|
|
150
247
|
export function UIModal(children: UINodeChild[] | ChildrenFn): UIModal;
|
|
151
248
|
export function UIModal(...args: any[]): UIModal {
|