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/package.json CHANGED
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.15.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.2.13",
46
+ "version": "2.2.14",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -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
- export const serverStubSource = (path: string, exports: ServerExport[], serverUrl: string): string => {
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 subscribes; to send, call a server function that publishes:\n ${problems.join("\n ")}`)
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
- const channels = new Map<string, Set<string>>()
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.length) channels.set(path.replace(/\.[tj]s$/, ""), new Set(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
- if (imported && names.has(imported)) locals.push(local ?? imported)
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*publish\\s*\\(`, "g"))) {
169
- out.push(`${serverModuleId(e.path)}:${code.slice(0, m.index).split("\n").length}: ${local}.publish(…)`)
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
- entries.map(e => e.type === "text" && isServerPath(e.path)
233
- ? { ...e, text: serverStubSource(e.path, scanServerExports(e.text ?? "", serverModuleId(e.path)), serverUrl) }
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 — it also rides the app's own link path (`app.launchUrl`, the "url" event). */
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 (the host shows no banner). */
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 1.
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 = 1
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) // every method is of version 1
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
- // Hand-written over the generated wire because the public API is a module singleton with
8
- // `addEventListener`, and a listener must OPEN the session: events arrive on a live one only.
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 three delivery paths:
12
- // - event "message": arrived while this app was foregrounded (the host suppresses the banner)
13
- // - event "tap": tapped while the world was alive (warm)
14
- // - getLaunch(): the notification that cold-started this world (stable pull, or null)
15
- // A payload's `url` additionally rides the app-lifecycle deep-link path (`app.launchUrl` /
16
- // the "url" event), so apps that already route links get notification routing for free.
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
- // The OS permission prompt happens inside `register()`; `getStatus()` and `getLaunch()` never
19
- // prompt. On a host without the service every method rejects at once, BEFORE any permission path.
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
- export const Push: {
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 (!PushBase.supports(NAME)) {
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
- // `on` only records listeners; events need a live session to arrive on. Poke the host with the
61
- // prompt-free getStatus once so a listener-only app (already registered on a previous run)
62
- // still hears warm taps.
63
- let pipeOpened = false
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 (pipeOpened || !PushBase.supports(NAME)) return
66
- pipeOpened = true
67
- service().getStatus().catch(() => { pipeOpened = false })
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
- return await service().getStatus()
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
- return await service().register(options ?? {})
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
- async getLaunch(): Promise<PushPayload | null> {
87
- assertSupported()
88
- return (await service().getLaunch()) ?? null
162
+ addEventListener(event: PushEvent, callback: Handler): void {
163
+ listen(event, "", callback)
89
164
  },
90
-
91
- addEventListener(event: PushEvent, callback: (payload: PushPayload) => void): void {
92
- // Recording on an unsupported host is a silent no-op (the events just never fire),
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 => PushBase.supports(NAME),
171
+ get: (): boolean => supported(),
103
172
  })
104
- return push as any
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 }