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.
- package/dist/global.d.ts +11 -0
- package/dist/types/gl/Scene.d.ts +0 -11
- package/dist/types/gl/postProcessing.d.ts +31 -2
- 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/device.d.ts +7 -19
- 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/animate/tween/Timeline.ts +175 -175
- package/src/bridges/gl.d.ts +5 -13
- package/src/compile/serverSplit.ts +29 -19
- package/src/compile/serverTypes.ts +1 -1
- package/src/gl/CameraPlace.ts +52 -52
- package/src/gl/Light.ts +257 -257
- package/src/gl/Mesh.ts +120 -120
- package/src/gl/Scene.ts +0 -13
- package/src/gl/animation/DynamicBone.ts +486 -486
- package/src/gl/postProcessing.ts +52 -9
- package/src/gl/scenarios.ts +291 -291
- 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/device.ts +8 -37
- 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
|
@@ -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.
|
|
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 => {
|