lecodes-sdk 2.2.13 → 2.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/global.d.ts +11 -0
- package/dist/types/inject.d.ts +3 -2
- package/dist/types/plugin.d.ts +3 -0
- package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
- package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
- package/dist/types/runtime/pushSeam.d.ts +23 -0
- package/dist/types/runtime/rpc.d.ts +15 -1
- package/dist/types/server/auth/models.d.ts +13 -1
- package/dist/types/server/auth/types.d.ts +2 -0
- package/dist/types/server/inject.d.ts +1 -0
- package/dist/types/server/pushChannel.d.ts +79 -0
- package/dist/types/ui/UINode.d.ts +1 -1
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +1 -1
- package/src/compile/serverSplit.ts +29 -19
- package/src/compile/serverTypes.ts +1 -1
- package/src/inject.ts +3 -2
- package/src/plugin.ts +3 -0
- package/src/plugins/gen/push/contract.d.ts +24 -2
- package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
- package/src/plugins/gen/push/sdk/push.ts +159 -55
- package/src/runtime/pushSeam.ts +22 -0
- package/src/runtime/rpc.ts +35 -1
- package/src/server/auth/host.ts +40 -9
- package/src/server/auth/models.ts +5 -1
- package/src/server/auth/types.ts +2 -0
- package/src/server/host.ts +6 -0
- package/src/server/inject.ts +3 -2
- package/src/server/push/host.ts +143 -0
- package/src/server/pushChannel.ts +121 -0
- package/src/server/runtime.ts +20 -8
- package/src/ui/UIInput.ts +2 -2
- package/src/ui/UINode.ts +1 -1
- package/src/version.ts +1 -1
package/src/runtime/rpc.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
* export const db = __serverOnly("schema.server.ts#db")
|
|
9
9
|
*
|
|
10
10
|
* Wire (implemented by the runner):
|
|
11
|
-
* POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
|
|
11
|
+
* POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known,
|
|
12
|
+
* `x-lecodes-push: <address>` once the device registered for notifications
|
|
12
13
|
* — with a `File` among the arguments: multipart, the field `args` is
|
|
13
14
|
* that JSON with `{"$file": <i>}` where the file stood, `file<i>` the files
|
|
14
15
|
* → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
|
|
@@ -24,6 +25,7 @@ import { localStorage } from "./storage"
|
|
|
24
25
|
import { WebSocket } from "./net"
|
|
25
26
|
import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
|
|
26
27
|
import type { ChannelSubscription } from "../server/channel"
|
|
28
|
+
import { _pushRuntime } from "./pushSeam"
|
|
27
29
|
|
|
28
30
|
/** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
|
|
29
31
|
* (0 = the request itself failed: no network / no server). */
|
|
@@ -69,6 +71,9 @@ export const __rpc = (serverUrl: string, id: string) => {
|
|
|
69
71
|
const headers: Record<string, string> = {}
|
|
70
72
|
const session = getSession(serverUrl)
|
|
71
73
|
if (session) headers.authorization = `Bearer ${session}`
|
|
74
|
+
// the device's push address, for the session to carry (src/server/auth/host.ts) — what a push channel reaches the user by
|
|
75
|
+
const pushAddress = _pushRuntime.current?.address()
|
|
76
|
+
if (pushAddress) headers["x-lecodes-push"] = pushAddress
|
|
72
77
|
// a File anywhere among the arguments leaves its place to a marker and travels beside them
|
|
73
78
|
const files: File[] = []
|
|
74
79
|
const json = JSON.stringify({ args }, (_key, value) => value instanceof File ? { $file: files.push(value) - 1 } : value)
|
|
@@ -88,6 +93,8 @@ export const __rpc = (serverUrl: string, id: string) => {
|
|
|
88
93
|
let body: any
|
|
89
94
|
try { body = res.json() } catch { body = null }
|
|
90
95
|
if (body && typeof body === "object" && "session" in body) setSession(serverUrl, body.session ?? null)
|
|
96
|
+
// the server asks this device to register for notifications: a signed-in session without an address, and push channels to hear
|
|
97
|
+
if (body && typeof body === "object" && body.push === true) _pushRuntime.current?.requestRegister()
|
|
91
98
|
if (body && body.ok === true) return body.result
|
|
92
99
|
if (body && body.ok === false) throw new RpcError(body.status ?? res.status, body.message ?? "")
|
|
93
100
|
throw new RpcError(res.status, `Endpoint ${id}: unexpected response (HTTP ${res.status})`)
|
|
@@ -287,3 +294,30 @@ export const __channel = (serverUrl: string, id: string) => ({
|
|
|
287
294
|
throw new RpcError(0, `Channel ${id}: the server publishes, the app subscribes — call a server function that publishes`)
|
|
288
295
|
},
|
|
289
296
|
})
|
|
297
|
+
|
|
298
|
+
// ───────────────────────────── push channels ─────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Build the client proxy of push channel `id` — what a `pushChannel()` export of a `*.server.ts` file is
|
|
302
|
+
* in the app (its type is the server's: src/server/pushChannel.ts). The notifications come off the push
|
|
303
|
+
* plugin, through the runtime its wrapper installed (`__pushEnable()`, the first line of the stubs of a
|
|
304
|
+
* project with push channels — ./pushSeam.ts): `onTap` hears this channel's taps (the one that started
|
|
305
|
+
* the app included), `onMessage` its arrivals while the app is in the foreground. On a host without the
|
|
306
|
+
* plugin a handler is recorded and never called, like the plugin's own listeners.
|
|
307
|
+
*/
|
|
308
|
+
export const __pushChannel = (serverUrl: string, id: string) => {
|
|
309
|
+
const listen = (kind: "tap" | "message", handler: unknown): ChannelSubscription => {
|
|
310
|
+
if (typeof handler !== "function") throw new RpcError(0, `Push channel ${id}: ${kind === "tap" ? "onTap" : "onMessage"}(handler) — no handler given`)
|
|
311
|
+
const runtime = _pushRuntime.current
|
|
312
|
+
if (!runtime) throw new RpcError(0, `Push channel ${id}: the push runtime is not installed — the compiler puts __pushEnable() in the stub of a project with push channels`)
|
|
313
|
+
const off = runtime.on(kind, id, handler as (payload: unknown) => void)
|
|
314
|
+
return { close: off }
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
onTap: (handler: unknown): ChannelSubscription => listen("tap", handler),
|
|
318
|
+
onMessage: (handler: unknown): ChannelSubscription => listen("message", handler),
|
|
319
|
+
send(): never {
|
|
320
|
+
throw new RpcError(0, `Push channel ${id}: the server sends, the app listens — call a server function that sends${serverUrl ? "" : " (and compile with serverUrl)"}`)
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
}
|
package/src/server/auth/host.ts
CHANGED
|
@@ -12,7 +12,13 @@
|
|
|
12
12
|
*
|
|
13
13
|
* Tokens: 32 random bytes (base64url) held by the client transport; only `sha256(token)` is stored.
|
|
14
14
|
* A request without a valid token gets a fresh guest session and the token travels back in the
|
|
15
|
-
* envelope (`session`); sign-in/out ROTATE the token the same way
|
|
15
|
+
* envelope (`session`); sign-in/out ROTATE the token the same way (the row stays: what it holds of the
|
|
16
|
+
* device — its push address — survives a sign-out, and a sign-in on the same device finds it there).
|
|
17
|
+
*
|
|
18
|
+
* The push address: the transport sends the device's address at the platform's push relay in the
|
|
19
|
+
* `x-lecodes-push` header of every call (once the device registered); `resolve` keeps the session's
|
|
20
|
+
* `pushAddress` equal to it. That is the whole binding of a device to a user — a push channel's `send`
|
|
21
|
+
* reaches a user through the live sessions that carry an address (../push/host.ts).
|
|
16
22
|
*
|
|
17
23
|
* Identities: every sign-in method is an `Identity` row `(provider, subject)` of a user — `email:<address>`
|
|
18
24
|
* or `login:<name>` (its `hash` = the password). The one rule that keeps passwords honest: proving an
|
|
@@ -114,6 +120,17 @@ const parseLogin = (login: unknown): { provider: "email" | "login", subject: str
|
|
|
114
120
|
|
|
115
121
|
const identityKey = (provider: IdentityProvider, subject: string) => `${provider}:${subject}`
|
|
116
122
|
|
|
123
|
+
/** The header the transport tells the device's push address in (src/runtime/rpc.ts). */
|
|
124
|
+
export const PUSH_ADDRESS_HEADER = "x-lecodes-push"
|
|
125
|
+
const PUSH_ADDRESS_RE = /^[A-Za-z0-9_-]{1,128}$/
|
|
126
|
+
/** The address of this request's device, when the header carries a well-formed one. */
|
|
127
|
+
const pushAddressOf = (ctx: RequestContext): string | null => {
|
|
128
|
+
const headers = ctx.headers ?? {}
|
|
129
|
+
const raw = headers[PUSH_ADDRESS_HEADER] ?? Object.entries(headers).find(([k]) => k.toLowerCase() === PUSH_ADDRESS_HEADER)?.[1]
|
|
130
|
+
const value = typeof raw === "string" ? raw.trim() : ""
|
|
131
|
+
return PUSH_ADDRESS_RE.test(value) ? value : null
|
|
132
|
+
}
|
|
133
|
+
|
|
117
134
|
/** A sliding-window counter per key (in-worker; the persistent limits live on the rows). */
|
|
118
135
|
const window = (map: Map<string, number[]>, key: string, now: number, span: number): number[] => {
|
|
119
136
|
const list = (map.get(key) ?? []).filter(x => now - x < span)
|
|
@@ -146,7 +163,7 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
|
|
|
146
163
|
const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
|
|
147
164
|
|
|
148
165
|
// token → session (60 s). Cleared on every mutation — correctness over reuse.
|
|
149
|
-
const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number }>()
|
|
166
|
+
const sessionCache = new Map<string, { sessionId: number, userId: number | null, pushAddress: string | null, at: number }>()
|
|
150
167
|
const clearCache = () => { sessionCache.clear() }
|
|
151
168
|
|
|
152
169
|
const requireAuth = () => {
|
|
@@ -165,9 +182,10 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
|
|
|
165
182
|
|
|
166
183
|
const mint = async (ctx: RequestContext): Promise<AuthState> => {
|
|
167
184
|
const token = randomToken()
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
185
|
+
const pushAddress = pushAddressOf(ctx)
|
|
186
|
+
const { id } = await sessions.insert({ tokenHash: await sha256(token), ...(pushAddress ? { pushAddress } : {}) })
|
|
187
|
+
const state: AuthState = { sessionId: id, userId: null, pushAddress, issuedToken: token }
|
|
188
|
+
sessionCache.set(token, { sessionId: id, userId: null, pushAddress, at: now() })
|
|
171
189
|
ctx.auth = state
|
|
172
190
|
ctx.sessionToken = token
|
|
173
191
|
return state
|
|
@@ -188,21 +206,34 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
|
|
|
188
206
|
if (opts.rotate) await rotate(ctx)
|
|
189
207
|
}
|
|
190
208
|
|
|
209
|
+
/** The session's push address follows the header: written when it differs (a registration, a new address
|
|
210
|
+
* after the OS rotated the token) — never cleared by a call without the header: the app may not have
|
|
211
|
+
* asked the plugin yet in this run. */
|
|
212
|
+
const followPushAddress = async (token: string, sessionId: number, held: string | null, told: string | null): Promise<string | null> => {
|
|
213
|
+
if (!told || told === held) return held
|
|
214
|
+
await sessions.update(sessionId, { pushAddress: told })
|
|
215
|
+
const c = sessionCache.get(token)
|
|
216
|
+
if (c) c.pushAddress = told
|
|
217
|
+
return told
|
|
218
|
+
}
|
|
219
|
+
|
|
191
220
|
const resolve = async (ctx: RequestContext): Promise<void> => {
|
|
192
221
|
if (!tables) return
|
|
193
222
|
const token = ctx.sessionToken
|
|
194
223
|
if (token) {
|
|
195
224
|
const c = sessionCache.get(token)
|
|
196
225
|
if (c && now() - c.at < CACHE_TTL_MS) {
|
|
197
|
-
|
|
226
|
+
const pushAddress = await followPushAddress(token, c.sessionId, c.pushAddress, pushAddressOf(ctx))
|
|
227
|
+
ctx.auth = { sessionId: c.sessionId, userId: c.userId, pushAddress }
|
|
198
228
|
return
|
|
199
229
|
}
|
|
200
|
-
const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, user: { id: true } }).first()
|
|
230
|
+
const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, pushAddress: true, user: { id: true } }).first()
|
|
201
231
|
if (row && !row.revoked) {
|
|
202
232
|
const userId: number | null = row.user ? row.user.id : null
|
|
203
233
|
if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS) sessions.update(row.id, { lastSeenAt: now() }).then(undefined, () => {})
|
|
204
|
-
sessionCache.set(token, { sessionId: row.id, userId, at: now() })
|
|
205
|
-
|
|
234
|
+
sessionCache.set(token, { sessionId: row.id, userId, pushAddress: row.pushAddress ?? null, at: now() })
|
|
235
|
+
const pushAddress = await followPushAddress(token, row.id, row.pushAddress ?? null, pushAddressOf(ctx))
|
|
236
|
+
ctx.auth = { sessionId: row.id, userId, pushAddress }
|
|
206
237
|
return
|
|
207
238
|
}
|
|
208
239
|
}
|
|
@@ -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,
|
|
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
|
|
package/src/server/auth/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/host.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/inject.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/server/runtime.ts
CHANGED
|
@@ -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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
//
|
|
172
|
-
return new InputElement("input", { minHeight:
|
|
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
|
-
|
|
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.
|
|
7
|
+
export const SDK_VERSION = "2.2.14"
|
|
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 => {
|