lecodes-sdk 2.2.13 → 2.3.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 (48) hide show
  1. package/dist/global.d.ts +11 -0
  2. package/dist/types/gl/Scene.d.ts +0 -11
  3. package/dist/types/gl/postProcessing.d.ts +31 -2
  4. package/dist/types/inject.d.ts +3 -2
  5. package/dist/types/plugin.d.ts +3 -0
  6. package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
  7. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
  8. package/dist/types/runtime/device.d.ts +7 -19
  9. package/dist/types/runtime/pushSeam.d.ts +23 -0
  10. package/dist/types/runtime/rpc.d.ts +15 -1
  11. package/dist/types/server/auth/models.d.ts +13 -1
  12. package/dist/types/server/auth/types.d.ts +2 -0
  13. package/dist/types/server/inject.d.ts +1 -0
  14. package/dist/types/server/pushChannel.d.ts +79 -0
  15. package/dist/types/ui/UINode.d.ts +1 -1
  16. package/dist/types/version.d.ts +1 -1
  17. package/dist/types.json +1 -1
  18. package/package.json +1 -1
  19. package/src/animate/tween/Timeline.ts +175 -175
  20. package/src/bridges/gl.d.ts +5 -13
  21. package/src/compile/serverSplit.ts +29 -19
  22. package/src/compile/serverTypes.ts +1 -1
  23. package/src/gl/CameraPlace.ts +52 -52
  24. package/src/gl/Light.ts +257 -257
  25. package/src/gl/Mesh.ts +120 -120
  26. package/src/gl/Scene.ts +0 -13
  27. package/src/gl/animation/DynamicBone.ts +486 -486
  28. package/src/gl/postProcessing.ts +52 -9
  29. package/src/gl/scenarios.ts +291 -291
  30. package/src/inject.ts +3 -2
  31. package/src/plugin.ts +3 -0
  32. package/src/plugins/gen/push/contract.d.ts +24 -2
  33. package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
  34. package/src/plugins/gen/push/sdk/push.ts +159 -55
  35. package/src/runtime/device.ts +8 -37
  36. package/src/runtime/pushSeam.ts +22 -0
  37. package/src/runtime/rpc.ts +35 -1
  38. package/src/server/auth/host.ts +40 -9
  39. package/src/server/auth/models.ts +5 -1
  40. package/src/server/auth/types.ts +2 -0
  41. package/src/server/host.ts +6 -0
  42. package/src/server/inject.ts +3 -2
  43. package/src/server/push/host.ts +143 -0
  44. package/src/server/pushChannel.ts +121 -0
  45. package/src/server/runtime.ts +20 -8
  46. package/src/ui/UIInput.ts +2 -2
  47. package/src/ui/UINode.ts +1 -1
  48. package/src/version.ts +1 -1
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The two models sign-in adds to a database — `defineDb({...}).withAuth({ model: "User" })`:
3
3
  *
4
- * Session one device: the hash of its token, and the user it is signed in as (none = a guest)
4
+ * Session one device: the hash of its token, the user it is signed in as (none = a guest), and the
5
+ * device's push address when it told one (what a push channel's `send` reaches the user by)
5
6
  * Identity one way a user signs in: `email:<address>` or `login:<name>` (with the password's hash
6
7
  * when one is set) — several per user.
7
8
  *
@@ -40,6 +41,9 @@ export const sessionFields = (userModel: string) => ({
40
41
  codeHash: t.string().optional(),
41
42
  codeExpires: t.date().optional(),
42
43
  codeAttempts: t.int().default(0),
44
+ /** The device's address at the platform's push relay (`x-lecodes-push` of its calls) — the session's own
45
+ * secret, like the token: never read back by a project (../push/host.ts sends by it). */
46
+ pushAddress: t.string().optional(),
43
47
  user: t.one(userModel).optional().onDelete("cascade"),
44
48
  })
45
49
 
@@ -13,6 +13,8 @@ import type { RequestContext } from "../context"
13
13
  export type AuthState = {
14
14
  sessionId: number
15
15
  userId: number | null
16
+ /** The push address of the device, as the session holds it after this request's header was read (null = none). */
17
+ pushAddress?: string | null
16
18
  /** A token to hand back to the client in this response (a new guest session, or a rotation). */
17
19
  issuedToken?: string
18
20
  }
@@ -21,6 +21,9 @@ export type { ServerManifest, ServerExport } from "../compile/serverSplit"
21
21
  export { validateArgs, ValidationError } from "./validate"
22
22
  export type { ParamSchema, Schema as ParamSchemaNode } from "./validate"
23
23
  export { createAuthHost, sha256, hashPassword, verifyPassword } from "./auth/host"
24
+ export { createPushHost, PUSH_BATCH } from "./push/host"
25
+ export type { PushHost, PushHostConfig, PushRelay, PushRelayMessage, PushRelayResult } from "./push/host"
26
+ export type { PushSender } from "./pushChannel"
24
27
  export type { AuthHost, AuthHostConfig, AuthMail } from "./auth/host"
25
28
  export type { AuthState, AuthOps } from "./auth/types"
26
29
  export type { AppConfig } from "./auth/appConfig"
@@ -52,6 +55,8 @@ export type ServerDescription = {
52
55
  models: Record<string, { fields: Record<string, FieldDescription>, auth?: true, files?: true }>
53
56
  endpoints: { id: string, params: unknown[] | null }[]
54
57
  channels: string[]
58
+ /** The push channels (`pushChannel()` exports), by id. */
59
+ pushChannels: string[]
55
60
  /** The project's model of a user (`null` = its db has no sign-in). */
56
61
  auth: { model: string | null }
57
62
  /** `app.json` name (when the compile embedded one). */
@@ -99,6 +104,7 @@ export const describeServer = (server: LoadedServer): ServerDescription => {
99
104
  models,
100
105
  endpoints: [...server.endpoints.keys()].map(id => ({ id, params: params[id] ?? null })),
101
106
  channels: [...server.channels.keys()],
107
+ pushChannels: [...server.pushChannels.keys()],
102
108
  auth: { model: db?.$auth?.model ?? null },
103
109
  appName: server.manifest.app?.name,
104
110
  }
@@ -3,13 +3,14 @@
3
3
  * chisel injects these into the server bundle exactly like ./inject.ts does for the client bundle;
4
4
  * nothing here may reach the client bundle. Keep this the ONLY file that exports server globals.
5
5
  *
6
- * Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, request. Sign-in is not a
7
- * global: it is `db.auth` of a db made with `defineDb({...}).withAuth({ model })`.
6
+ * Globals: t, model, defineDb, ref (schema + queries), ApiError, channel, pushChannel, request. Sign-in is
7
+ * not a global: it is `db.auth` of a db made with `defineDb({...}).withAuth({ model })`.
8
8
  */
9
9
 
10
10
  export { t, model, defineDb, ref } from "./db"
11
11
  export { ApiError } from "./errors"
12
12
  export { channel } from "./channel"
13
+ export { pushChannel, type PushChannel, type PushMessage, type PushNotification, type PushTarget } from "./pushChannel"
13
14
  export { request } from "./context"
14
15
  export type { StoredFile, StoredImage } from "./files/models"
15
16
  /** The value an SVG source is to the client (`assetIcon(…)` compiles to `SvgSource(…)`): a shared module of
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The HOST side of push channels (../pushChannel.ts): what `send` does, run where the runner imports
3
+ * `sdk/server` (the project worker, the CLI's local backend) over the sessions of the db with sign-in.
4
+ *
5
+ * createPushHost({ db, relay }).install() // the bundle's `send` reaches it through globalThis.__lecodesPushSend
6
+ *
7
+ * A user's devices are their sessions: each carries the address its device told the transport
8
+ * (`Session.pushAddress`, written by the auth host from the `x-lecodes-push` header). `send` takes the
9
+ * live sessions of the users, their addresses once each, and hands them to the RELAY — the host's
10
+ * road to the platform's sender (the runner: the platform's internal route under its shared secret;
11
+ * `lecodes dev`: the developer's own login) — in batches of what the platform takes per request. The
12
+ * relay answers what did not go: an address the platform knows no more (`unknown`: the device
13
+ * unregistered, a registration row gone) or has disabled (reason "disabled") is a dead one, and is
14
+ * dropped from its session so it is not tried again; any other failure (the platform's credentials, a
15
+ * provider's hiccup) leaves the address where it is.
16
+ *
17
+ * A host with no relay sends nothing and says so once per channel in the server console — a check, a
18
+ * run without a login — and `send` answers 0: a notification with nowhere to go is not the app's bug.
19
+ */
20
+
21
+ import type { AuthBinding } from "../db/types"
22
+ import { setPushSender, type PushMessage } from "../pushChannel"
23
+
24
+ /** What goes to the platform for one batch of addresses. */
25
+ export type PushRelayMessage = {
26
+ /** The push channel's id (`<path>#<export>`): the app's side hears each channel's own. */
27
+ channel: string
28
+ title: string
29
+ body?: string
30
+ url?: string
31
+ data?: Record<string, unknown>
32
+ badge?: number
33
+ }
34
+ export type PushRelayResult = {
35
+ /** How many devices the platform sent to. */
36
+ sent: number
37
+ /** What it could not send to, by address: "disabled" = the platform has given the device up. */
38
+ failed: { address: string, reason: string }[]
39
+ /** Addresses the platform does not know (the whole batch was refused for them): the host drops them
40
+ * from their sessions and sends the rest again. */
41
+ unknown?: string[]
42
+ }
43
+ /** The host's road to the platform's sender, for up to `PUSH_BATCH` addresses at a time. */
44
+ export type PushRelay = (to: string[], message: PushRelayMessage) => Promise<PushRelayResult>
45
+
46
+ export type PushHostConfig = {
47
+ /** The project's db with sign-in (its sessions are the devices); `null`, or a db without it = `send` fails: there are no devices. */
48
+ db: { readonly $auth: AuthBinding | null } | null
49
+ /** Absent = this host sends nothing (`send` answers 0 and warns). */
50
+ relay?: PushRelay | null
51
+ /** Server console sink (default console). */
52
+ log?: (level: "warn" | "info", text: string) => void
53
+ }
54
+
55
+ export type PushHost = {
56
+ /** Is there a relay? Without one `send` answers 0. */
57
+ readonly enabled: boolean
58
+ /** Install `send` for the bundle's push channels (`globalThis.__lecodesPushSend`). */
59
+ install(): void
60
+ send(channelId: string, userIds: number[], message: PushMessage<any>): Promise<{ sent: number }>
61
+ }
62
+
63
+ /** What the platform takes per request. */
64
+ export const PUSH_BATCH = 500
65
+
66
+ const chunks = <T>(list: T[], size: number): T[][] => {
67
+ const out: T[][] = []
68
+ for (let i = 0; i < list.length; i += size) out.push(list.slice(i, i + size))
69
+ return out
70
+ }
71
+
72
+ export const createPushHost = (cfg: PushHostConfig): PushHost => {
73
+ const sessions = cfg.db?.$auth?.session ?? null
74
+ const relay = cfg.relay ?? null
75
+ const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
76
+ const warned = new Set<string>()
77
+
78
+ type Row = { id: number, pushAddress: string | null }
79
+ /** The live sessions of these users that have a device, by address (one session per address — the latest). */
80
+ const devices = async (userIds: number[]): Promise<Map<string, number[]>> => {
81
+ const rows: Row[] = await sessions.where({ user: { id: { $in: userIds } }, revoked: false }).select({ id: true, pushAddress: true })
82
+ const byAddress = new Map<string, number[]>()
83
+ for (const row of rows) {
84
+ if (!row.pushAddress) continue
85
+ const list = byAddress.get(row.pushAddress) ?? []
86
+ list.push(row.id)
87
+ byAddress.set(row.pushAddress, list)
88
+ }
89
+ return byAddress
90
+ }
91
+ /** A dead address: no session keeps it. */
92
+ const forget = async (byAddress: Map<string, number[]>, addresses: string[]): Promise<void> => {
93
+ const ids = addresses.flatMap(a => byAddress.get(a) ?? [])
94
+ if (!ids.length) return
95
+ await sessions.where({ id: { $in: ids } }).updateMany({ pushAddress: null })
96
+ }
97
+
98
+ const send = async (channelId: string, userIds: number[], message: PushMessage<any>): Promise<{ sent: number }> => {
99
+ if (!sessions) {
100
+ throw new Error("pushChannel.send: the project's database has no sign-in — a user's devices are the sessions of defineDb({...}).withAuth({ model }), without it there is no one to send to")
101
+ }
102
+ if (!relay) {
103
+ if (!warned.has(channelId)) {
104
+ warned.add(channelId)
105
+ log("warn", `${channelId}: notifications are not sent from this backend (no road to the platform's sender) — a deployed backend sends them`)
106
+ }
107
+ return { sent: 0 }
108
+ }
109
+ const byAddress = await devices(userIds)
110
+ if (byAddress.size === 0) return { sent: 0 }
111
+ const wire: PushRelayMessage = {
112
+ channel: channelId,
113
+ title: message.title,
114
+ ...(message.body !== undefined ? { body: message.body } : {}),
115
+ ...(message.url !== undefined ? { url: message.url } : {}),
116
+ ...(message.data !== undefined ? { data: message.data as Record<string, unknown> } : {}),
117
+ ...(message.badge !== undefined ? { badge: message.badge } : {}),
118
+ }
119
+ let sent = 0
120
+ for (let batch of chunks([...byAddress.keys()], PUSH_BATCH)) {
121
+ let result = await relay(batch, wire)
122
+ // the platform knows none of these any more: drop them, and send to the rest
123
+ if (result.unknown?.length) {
124
+ const unknown = new Set(result.unknown)
125
+ await forget(byAddress, [...unknown])
126
+ batch = batch.filter(a => !unknown.has(a))
127
+ if (!batch.length) continue
128
+ result = await relay(batch, wire)
129
+ if (result.unknown?.length) await forget(byAddress, result.unknown)
130
+ }
131
+ sent += result.sent
132
+ const dead = result.failed.filter(f => f.reason === "disabled").map(f => f.address)
133
+ if (dead.length) await forget(byAddress, dead)
134
+ }
135
+ return { sent }
136
+ }
137
+
138
+ return {
139
+ get enabled() { return relay !== null },
140
+ install() { setPushSender(send) },
141
+ send,
142
+ }
143
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Push channels: the server's notifications to a person's devices — the twin of ./channel.ts for what
3
+ * reaches someone while the app is closed. One export types both sides:
4
+ *
5
+ * export const orderShippedPushChannel = pushChannel<{ orderId: number }>() // pushes.server.ts
6
+ *
7
+ * await orderShippedPushChannel.send(order.owner, { title: "Order shipped", data: { orderId } }) // server
8
+ *
9
+ * orderShippedPushChannel.onTap(({ data }) => Router.push(OrderScreen(data.orderId))) // app
10
+ * orderShippedPushChannel.onMessage(({ title }) => toast(title))
11
+ *
12
+ * `send` reaches every device the user is signed in on. The devices are the sessions of the project's db
13
+ * with sign-in (`defineDb(...).withAuth(...)`): a session carries the address of its device — the app's
14
+ * transport tells it with every call, and the server asks the device to register when a signed-in
15
+ * session has none (the first response after a sign-in; the OS asks the permission then) — and a
16
+ * sign-out leaves the device on a guest session, which no `send` reaches. A user with no device is zero
17
+ * sends, not an error. `send` needs no request: a timer may call it.
18
+ *
19
+ * The server holds no push key: the host sends through the platform (./push/host.ts), reached here over
20
+ * the `globalThis.__lecodesPushSend` seam (the compiled bundle carries its own SDK copy — ./context.ts).
21
+ * A channel's identity is its export (`<path>#<name>`), assigned when the bundle is loaded
22
+ * (`loadServerModules`), and it travels in the notification: the app's side hears each channel's own.
23
+ *
24
+ * In the app the export is another object — the proxy `__pushChannel` of src/runtime/rpc.ts — which is
25
+ * why `onTap` / `onMessage` here only throw.
26
+ */
27
+
28
+ import type { ChannelSubscription } from "./channel"
29
+
30
+ export type { ChannelSubscription }
31
+
32
+ /** Whom a notification goes to: a user id, or a row that has one (`db.auth.requireUser().select({ id: true })`, a relation). */
33
+ export type PushTarget = number | { id: number }
34
+
35
+ /** What a notification shows, and what it carries for the app (`data`, the channel's type). */
36
+ export type PushMessage<D> = {
37
+ /** Up to 200 characters. */
38
+ title: string
39
+ /** The second line, up to 1000 characters. */
40
+ body?: string
41
+ /** A link for the app to open on a tap (`onTap` gets it — it is not opened by itself). */
42
+ url?: string
43
+ /** The number on the app's icon (iOS; 0 clears it). */
44
+ badge?: number
45
+ } & (undefined extends D ? { data?: D } : { data: D })
46
+
47
+ /** A notification as the app receives it: what was sent, and the channel it came through. */
48
+ export type PushNotification<D> = PushMessage<D> & { channel: string }
49
+
50
+ export interface PushChannel<D extends Record<string, unknown> | undefined = undefined> {
51
+ /** Server: send `message` to every device `user` is signed in on (one id or several). Answers how many
52
+ * devices it went to — none is 0, not an error. */
53
+ send(user: PushTarget | PushTarget[], message: PushMessage<D>): Promise<{ sent: number }>
54
+ /** App: a notification of this channel was tapped — while the app ran, or to start it. Lives until `close()`. */
55
+ onTap(handler: (notification: PushNotification<D>) => void): ChannelSubscription
56
+ /** App: a notification of this channel arrived while the app was in the foreground: the app shows it
57
+ * (the host shows no banner); a channel without this handler gets the system banner. */
58
+ onMessage(handler: (notification: PushNotification<D>) => void): ChannelSubscription
59
+ }
60
+
61
+ /** What the host reads of a push channel (src/server/runtime.ts). */
62
+ export type PushChannelRecord = {
63
+ /** set by the runtime: `<path>#<export>` */
64
+ __id: string | null
65
+ readonly __pushChannel: true
66
+ }
67
+
68
+ /** The host's sender (./push/host.ts): the channel, the users' ids, the message → how many devices. */
69
+ export type PushSender = (channelId: string, userIds: number[], message: PushMessage<any>) => Promise<{ sent: number }>
70
+ const seam = globalThis as unknown as { __lecodesPushSend?: PushSender }
71
+
72
+ /** Host hook (runner / local backend / test harness): where `send` delivers to. */
73
+ export const setPushSender = (sender: PushSender | null) => {
74
+ if (sender) seam.__lecodesPushSend = sender
75
+ else delete seam.__lecodesPushSend
76
+ }
77
+
78
+ const TITLE_MAX = 200
79
+
80
+ /** The ids of `send`'s first argument, each once; throws on what is not a user. */
81
+ export const pushTargetIds = (user: unknown): number[] => {
82
+ const list = Array.isArray(user) ? user : [user]
83
+ const ids = new Set<number>()
84
+ for (const u of list) {
85
+ const id = typeof u === "number" ? u : u && typeof u === "object" ? (u as { id?: unknown }).id : undefined
86
+ if (typeof id !== "number" || !Number.isInteger(id)) {
87
+ throw new Error(`pushChannel.send: a user is an id or a row with one ({ id }), got ${u === null ? "null" : Array.isArray(u) ? "an array" : typeof u}`)
88
+ }
89
+ ids.add(id)
90
+ }
91
+ return [...ids]
92
+ }
93
+
94
+ export const pushChannel = <D extends Record<string, unknown> | undefined = undefined>(): PushChannel<D> => {
95
+ const ch: PushChannelRecord & Record<string, unknown> = {
96
+ __id: null,
97
+ __pushChannel: true,
98
+ async send(user: unknown, message: PushMessage<any>) {
99
+ if (!ch.__id) throw new Error("pushChannel.send: the channel is not registered — export it from a *.server.ts file")
100
+ const sender = seam.__lecodesPushSend
101
+ if (!sender) throw new Error("pushChannel.send: no host sender installed")
102
+ const ids = pushTargetIds(user)
103
+ if (!message || typeof message !== "object") throw new Error("pushChannel.send: send(user, { title, … })")
104
+ const title = typeof message.title === "string" ? message.title.trim() : ""
105
+ if (!title) throw new Error("pushChannel.send: a notification needs a title")
106
+ if (title.length > TITLE_MAX) throw new Error(`pushChannel.send: the title is longer than ${TITLE_MAX} characters`)
107
+ if (ids.length === 0) return { sent: 0 }
108
+ return sender(ch.__id, ids, { ...message, title })
109
+ },
110
+ onTap() {
111
+ throw new Error("pushChannel.onTap: the app listens, the server sends")
112
+ },
113
+ onMessage() {
114
+ throw new Error("pushChannel.onMessage: the app listens, the server sends")
115
+ },
116
+ }
117
+ return ch as unknown as PushChannel<D>
118
+ }
119
+
120
+ export const isPushChannel = (v: unknown): v is PushChannelRecord =>
121
+ typeof v === "object" && v !== null && (v as PushChannelRecord).__pushChannel === true
@@ -16,6 +16,7 @@ import type { AuthHost } from "./auth/host"
16
16
  import type { AuthState } from "./auth/types"
17
17
  import type { FilesHost, ResourceReader } from "./files/host"
18
18
  import { isChannel, isChannelGroup, setChannelPublisher, type ChannelRecord } from "./channel"
19
+ import { isPushChannel, type PushChannelRecord } from "./pushChannel"
19
20
  import { setRequestProvider, type RequestContext } from "./context"
20
21
  import type { Db } from "./db/types"
21
22
  import { takeRegisteredDbs } from "./db/defineDb"
@@ -26,6 +27,8 @@ export type ServerRegistration = { modules: Record<string, Record<string, unknow
26
27
  export type LoadedServer = {
27
28
  endpoints: Map<string, (...args: any[]) => unknown>
28
29
  channels: Map<string, ChannelRecord>
30
+ /** The push channels (`pushChannel()` exports): their presence is what makes a response ask a signed-in device to register. */
31
+ pushChannels: Map<string, PushChannelRecord>
29
32
  /** The project's `defineDb` handle — exported or not (the bundle registers it on globalThis); none or one. */
30
33
  dbs: Db<any>[]
31
34
  manifest: ServerManifest
@@ -38,7 +41,7 @@ export const loadServerModules = (
38
41
  reg: ServerRegistration | undefined = (globalThis as any).__lecodesServer,
39
42
  ): LoadedServer => {
40
43
  if (!reg) throw new Error("loadServerModules: no server bundle registered (globalThis.__lecodesServer)")
41
- const out: LoadedServer = { endpoints: new Map(), channels: new Map(), dbs: [], manifest: reg.manifest }
44
+ const out: LoadedServer = { endpoints: new Map(), channels: new Map(), pushChannels: new Map(), dbs: [], manifest: reg.manifest }
42
45
  for (const [mod, exports] of Object.entries(reg.modules)) {
43
46
  // the compile's word on each export (serverSplit.ts): a function that is not async is the
44
47
  // server's helper, never an endpoint — an old manifest without kinds counts every function
@@ -47,6 +50,7 @@ export const loadServerModules = (
47
50
  const id = `${mod}#${name}`
48
51
  if (typeof value === "function") { if (kinds.get(name) !== "other") out.endpoints.set(id, value as (...args: any[]) => unknown) }
49
52
  else if (isChannel(value)) { value.__id = id; out.channels.set(id, value) }
53
+ else if (isPushChannel(value)) { value.__id = id; out.pushChannels.set(id, value) }
50
54
  else if (isDb(value)) out.dbs.push(value)
51
55
  }
52
56
  }
@@ -61,10 +65,12 @@ export const loadServerModules = (
61
65
  return out
62
66
  }
63
67
 
64
- /** The wire envelope. `session` = a token the client must store from now on (new guest session / rotation). */
68
+ /** The wire envelope. `session` = a token the client must store from now on (new guest session / rotation).
69
+ * `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). */
65
71
  export type InvokeResult =
66
- | { ok: true, result: unknown, session?: string }
67
- | { ok: false, status: number, message: string, session?: string }
72
+ | { ok: true, result: unknown, session?: string, push?: true }
73
+ | { ok: false, status: number, message: string, session?: string, push?: true }
68
74
 
69
75
  export type InvokerOptions = {
70
76
  /** Validate args against the manifest's param schemas (default true; endpoints without a schema pass). */
@@ -104,10 +110,13 @@ const dbStatus = (e: unknown): number | null => {
104
110
  const als = new AsyncLocalStorage<RequestContext>()
105
111
  const installProvider = () => setRequestProvider(() => als.getStore())
106
112
 
107
- // A token minted/rotated during the call rides on the envelope (the client transport stores it).
108
- const withSession = (r: InvokeResult, rc: RequestContext): InvokeResult => {
109
- const issued = (rc.auth as AuthState | undefined)?.issuedToken
110
- return issued ? { ...r, session: issued } : r
113
+ // A token minted/rotated during the call rides on the envelope (the client transport stores it); so does the
114
+ // request to register the device for notifications, when a signed-in session has no address and there are
115
+ // push channels to hear.
116
+ const envelope = (server: LoadedServer) => (r: InvokeResult, rc: RequestContext): InvokeResult => {
117
+ const s = rc.auth as AuthState | undefined
118
+ const out = s?.issuedToken ? { ...r, session: s.issuedToken } : r
119
+ return s && s.userId !== null && !s.pushAddress && server.pushChannels.size ? { ...out, push: true } : out
111
120
  }
112
121
 
113
122
  export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) => {
@@ -117,6 +126,7 @@ export const createInvoker = (server: LoadedServer, opts: InvokerOptions = {}) =
117
126
  const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
118
127
  const auth = opts.auth ?? null
119
128
  const files = opts.files ?? null
129
+ const withSession = envelope(server)
120
130
 
121
131
  return async (id: string, args: unknown[], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
122
132
  const fn = server.endpoints.get(id)
@@ -233,6 +243,7 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
233
243
  const onError = opts.onError ?? ((id, e) => console.error(`[server] ${id}:`, e))
234
244
  const params = server.manifest.params as Record<string, ParamSchema[]> | undefined
235
245
  const auth = opts.auth ?? null
246
+ const withSession = envelope(server)
236
247
  return async (channelId: string, args: unknown[] = [], ctx: Omit<RequestContext, "id"> = { headers: {} }): Promise<InvokeResult> => {
237
248
  const ch = server.channels.get(channelId)
238
249
  if (!ch) return { ok: false, status: 404, message: `Unknown channel ${channelId}` }
@@ -258,3 +269,4 @@ export const createSubscribeGuard = (server: LoadedServer, opts: InvokerOptions
258
269
  }
259
270
 
260
271
  export { setChannelPublisher, setRequestProvider }
272
+ export { setPushSender } from "./pushChannel"
package/src/ui/UIInput.ts CHANGED
@@ -168,8 +168,8 @@ class InputElement<T extends "input" | "textarea"> extends Element<T> {
168
168
  export function UIInput(): UIInput;
169
169
  export function UIInput(style: UIInputStyle): UIInput;
170
170
  export function UIInput(style?: UIInputStyle): UIInput {
171
- // One text line as the floor — an unstyled input must never collapse to 0 height.
172
- return new InputElement("input", { minHeight: "1.2em", ...style })
171
+ // A touch target as the floor — an unstyled input is a finger high, never a text line.
172
+ return new InputElement("input", { minHeight: 44, ...style })
173
173
  }
174
174
 
175
175
  /**
package/src/ui/UINode.ts CHANGED
@@ -176,7 +176,7 @@ export type ElementStyle = PaddingStyle & MarginStyle & BaseStyle & PositionStyl
176
176
  flex?: number | string,
177
177
  flexGrow?: number,
178
178
  flexShrink?: number,
179
- flexBase?: UIValue | `${number}%` | "auto",
179
+ flexBasis?: UIValue | `${number}%` | "auto",
180
180
  alignSelf?: "flex-start" | "center" | "flex-end" | "stretch",
181
181
  display?: "none" | "flex"
182
182
  }
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.2.13"
7
+ export const SDK_VERSION = "2.3.0"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {