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/package.json
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* The client/server split of a project (docs/backend-plan.md §4).
|
|
3
3
|
*
|
|
4
4
|
* `*.server.ts` files never reach the client bundle: `compileProject` swaps each one for a STUB
|
|
5
|
-
* module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__serverOnly` — client SDK
|
|
5
|
+
* module whose exports are RPC/channel proxies (`__rpc`, `__channel`, `__pushChannel`, `__serverOnly` — client SDK
|
|
6
6
|
* globals, src/runtime/rpc.ts), so `import { listNotes } from "./notes.server"` in client code keeps
|
|
7
7
|
* its types and calls the server. `compileServerBundle` bundles the same files with the SERVER inject
|
|
8
8
|
* behind a synthetic entry that registers every module on `globalThis.__lecodesServer` (chisel keeps
|
|
9
9
|
* side effects, not entry exports) — the runner reads that (src/server/runtime.ts).
|
|
10
10
|
*
|
|
11
11
|
* Export kinds are found by a static scan (no TS program needed): a function export is an
|
|
12
|
-
* endpoint, a `channel<…>()…` export is a channel, anything else is server-only.
|
|
12
|
+
* endpoint, a `channel<…>()…` export is a channel, a `pushChannel<…>()` export a push channel, anything else is server-only.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { AppConfig } from "../server/auth/appConfig"
|
|
@@ -29,7 +29,7 @@ export const serverModuleId = (path: string) => path.split("\\").join("/").repla
|
|
|
29
29
|
* a helper: a function that is not async is used by server files alone, and the app's import of
|
|
30
30
|
* it is refused at compile time (serverTypes.ts), never a call that silently holds a promise.
|
|
31
31
|
*/
|
|
32
|
-
export type ServerExportKind = "fn" | "channel" | "other"
|
|
32
|
+
export type ServerExportKind = "fn" | "channel" | "push" | "other"
|
|
33
33
|
export type ServerExport = { name: string, kind: ServerExportKind }
|
|
34
34
|
|
|
35
35
|
// ───────────────────────────── static export scan ─────────────────────────────
|
|
@@ -66,6 +66,7 @@ const IDENT = "[A-Za-z_$][\\w$]*"
|
|
|
66
66
|
const classifyInit = (init: string): ServerExportKind => {
|
|
67
67
|
const s = init.trimStart()
|
|
68
68
|
if (/^channel\s*[<(]/.test(s)) return "channel"
|
|
69
|
+
if (/^pushChannel\s*[<(]/.test(s)) return "push"
|
|
69
70
|
if (/^async\s+function\b/.test(s)) return "fn"
|
|
70
71
|
if (/^async\b/.test(s)) return "fn" // async (…) => / async x =>
|
|
71
72
|
return "other" // a sync function, a value: the server's own
|
|
@@ -113,38 +114,43 @@ export const scanServerExports = (source: string, path = "<server module>"): Ser
|
|
|
113
114
|
// ───────────────────────────── client stubs ─────────────────────────────
|
|
114
115
|
|
|
115
116
|
/** The client-side replacement of a `*.server.ts` module. `serverUrl` is baked in (draft/prod are
|
|
116
|
-
* separate compiles); "" means "no server deployed" — calls fail with a clear error.
|
|
117
|
-
|
|
117
|
+
* separate compiles); "" means "no server deployed" — calls fail with a clear error. `push` = the project
|
|
118
|
+
* has push channels (in this module or another): the stub opens by installing the push runtime, so the
|
|
119
|
+
* transport registers the device when the server asks, whether or not the app hears a channel itself. */
|
|
120
|
+
export const serverStubSource = (path: string, exports: ServerExport[], serverUrl: string, push = exports.some(e => e.kind === "push")): string => {
|
|
118
121
|
const mod = serverModuleId(path)
|
|
119
122
|
const url = JSON.stringify(serverUrl)
|
|
120
123
|
const lines = exports.map(({ name, kind }) => {
|
|
121
124
|
const id = JSON.stringify(`${mod}#${name}`)
|
|
122
125
|
if (kind === "fn") return `export const ${name} = __rpc(${url}, ${id})`
|
|
123
126
|
if (kind === "channel") return `export const ${name} = __channel(${url}, ${id})`
|
|
127
|
+
if (kind === "push") return `export const ${name} = __pushChannel(${url}, ${id})`
|
|
124
128
|
return `export const ${name} = __serverOnly(${id})`
|
|
125
129
|
})
|
|
130
|
+
if (push) lines.unshift("__pushEnable()")
|
|
126
131
|
return `// client stub of ${mod} — generated by the lecodes compiler\n${lines.join("\n")}\n`
|
|
127
132
|
}
|
|
128
133
|
|
|
129
134
|
// ───────────────────────────── a channel in the app ─────────────────────────────
|
|
130
135
|
|
|
131
136
|
/** A channel export has ONE type on both sides, so `publish` type-checks in the app — where the
|
|
132
|
-
* export is the subscribing proxy. Said at compile time, with what to do instead. */
|
|
137
|
+
* export is the subscribing proxy; a push channel's `send` likewise. Said at compile time, with what to do instead. */
|
|
133
138
|
export class ClientPublishError extends Error {
|
|
134
139
|
readonly problems: string[]
|
|
135
140
|
constructor(problems: string[]) {
|
|
136
|
-
super(`a channel goes from the server to the app — the app
|
|
141
|
+
super(`a channel goes from the server to the app — the app listens; to send, call a server function that publishes (a push channel: that sends):\n ${problems.join("\n ")}`)
|
|
137
142
|
this.name = "ClientPublishError"
|
|
138
143
|
this.problems = problems
|
|
139
144
|
}
|
|
140
145
|
}
|
|
141
146
|
|
|
142
|
-
/** `<channel>.publish(` in the project's client files, for every channel imported from a `*.server.ts`. */
|
|
147
|
+
/** `<channel>.publish(` and `<pushChannel>.send(` in the project's client files, for every channel imported from a `*.server.ts`. */
|
|
143
148
|
export const clientPublishes = (entries: CompileEntry[]): string[] => {
|
|
144
|
-
|
|
149
|
+
/** module → export name → the server's verb */
|
|
150
|
+
const channels = new Map<string, Map<string, string>>()
|
|
145
151
|
for (const [path, exports] of Object.entries(scanServerModules(entries))) {
|
|
146
|
-
const names = exports.filter(e => e.kind === "channel").map(e => e.name)
|
|
147
|
-
if (names.
|
|
152
|
+
const names = new Map(exports.filter(e => e.kind === "channel" || e.kind === "push").map(e => [e.name, e.kind === "push" ? "send" : "publish"]))
|
|
153
|
+
if (names.size) channels.set(path.replace(/\.[tj]s$/, ""), names)
|
|
148
154
|
}
|
|
149
155
|
if (channels.size === 0) return []
|
|
150
156
|
const out: string[] = []
|
|
@@ -152,21 +158,22 @@ export const clientPublishes = (entries: CompileEntry[]): string[] => {
|
|
|
152
158
|
if (e.type !== "text" || isServerPath(e.path) || !/\.[tj]sx?$/.test(e.path)) continue
|
|
153
159
|
const src = e.text ?? ""
|
|
154
160
|
const dir = normalizePath(e.path).replace(/\/[^/]*$/, "")
|
|
155
|
-
const locals: string[] = []
|
|
161
|
+
const locals: [string, string][] = []
|
|
156
162
|
for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g)) {
|
|
157
163
|
if (!m[2].startsWith(".")) continue
|
|
158
164
|
const names = channels.get(normalizePath(`${dir}/${m[2]}`).replace(/\.[tj]s$/, ""))
|
|
159
165
|
if (!names) continue
|
|
160
166
|
for (const part of m[1].split(",")) {
|
|
161
167
|
const [imported, local] = part.split(/\s+as\s+/).map(s => s.trim())
|
|
162
|
-
|
|
168
|
+
const verb = imported ? names.get(imported) : undefined
|
|
169
|
+
if (verb) locals.push([local ?? imported, verb])
|
|
163
170
|
}
|
|
164
171
|
}
|
|
165
172
|
if (!locals.length) continue
|
|
166
173
|
const code = blankCommentsAndStrings(src)
|
|
167
|
-
for (const local of locals) {
|
|
168
|
-
for (const m of code.matchAll(new RegExp(`(?<![\\w$.])${local.replace(/\$/g, "\\$")}\\s*\\.\\s
|
|
169
|
-
out.push(`${serverModuleId(e.path)}:${code.slice(0, m.index).split("\n").length}: ${local}
|
|
174
|
+
for (const [local, verb] of locals) {
|
|
175
|
+
for (const m of code.matchAll(new RegExp(`(?<![\\w$.])${local.replace(/\$/g, "\\$")}\\s*\\.\\s*${verb}\\s*\\(`, "g"))) {
|
|
176
|
+
out.push(`${serverModuleId(e.path)}:${code.slice(0, m.index).split("\n").length}: ${local}.${verb}(…)`)
|
|
170
177
|
}
|
|
171
178
|
}
|
|
172
179
|
}
|
|
@@ -228,10 +235,13 @@ export const scanServerModules = (entries: CompileEntry[]): Record<string, Serve
|
|
|
228
235
|
}
|
|
229
236
|
|
|
230
237
|
/** Replace each `*.server.ts` entry with its client stub (returns a new entries array). */
|
|
231
|
-
export const stubServerEntries = (entries: CompileEntry[], serverUrl: string): CompileEntry[] =>
|
|
232
|
-
|
|
233
|
-
|
|
238
|
+
export const stubServerEntries = (entries: CompileEntry[], serverUrl: string): CompileEntry[] => {
|
|
239
|
+
const modules = scanServerModules(entries)
|
|
240
|
+
const push = Object.values(modules).some(exports => exports.some(e => e.kind === "push"))
|
|
241
|
+
return entries.map(e => e.type === "text" && isServerPath(e.path)
|
|
242
|
+
? { ...e, text: serverStubSource(e.path, modules[normalizePath(e.path)] ?? [], serverUrl, push) }
|
|
234
243
|
: e)
|
|
244
|
+
}
|
|
235
245
|
|
|
236
246
|
export type CompileServerOptions = {
|
|
237
247
|
entries: CompileEntry[]
|
|
@@ -237,7 +237,7 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
|
|
|
237
237
|
if (!modSym) continue
|
|
238
238
|
const exported = new Map(checker.getExportsOfModule(modSym).map(s => [s.name, s]))
|
|
239
239
|
for (const ex of exports) {
|
|
240
|
-
if (ex.kind === "other") continue
|
|
240
|
+
if (ex.kind === "other" || ex.kind === "push") continue
|
|
241
241
|
const id = `${serverModuleId(path)}#${ex.name}`
|
|
242
242
|
let sym = exported.get(ex.name)
|
|
243
243
|
if (!sym) { warnings.push(`${id}: export not found by the checker — unvalidated`); continue }
|
package/src/inject.ts
CHANGED
|
@@ -43,10 +43,11 @@ export { WebSocket } from "./runtime/net"
|
|
|
43
43
|
export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform, type NetCorrection } from "./net"
|
|
44
44
|
// App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
|
|
45
45
|
// only ever imports its server functions (docs/backend-plan.md §4).
|
|
46
|
-
export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
|
|
46
|
+
export { __rpc, __channel, __pushChannel, __serverOnly, RpcError } from "./runtime/rpc"
|
|
47
47
|
// What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
|
|
48
48
|
export { resource, action, dataOf, failureOf, type Loaded, type Failure, type Resource, type Action } from "./runtime/resource"
|
|
49
49
|
export type { ChannelSubscription } from "./server/channel"
|
|
50
|
+
export type { PushChannel, PushMessage, PushNotification, PushTarget } from "./server/pushChannel"
|
|
50
51
|
export type { StoredFile, StoredImage } from "./server/files/models"
|
|
51
52
|
export { AudioPlayer, VideoPlayer } from "./runtime/media"
|
|
52
53
|
// Game audio (docs/audio-plan.md): decoded clips, a voice pool, buses with effects; 3D through the
|
|
@@ -162,7 +163,7 @@ export {
|
|
|
162
163
|
type MapPaddingValues, type MarkerItem, type MarkerTap, type MarkerLayer, type MarkerLayerOptions,
|
|
163
164
|
type LineLayer, type LineLayerOptions, type UserLocationOptions,
|
|
164
165
|
} from "./plugins/gen/map/sdk/map"
|
|
165
|
-
export { Push, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/gen/push/sdk/push"
|
|
166
|
+
export { Push, __pushEnable, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/gen/push/sdk/push"
|
|
166
167
|
export { Browser, WebView, type BrowserOpenOptions, type WebViewParams, type WebViewEvents } from "./plugins/gen/browser/sdk/browser"
|
|
167
168
|
export {
|
|
168
169
|
RTCPeerConnection, MediaStream, MediaStreamTrack, RTCDataChannel, RTCRtpSender, RTCRtpReceiver, RTCRtpTransceiver,
|
package/src/plugin.ts
CHANGED
|
@@ -44,6 +44,9 @@ export { Quat, type QuatLike } from "./math/quat"
|
|
|
44
44
|
export { Mat4 } from "./math/mat4"
|
|
45
45
|
export type { ClickEvent, TouchStartEvent } from "./runtime/touch"
|
|
46
46
|
export type { DismissOptions } from "./ui/presentable"
|
|
47
|
+
/** The seam the push plugin's wrapper installs its runtime on (src/runtime/pushSeam.ts): what the app's transport
|
|
48
|
+
* reads the device's address from, registers through, and a push channel's handlers are kept by. */
|
|
49
|
+
export { _pushRuntime, type PushRuntime } from "./runtime/pushSeam"
|
|
47
50
|
/** @internal The navigation epoch: a destination that prepares before it presents (ARScene) reads it
|
|
48
51
|
* before its awaits and gives up when another navigation moved it meanwhile. */
|
|
49
52
|
export { _navEpoch }
|
|
@@ -7,14 +7,22 @@
|
|
|
7
7
|
// An OPEN SESSION is the delivery pipe of the events. Opening one is free (no prompt, no I/O).
|
|
8
8
|
// Identity: every call is attributed to the world that makes it (the project's uuid, held by the
|
|
9
9
|
// host); a world without one rejects "unavailable" BEFORE any permission path.
|
|
10
|
+
//
|
|
11
|
+
// A notification reaches the JS world through this channel ALONE: a tap is the "tap" event (or
|
|
12
|
+
// `getLaunch()` for the one that started the process — the SDK's wrapper replays it into the first
|
|
13
|
+
// "tap" listener), a foreground arrival is "message". Its `url` is the app's to route — it does NOT
|
|
14
|
+
// ride the app's deep-link path (`app.launchUrl`, the "url" event).
|
|
10
15
|
|
|
11
16
|
import type { Json, Service, i32 } from "../../../plugin"
|
|
12
17
|
|
|
13
18
|
/** A delivered notification, as the app sees it. */
|
|
14
19
|
export interface PushPayload {
|
|
20
|
+
/** The push channel it was sent through — the id of the `pushChannel()` export of the project's
|
|
21
|
+
* server (`<path>#<name>`). Absent = sent outside one (a push key, `lecodes pn send`). */
|
|
22
|
+
channel?: string
|
|
15
23
|
title: string
|
|
16
24
|
body?: string
|
|
17
|
-
/** A deep link
|
|
25
|
+
/** A deep link, for the app to open itself (it does not ride `app.launchUrl`). */
|
|
18
26
|
url?: string
|
|
19
27
|
/** The developer's own JSON object, ≤ 2 KB serialized. */
|
|
20
28
|
data?: { [key: string]: Json }
|
|
@@ -26,6 +34,8 @@ export interface PushStatus {
|
|
|
26
34
|
permission: "granted" | "denied" | "prompt"
|
|
27
35
|
/** Whether this device holds a registration (an address) for this app. */
|
|
28
36
|
registered: boolean
|
|
37
|
+
/** The device's address for this app, when it is registered. @since 2 */
|
|
38
|
+
address?: string
|
|
29
39
|
}
|
|
30
40
|
|
|
31
41
|
export interface PushRegisterOptions {
|
|
@@ -38,10 +48,19 @@ export interface PushRegistration {
|
|
|
38
48
|
address: string
|
|
39
49
|
}
|
|
40
50
|
|
|
51
|
+
/** What the app shows itself when a notification arrives while it is in the foreground (the
|
|
52
|
+
* "message" event): the host shows the system banner for every other one. */
|
|
53
|
+
export interface PushHandled {
|
|
54
|
+
/** The push channels with a foreground handler. */
|
|
55
|
+
channels: string[]
|
|
56
|
+
/** Whether notifications sent outside a channel have one. */
|
|
57
|
+
untyped: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
41
60
|
export interface PushParams {}
|
|
42
61
|
|
|
43
62
|
export interface PushEvents {
|
|
44
|
-
/** Arrived while this app was in the foreground
|
|
63
|
+
/** Arrived while this app was in the foreground and the app handles it (PushHandled): no banner. */
|
|
45
64
|
message: PushPayload
|
|
46
65
|
/** Tapped while the world was alive. */
|
|
47
66
|
tap: PushPayload
|
|
@@ -58,4 +77,7 @@ export interface Push extends Service<"push", PushParams, PushEvents> {
|
|
|
58
77
|
unregister(): Promise<void>
|
|
59
78
|
/** The notification that cold-started this world, or null. Stable across calls. */
|
|
60
79
|
getLaunch(): Promise<PushPayload | null>
|
|
80
|
+
/** Which notifications the app handles itself in the foreground; the rest get the system banner.
|
|
81
|
+
* Until it is called nothing is handled. @since 2 */
|
|
82
|
+
setMessageHandled(handled: PushHandled): Promise<void>
|
|
61
83
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
// GENERATED by scripts/vendor-plugins.ts from lecodes-plugins/plugins/push/sdk/push.gen.ts — do not edit here.
|
|
2
2
|
// Change it in the plugin repo and run `bun run vendor:plugins`; `bun run check:plugins` fails when this copy is stale.
|
|
3
3
|
// GENERATED by `lecodes plugin gen` from contract.d.ts — do not edit.
|
|
4
|
-
// push: the service "push", contract version
|
|
4
|
+
// push: the service "push", contract version 2.
|
|
5
5
|
|
|
6
6
|
import { ServiceChannel } from "../../../../plugin"
|
|
7
|
-
import type { PushEvents, PushParams, PushPayload, PushRegisterOptions, PushRegistration, PushStatus } from "../contract"
|
|
7
|
+
import type { PushEvents, PushHandled, PushParams, PushPayload, PushRegisterOptions, PushRegistration, PushStatus } from "../contract"
|
|
8
8
|
|
|
9
9
|
/** What the halves register under. */
|
|
10
10
|
export const NAME = "push"
|
|
11
11
|
/** The contract's version: the highest `@since` of its members. */
|
|
12
|
-
export const VERSION =
|
|
12
|
+
export const VERSION = 2
|
|
13
13
|
|
|
14
14
|
// ---- the wire, call by call ----------------------------------------------------------------------
|
|
15
15
|
|
|
@@ -31,15 +31,23 @@ export const unregister = (service: ServiceChannel<PushEvents>): Promise<void> =
|
|
|
31
31
|
export const getLaunch = (service: ServiceChannel<PushEvents>): Promise<PushPayload | null> =>
|
|
32
32
|
service._call("getLaunch", []).then((w: any) => w ?? null)
|
|
33
33
|
|
|
34
|
+
/** Which notifications the app handles itself in the foreground; the rest get the system banner.
|
|
35
|
+
* Until it is called nothing is handled.
|
|
36
|
+
* Contract version 2: an older half rejects the call — ask `supports("setMessageHandled")`. */
|
|
37
|
+
export const setMessageHandled = (service: ServiceChannel<PushEvents>, handled: PushHandled): Promise<void> =>
|
|
38
|
+
service._call("setMessageHandled", [handled], 2)
|
|
39
|
+
|
|
34
40
|
// ---- what the host's half has --------------------------------------------------------------------
|
|
35
41
|
|
|
36
42
|
/** A method of the contract. */
|
|
37
|
-
export type Method = "getStatus" | "register" | "unregister" | "getLaunch"
|
|
43
|
+
export type Method = "getStatus" | "register" | "unregister" | "getLaunch" | "setMessageHandled"
|
|
44
|
+
|
|
45
|
+
const SINCE: { [method: string]: number } = { setMessageHandled: 2 }
|
|
38
46
|
|
|
39
47
|
/** Whether this host has the service "push" — and, given a method, a half new enough for it
|
|
40
48
|
* (a method added after the first version of the contract is tagged `@since`). */
|
|
41
49
|
export const supports = (method?: Method): boolean =>
|
|
42
|
-
ServiceChannel.supports(NAME
|
|
50
|
+
ServiceChannel.supports(NAME, method === undefined ? 1 : SINCE[method] ?? 1)
|
|
43
51
|
|
|
44
52
|
// ---- the client -----------------------------------------------------------------------------------
|
|
45
53
|
|
|
@@ -57,4 +65,8 @@ export class PushBase extends ServiceChannel<PushEvents> {
|
|
|
57
65
|
unregister(): Promise<void> { return unregister(this) }
|
|
58
66
|
/** The notification that cold-started this world, or null. Stable across calls. */
|
|
59
67
|
getLaunch(): Promise<PushPayload | null> { return getLaunch(this) }
|
|
68
|
+
/** Which notifications the app handles itself in the foreground; the rest get the system banner.
|
|
69
|
+
* Until it is called nothing is handled.
|
|
70
|
+
* Contract version 2: an older half rejects the call — ask `supports("setMessageHandled")`. */
|
|
71
|
+
setMessageHandled(handled: PushHandled): Promise<void> { return setMessageHandled(this, handled) }
|
|
60
72
|
}
|
|
@@ -4,102 +4,206 @@
|
|
|
4
4
|
// registration with the le.codes relay; the app only ever sees the opaque project-scoped ADDRESS
|
|
5
5
|
// (never the raw APNs / FCM token) plus payloads.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Two readers of this module:
|
|
8
|
+
// - the app, through `Push`: the permission (`register`), the state (`getStatus`), and the
|
|
9
|
+
// notifications sent OUTSIDE a push channel — a push key, `lecodes pn send` — as events;
|
|
10
|
+
// - the SDK's transport and its push-channel proxies, through the runtime `__pushEnable()`
|
|
11
|
+
// installs (lecodes-sdk/plugin `_pushRuntime`): the device's address for the `x-lecodes-push`
|
|
12
|
+
// header, the registration the server asks for, the handlers of each `pushChannel()` export.
|
|
13
|
+
// The compiler puts `__pushEnable()` at the top of every server stub of a project with push
|
|
14
|
+
// channels, so an app that has none never carries this module.
|
|
9
15
|
//
|
|
10
16
|
// The app is not running when a push arrives — display is host / OS work. The JS world sees
|
|
11
|
-
// exactly
|
|
12
|
-
// -
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
+
// exactly two delivery paths:
|
|
18
|
+
// - "tap": tapped, whether the app was running or the tap started it (the cold start is
|
|
19
|
+
// read with the service's getLaunch once and replayed into the first matching
|
|
20
|
+
// tap handler, AFTER the top-level code — the handler may route);
|
|
21
|
+
// - "message": arrived while this app was in the foreground AND the app handles it (a message
|
|
22
|
+
// handler of its channel, or an untyped one): the host shows no banner for those,
|
|
23
|
+
// and the system banner for every other.
|
|
24
|
+
// A payload's `url` is the app's to route; it does not ride the deep-link path.
|
|
17
25
|
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
26
|
+
// A notification is heard ONCE: one sent through a push channel by its channel's handlers, one
|
|
27
|
+
// sent outside a channel by the untyped listeners (`Push.addEventListener`).
|
|
28
|
+
//
|
|
29
|
+
// The OS permission prompt happens inside `register()`; `getStatus()` never prompts. On a host
|
|
30
|
+
// without the service every method rejects at once, BEFORE any permission path.
|
|
20
31
|
|
|
21
|
-
import { NAME, PushBase } from "./push.gen"
|
|
32
|
+
import { NAME, PushBase, supports } from "./push.gen"
|
|
33
|
+
import { _pushRuntime } from "../../../../plugin"
|
|
22
34
|
import type { PushEvents, PushPayload, PushRegisterOptions, PushRegistration, PushStatus } from "../contract"
|
|
23
35
|
|
|
24
36
|
export type { PushPayload, PushRegisterOptions, PushStatus }
|
|
25
37
|
|
|
26
38
|
export type PushEvent = keyof PushEvents
|
|
39
|
+
type Handler = (payload: PushPayload) => void
|
|
40
|
+
/** Handlers by push channel id; "" = the untyped ones. */
|
|
41
|
+
type Handlers = Map<string, Set<Handler>>
|
|
27
42
|
|
|
28
|
-
/**
|
|
29
|
-
* Remote push notifications. `Push.isSupported` reports whether this host registered the
|
|
30
|
-
* service — check it before offering notification features.
|
|
31
|
-
*/
|
|
32
43
|
// PURE IIFE so an app that never uses push tree-shakes the whole plugin away.
|
|
33
|
-
|
|
34
|
-
/** Whether this host registered a "push" service. */
|
|
35
|
-
readonly isSupported: boolean
|
|
36
|
-
/** Permission + registration state. Never prompts. */
|
|
37
|
-
getStatus(): Promise<PushStatus>
|
|
38
|
-
/** Ask for permission (first time) and register this device → the opaque address your server
|
|
39
|
-
* targets. Idempotent — safe to call every launch; rejects "denied" / "unavailable". */
|
|
40
|
-
register(options?: PushRegisterOptions): Promise<PushRegistration>
|
|
41
|
-
/** Remove this device's registration (the address stops receiving). */
|
|
42
|
-
unregister(): Promise<void>
|
|
43
|
-
/** The notification that cold-started this app run, or null. Stable across calls. */
|
|
44
|
-
getLaunch(): Promise<PushPayload | null>
|
|
45
|
-
/** "message" = received while the app is open; "tap" = the user tapped one while it ran. */
|
|
46
|
-
addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
|
|
47
|
-
removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
|
|
48
|
-
} = /*#__PURE__*/ (() => {
|
|
44
|
+
const built = /*#__PURE__*/ (() => {
|
|
49
45
|
let client: PushBase | null = null
|
|
50
46
|
const service = (): PushBase => client ??= new PushBase()
|
|
47
|
+
const supported = (): boolean => supports()
|
|
51
48
|
|
|
52
49
|
const assertSupported = (): void => {
|
|
53
50
|
// Fail before the host is touched — a permission dialog for a feature that can't exist
|
|
54
51
|
// would be worse than the error.
|
|
55
|
-
if (!
|
|
52
|
+
if (!supported()) {
|
|
56
53
|
throw new Error('Push isn\'t available on this host — it needs a registered "push" service.')
|
|
57
54
|
}
|
|
58
55
|
}
|
|
59
56
|
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
// ── the address: what the transport tells the server ──
|
|
58
|
+
let address: string | null = null
|
|
59
|
+
const learn = (a: string | null | undefined): void => { if (a) address = a }
|
|
60
|
+
|
|
61
|
+
// ── the handlers ──
|
|
62
|
+
const taps: Handlers = new Map()
|
|
63
|
+
const messages: Handlers = new Map()
|
|
64
|
+
const add = (map: Handlers, key: string, fn: Handler): void => {
|
|
65
|
+
let set = map.get(key)
|
|
66
|
+
if (!set) map.set(key, set = new Set())
|
|
67
|
+
set.add(fn)
|
|
68
|
+
}
|
|
69
|
+
const remove = (map: Handlers, key: string, fn: Handler): void => { map.get(key)?.delete(fn) }
|
|
70
|
+
/** A payload's handlers: its channel's, or the untyped ones when it was sent outside a channel. */
|
|
71
|
+
const handlersOf = (map: Handlers, p: PushPayload): Set<Handler> | undefined => map.get(p.channel ?? "")
|
|
72
|
+
const dispatch = (map: Handlers, p: PushPayload): void => {
|
|
73
|
+
const set = handlersOf(map, p)
|
|
74
|
+
if (set) for (const fn of [...set]) fn(p)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Events arrive on a live session only; a handler opens the pipe. The prompt-free getStatus is the
|
|
78
|
+
// opening call, and it tells the address of an earlier registration.
|
|
79
|
+
let piped = false
|
|
64
80
|
const ensurePipe = (): void => {
|
|
65
|
-
if (
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
if (piped || !supported()) return
|
|
82
|
+
piped = true
|
|
83
|
+
const s = service()
|
|
84
|
+
s.on("tap", (p: PushPayload) => dispatch(taps, p))
|
|
85
|
+
s.on("message", (p: PushPayload) => dispatch(messages, p))
|
|
86
|
+
s.getStatus().then(st => learn(st.address), () => {})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The notification that started the process: replayed into the first tap handler that matches it,
|
|
90
|
+
// asynchronously — after the top-level code that attached the handler, so it may route.
|
|
91
|
+
let launch: Promise<PushPayload | null> | null = null
|
|
92
|
+
let launchDelivered = false
|
|
93
|
+
const replayLaunch = (): void => {
|
|
94
|
+
if (launchDelivered || !supported()) return
|
|
95
|
+
launch ??= service().getLaunch().catch(() => null)
|
|
96
|
+
launch.then(p => {
|
|
97
|
+
if (!p || launchDelivered) return
|
|
98
|
+
const set = handlersOf(taps, p)
|
|
99
|
+
if (!set?.size) return // its handler may be attached later: tried again then
|
|
100
|
+
launchDelivered = true
|
|
101
|
+
for (const fn of [...set]) fn(p)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// What the host shows no banner for in the foreground: the channels with a message handler.
|
|
106
|
+
const announceHandled = (): void => {
|
|
107
|
+
if (!supports("setMessageHandled")) return
|
|
108
|
+
const channels = [...messages].filter(([key, set]) => key && set.size).map(([key]) => key)
|
|
109
|
+
service().setMessageHandled({ channels, untyped: (messages.get("")?.size ?? 0) > 0 }).catch(() => {})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One handler of one kind for one channel ("" = untyped); returns its remover. Recording on an
|
|
113
|
+
* unsupported host is a silent no-op (the events never fire), like app / device listeners. */
|
|
114
|
+
const listen = (kind: PushEvent, channel: string, fn: Handler): () => void => {
|
|
115
|
+
const map = kind === "tap" ? taps : messages
|
|
116
|
+
add(map, channel, fn)
|
|
117
|
+
ensurePipe()
|
|
118
|
+
if (kind === "tap") replayLaunch()
|
|
119
|
+
else announceHandled()
|
|
120
|
+
return () => {
|
|
121
|
+
remove(map, channel, fn)
|
|
122
|
+
if (kind === "message") announceHandled()
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The server asked this device to register (the envelope's `push: true`): once per run. The prompt
|
|
127
|
+
// appears only when the permission was never asked; a refusal is final (the OS asks no twice).
|
|
128
|
+
let asked = false
|
|
129
|
+
const requestRegister = (): void => {
|
|
130
|
+
if (asked || !supported()) return
|
|
131
|
+
asked = true
|
|
132
|
+
service().getStatus()
|
|
133
|
+
.then(st => {
|
|
134
|
+
learn(st.address)
|
|
135
|
+
if (st.permission === "denied") return
|
|
136
|
+
return service().register({}).then(r => learn(r.address))
|
|
137
|
+
})
|
|
138
|
+
.catch(() => {})
|
|
68
139
|
}
|
|
69
140
|
|
|
70
141
|
const push = {
|
|
71
142
|
async getStatus(): Promise<PushStatus> {
|
|
72
143
|
assertSupported()
|
|
73
|
-
|
|
144
|
+
const st = await service().getStatus()
|
|
145
|
+
learn(st.address)
|
|
146
|
+
return st
|
|
74
147
|
},
|
|
75
148
|
|
|
76
149
|
async register(options?: PushRegisterOptions): Promise<PushRegistration> {
|
|
77
150
|
assertSupported()
|
|
78
|
-
|
|
151
|
+
const r = await service().register(options ?? {})
|
|
152
|
+
learn(r.address)
|
|
153
|
+
return r
|
|
79
154
|
},
|
|
80
155
|
|
|
81
156
|
async unregister(): Promise<void> {
|
|
82
157
|
assertSupported()
|
|
83
158
|
await service().unregister()
|
|
159
|
+
address = null
|
|
84
160
|
},
|
|
85
161
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
return (await service().getLaunch()) ?? null
|
|
162
|
+
addEventListener(event: PushEvent, callback: Handler): void {
|
|
163
|
+
listen(event, "", callback)
|
|
89
164
|
},
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// like app / device listeners — only the promise-returning methods reject loudly.
|
|
94
|
-
service().on(event, callback)
|
|
95
|
-
ensurePipe()
|
|
96
|
-
},
|
|
97
|
-
removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
|
|
98
|
-
service().off(event, callback)
|
|
165
|
+
removeEventListener(event: PushEvent, callback: Handler): void {
|
|
166
|
+
remove(event === "tap" ? taps : messages, "", callback)
|
|
167
|
+
if (event === "message") announceHandled()
|
|
99
168
|
},
|
|
100
169
|
}
|
|
101
170
|
Object.defineProperty(push, "isSupported", {
|
|
102
|
-
get: (): boolean =>
|
|
171
|
+
get: (): boolean => supported(),
|
|
103
172
|
})
|
|
104
|
-
|
|
173
|
+
|
|
174
|
+
const runtime = {
|
|
175
|
+
address: (): string | null => address,
|
|
176
|
+
requestRegister,
|
|
177
|
+
on: listen,
|
|
178
|
+
}
|
|
179
|
+
return { push, runtime }
|
|
105
180
|
})()
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Remote push notifications. `Push.isSupported` reports whether this host registered the
|
|
184
|
+
* service — check it before offering notification features.
|
|
185
|
+
*
|
|
186
|
+
* A project with a backend needs none of this: its `pushChannel()` exports are heard with their
|
|
187
|
+
* own `onTap` / `onMessage`, and the device is registered when the server asks (after a sign-in).
|
|
188
|
+
* `Push` is for the rest — a permission asked at a moment of the app's choosing, and the
|
|
189
|
+
* notifications sent outside a channel (a push key, `lecodes pn send`).
|
|
190
|
+
*/
|
|
191
|
+
export const Push: {
|
|
192
|
+
/** Whether this host registered a "push" service. */
|
|
193
|
+
readonly isSupported: boolean
|
|
194
|
+
/** Permission + registration state. Never prompts. */
|
|
195
|
+
getStatus(): Promise<PushStatus>
|
|
196
|
+
/** Ask for permission (first time) and register this device → the opaque address your server
|
|
197
|
+
* targets. Idempotent — safe to call every launch; rejects "denied" / "unavailable". */
|
|
198
|
+
register(options?: PushRegisterOptions): Promise<PushRegistration>
|
|
199
|
+
/** Remove this device's registration (the address stops receiving). */
|
|
200
|
+
unregister(): Promise<void>
|
|
201
|
+
/** The notifications sent outside a push channel: "tap" = tapped (the one that started the app
|
|
202
|
+
* too); "message" = arrived while the app is open (the app shows it, the host no banner). */
|
|
203
|
+
addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
|
|
204
|
+
removeEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void
|
|
205
|
+
} = built.push as any
|
|
206
|
+
|
|
207
|
+
/** Installs the push runtime for the SDK's transport and its push-channel proxies (lecodes-sdk/plugin
|
|
208
|
+
* `_pushRuntime`). A line of every server stub of a project with push channels — never app code. */
|
|
209
|
+
export const __pushEnable = (): void => { _pushRuntime.current = built.runtime }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between the app's transport (./rpc.ts) and the push plugin's wrapper (src/plugins/gen/push/sdk/push.ts).
|
|
3
|
+
*
|
|
4
|
+
* A push channel (`pushChannel()` of a *.server.ts file — src/server/pushChannel.ts) is heard in the app through
|
|
5
|
+
* the plugin: its taps and foreground arrivals come off the plugin's service. The transport, for its part, tells
|
|
6
|
+
* the server the device's address (the `x-lecodes-push` header of every call) and registers the device when the
|
|
7
|
+
* server asks for it (the envelope's `push: true`). Neither may import the wrapper: an app without push channels
|
|
8
|
+
* would then carry the plugin. So the wrapper installs itself here — `__pushEnable()`, a line the compiler puts at
|
|
9
|
+
* the top of every server stub of a project with push channels — and the transport reads what is installed.
|
|
10
|
+
*/
|
|
11
|
+
export type PushRuntime = {
|
|
12
|
+
/** This device's address for the app, once known (after a registration, or the status of an earlier one). */
|
|
13
|
+
address(): string | null
|
|
14
|
+
/** The server asked this device to register: once per run, prompt-free when the permission was given,
|
|
15
|
+
* nothing past a refusal. */
|
|
16
|
+
requestRegister(): void
|
|
17
|
+
/** Hear the taps ("tap") or the foreground arrivals ("message") of one push channel, by its id. Returns the remover. */
|
|
18
|
+
on(kind: "tap" | "message", channel: string, handler: (payload: any) => void): () => void
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What the wrapper installed; null = the app has no push channels (or the plugin is not in the bundle). */
|
|
22
|
+
export const _pushRuntime: { current: PushRuntime | null } = { current: null }
|