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.
Files changed (50) hide show
  1. package/dist/global.d.ts +7 -7
  2. package/dist/types/gl/Foliage.d.ts +6 -0
  3. package/dist/types/inject.d.ts +1 -1
  4. package/dist/types/runtime/firstCall.d.ts +12 -0
  5. package/dist/types/runtime/rpc.d.ts +24 -0
  6. package/dist/types/runtime/wire.d.ts +11 -4
  7. package/dist/types/server/channel.d.ts +16 -0
  8. package/dist/types/server/computed.d.ts +59 -0
  9. package/dist/types/server/context.d.ts +13 -1
  10. package/dist/types/server/db/fields.d.ts +2 -1
  11. package/dist/types/server/db/marci/query.d.ts +53 -6
  12. package/dist/types/server/inject.d.ts +2 -0
  13. package/dist/types/server/ordered.d.ts +18 -0
  14. package/dist/types/ui/UIBottomSheet.d.ts +21 -14
  15. package/dist/types/ui/UIModal.d.ts +50 -22
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +2 -2
  19. package/src/bridges/gl.d.ts +4 -1
  20. package/src/chisel.ts +1 -1
  21. package/src/compile/compileProject.ts +7 -2
  22. package/src/compile/serverSplit.ts +8 -4
  23. package/src/compile/serverTypes.ts +17 -3
  24. package/src/gl/Foliage.ts +11 -1
  25. package/src/inject.ts +3 -2
  26. package/src/runtime/firstCall.ts +28 -0
  27. package/src/runtime/rpc.ts +141 -12
  28. package/src/runtime/wire.ts +11 -6
  29. package/src/server/channel.ts +32 -0
  30. package/src/server/channelHub.ts +246 -16
  31. package/src/server/clock.ts +50 -0
  32. package/src/server/computed.ts +84 -0
  33. package/src/server/computedHost.ts +413 -0
  34. package/src/server/context.ts +14 -2
  35. package/src/server/db/defineDb.ts +47 -7
  36. package/src/server/db/fields.ts +2 -1
  37. package/src/server/db/httpTransport.ts +37 -2
  38. package/src/server/db/httpWatch.ts +180 -0
  39. package/src/server/db/marci/query.ts +112 -7
  40. package/src/server/db/watch.ts +160 -0
  41. package/src/server/files/host.ts +2 -1
  42. package/src/server/host.ts +19 -4
  43. package/src/server/inject.ts +6 -2
  44. package/src/server/ordered.ts +41 -0
  45. package/src/server/rateLimit.ts +72 -0
  46. package/src/server/runtime.ts +127 -9
  47. package/src/ui/UIBottomSheet.ts +39 -30
  48. package/src/ui/UIModal.ts +128 -31
  49. package/src/ui/UIWidget.ts +21 -8
  50. 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"
@@ -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, `onOpen`/`onClose`, back-button and scrim-tap dismissal all come from the
19
- * modal machinery.
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
- /** Mount and slide in to the current detent. No-op while already open. */
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, then unmount. No-op while closed. */
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
- /** Called when `show()` mounts the sheet. */
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 — scrim tap, back button, drag-dismiss, or `hide()`. */
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.hide(); return }
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
- override show(): void {
112
- if (this._shown) return
113
- // Before the mount, so the built node carries the target and hosts play the entrance to it.
114
- this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
115
- super.show()
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), animates on `show()`/`hide()` and on
23
- * a state block flipping its `display` (a 200 ms fade by default — swap the pose via
24
- * `transition()`; the runtime plays it as tracks that never touch the style), and closes itself
25
- * on a scrim tap or the Android back button (disable via `dismissible(false)`). Everything else
26
- * is a plain widget: declare it where it belongs — among its screen's children, hidden until a
27
- * tap (`display: "none"`), or shown with `show()` from anywhere, which puts a parentless modal in
28
- * the app layer. A dialog bound to data (its display from a binding) is declared
29
- * `dismissible(false)` and routes `onOverlayTap` / `onBack` into the data's dismiss, so the data
30
- * stays what the screen shows.
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
- /** Show and play the entrance transition. No-op while already open. */
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 closed or already closing. */
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
- /** Called when the modal opens — `show()`, or a state block flipping its display. */
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 closing — scrim tap, back button, a `hide()` call, or a state
55
- * block flipping its display. */
81
+ /** Called when the modal starts hiding — whoever hid it. */
56
82
  onClose(callback: () => void): this,
57
- /** `dismissible(false)` keeps scrim taps and the back button from closing the modal
58
- * (forced-choice dialogs, a dialog whose display is bound to data). Default `true`. */
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
- super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", ...style }, children)
72
- this.onOverlayTap(() => { if (this._dismissible) this.hide() })
73
- this.onBack(() => { if (this._dismissible) this.hide() })
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._shown) return
111
- this._syncPose()
112
- super.show()
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 (!this._shown) return
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.hide()
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
- get isOpen(): boolean {
122
- return this._shown
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}): declare it among its screen's children with
147
- * `display: "none"` and `show()` it from a tap, or `show()` a parentless one from anywhere.
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 {