lecodes-sdk 2.2.11 → 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.
Files changed (105) hide show
  1. package/dist/global.d.ts +13 -0
  2. package/dist/host.d.ts +8 -2
  3. package/dist/types/core/Aspect.d.ts +5 -0
  4. package/dist/types/g2/Physics2D.d.ts +3 -2
  5. package/dist/types/g2/Scene2D.d.ts +6 -0
  6. package/dist/types/gl/Scene.d.ts +6 -0
  7. package/dist/types/gl/Texture.d.ts +2 -7
  8. package/dist/types/gl/animation/DynamicBone.d.ts +3 -0
  9. package/dist/types/gl/animation/Locomotion.d.ts +3 -0
  10. package/dist/types/gl/physics/Physics.d.ts +2 -1
  11. package/dist/types/gl/physics/Ragdoll.d.ts +4 -0
  12. package/dist/types/gl/vehicle/Vehicle.d.ts +4 -0
  13. package/dist/types/inject.d.ts +4 -3
  14. package/dist/types/plugin.d.ts +3 -0
  15. package/dist/types/plugins/gen/push/sdk/push.d.ts +10 -3
  16. package/dist/types/plugins/gen/push/sdk/push.gen.d.ts +11 -3
  17. package/dist/types/runtime/app.d.ts +4 -2
  18. package/dist/types/runtime/fetch.d.ts +3 -0
  19. package/dist/types/runtime/pushSeam.d.ts +23 -0
  20. package/dist/types/runtime/rpc.d.ts +15 -1
  21. package/dist/types/runtime/storage.d.ts +6 -1
  22. package/dist/types/server/auth/models.d.ts +13 -1
  23. package/dist/types/server/auth/types.d.ts +2 -0
  24. package/dist/types/server/db/marci/query.d.ts +8 -2
  25. package/dist/types/server/inject.d.ts +1 -0
  26. package/dist/types/server/pushChannel.d.ts +79 -0
  27. package/dist/types/ui/UIButton.d.ts +2 -2
  28. package/dist/types/ui/UIContainer.d.ts +4 -2
  29. package/dist/types/ui/UINode.d.ts +2 -2
  30. package/dist/types/ui/UIScreen.d.ts +2 -2
  31. package/dist/types/ui/UIVideo.d.ts +3 -2
  32. package/dist/types/ui/router.d.ts +1 -1
  33. package/dist/types/version.d.ts +1 -1
  34. package/dist/types.json +1 -1
  35. package/package.json +2 -5
  36. package/src/compile/assetIconMacro.ts +2 -1
  37. package/src/compile/assetMacro.ts +2 -1
  38. package/src/compile/compileProject.ts +28 -4
  39. package/src/compile/header.ts +17 -0
  40. package/src/compile/index.ts +2 -0
  41. package/src/compile/serverSplit.ts +29 -19
  42. package/src/compile/serverTypes.ts +1 -1
  43. package/src/core/Aspect.ts +2 -1
  44. package/src/g2/Physics2D.ts +6 -3
  45. package/src/g2/Scene2D.ts +9 -1
  46. package/src/gl/Scene.ts +9 -1
  47. package/src/gl/Texture.ts +2 -1
  48. package/src/gl/animation/DynamicBone.ts +6 -2
  49. package/src/gl/animation/Locomotion.ts +11 -1
  50. package/src/gl/physics/Physics.ts +3 -2
  51. package/src/gl/physics/Ragdoll.ts +8 -3
  52. package/src/gl/vehicle/Vehicle.ts +7 -2
  53. package/src/host.d.ts +8 -2
  54. package/src/inject.ts +4 -3
  55. package/src/plugin.ts +3 -0
  56. package/src/plugins/gen/push/contract.d.ts +24 -2
  57. package/src/plugins/gen/push/sdk/push.gen.ts +17 -5
  58. package/src/plugins/gen/push/sdk/push.ts +159 -55
  59. package/src/runtime/app.ts +4 -2
  60. package/src/runtime/fetch.ts +6 -0
  61. package/src/runtime/pushSeam.ts +22 -0
  62. package/src/runtime/rpc.ts +35 -1
  63. package/src/runtime/storage.ts +14 -4
  64. package/src/server/auth/host.ts +40 -9
  65. package/src/server/auth/models.ts +5 -1
  66. package/src/server/auth/types.ts +2 -0
  67. package/src/server/db/marci/query.ts +8 -2
  68. package/src/server/host.ts +6 -0
  69. package/src/server/inject.ts +3 -2
  70. package/src/server/push/host.ts +143 -0
  71. package/src/server/pushChannel.ts +121 -0
  72. package/src/server/runtime.ts +20 -8
  73. package/src/ui/UIButton.ts +2 -2
  74. package/src/ui/UIContainer.ts +4 -2
  75. package/src/ui/UIInput.ts +2 -2
  76. package/src/ui/UINode.ts +2 -2
  77. package/src/ui/UIScreen.ts +2 -2
  78. package/src/ui/UIVideo.ts +3 -2
  79. package/src/ui/router.ts +4 -3
  80. package/src/version.ts +1 -1
  81. package/prompts/2d.md +0 -391
  82. package/prompts/3d-scene-files.md +0 -113
  83. package/prompts/3d-scene.md +0 -122
  84. package/prompts/3d.md +0 -299
  85. package/prompts/README.md +0 -142
  86. package/prompts/ar.md +0 -142
  87. package/prompts/canvas.md +0 -28
  88. package/prompts/compose.ts +0 -46
  89. package/prompts/concept.md +0 -49
  90. package/prompts/core-design.md +0 -98
  91. package/prompts/core.md +0 -294
  92. package/prompts/design.md +0 -218
  93. package/prompts/dist/2d-game.md +0 -1567
  94. package/prompts/dist/3d-app.md +0 -1712
  95. package/prompts/dist/ar-app.md +0 -1585
  96. package/prompts/dist/concept.md +0 -49
  97. package/prompts/dist/design.md +0 -521
  98. package/prompts/dist/ui-app.md +0 -1175
  99. package/prompts/index.md +0 -39
  100. package/prompts/intake-prompt.md +0 -33
  101. package/prompts/namer-prompt.md +0 -12
  102. package/prompts/router-prompt.md +0 -50
  103. package/prompts/select.ts +0 -126
  104. package/prompts/ui-design.md +0 -203
  105. package/prompts/ui.md +0 -813
@@ -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 }
@@ -56,8 +56,10 @@ export const app = {
56
56
  sdkVersion: SDK_VERSION,
57
57
  /** The INSTALLED app: the store build this bundle runs in. After an over-the-air update the code
58
58
  * is newer than the binary, so a feature that needs native support added in a later store build
59
- * (a plugin, an SDK method) branches on `app.host.sdk` / `app.host.build` — and on `typeof` of
60
- * the method itself, which is the final word. Minor and patch of the SDK never gate anything. */
59
+ * (a plugin, an SDK method) branches on `app.host.sdk` / `app.host.build` — and on the feature's
60
+ * own check (`Push.isSupported`, `Physics.supported`), which is the final word. Not `typeof` of
61
+ * the global: every SDK wrapper is compiled into the bundle, so `typeof Push` is "object" on a
62
+ * host without the plugin. Minor and patch of the SDK never gate anything. */
61
63
  get host(): AppHost {
62
64
  return {
63
65
  version: _creatorApp.appVersion?.(),
@@ -21,6 +21,9 @@ export interface FetchResponse {
21
21
  readonly status: number
22
22
  json<T = any>(): T
23
23
  text(): string
24
+ /** The body's bytes, a copy — a binary format of your own (`asset('./levels/1.bin')`), parsed in
25
+ * code with a DataView. Synchronous, like `text()`. */
26
+ bytes(): Uint8Array
24
27
  /** @deprecated A no-op: the body is freed with the response's handle (owned handles). Apps written
25
28
  * against the pre-owned SDK still call it after `text()` / `json()`; remove the call. */
26
29
  dispose(): void
@@ -42,6 +45,9 @@ class FetchResponseImpl implements FetchResponse {
42
45
  text(): string {
43
46
  return _creatorFetch.toText(this._h.id)
44
47
  }
48
+ bytes(): Uint8Array {
49
+ return _creatorFetch.toBytes(this._h.id)
50
+ }
45
51
  /** @deprecated see the interface */
46
52
  dispose(): void {}
47
53
  }
@@ -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 }
@@ -8,7 +8,8 @@
8
8
  * export const db = __serverOnly("schema.server.ts#db")
9
9
  *
10
10
  * Wire (implemented by the runner):
11
- * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
11
+ * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known,
12
+ * `x-lecodes-push: <address>` once the device registered for notifications
12
13
  * — with a `File` among the arguments: multipart, the field `args` is
13
14
  * that JSON with `{"$file": <i>}` where the file stood, `file<i>` the files
14
15
  * → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
@@ -24,6 +25,7 @@ import { localStorage } from "./storage"
24
25
  import { WebSocket } from "./net"
25
26
  import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
26
27
  import type { ChannelSubscription } from "../server/channel"
28
+ import { _pushRuntime } from "./pushSeam"
27
29
 
28
30
  /** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
29
31
  * (0 = the request itself failed: no network / no server). */
@@ -69,6 +71,9 @@ export const __rpc = (serverUrl: string, id: string) => {
69
71
  const headers: Record<string, string> = {}
70
72
  const session = getSession(serverUrl)
71
73
  if (session) headers.authorization = `Bearer ${session}`
74
+ // the device's push address, for the session to carry (src/server/auth/host.ts) — what a push channel reaches the user by
75
+ const pushAddress = _pushRuntime.current?.address()
76
+ if (pushAddress) headers["x-lecodes-push"] = pushAddress
72
77
  // a File anywhere among the arguments leaves its place to a marker and travels beside them
73
78
  const files: File[] = []
74
79
  const json = JSON.stringify({ args }, (_key, value) => value instanceof File ? { $file: files.push(value) - 1 } : value)
@@ -88,6 +93,8 @@ export const __rpc = (serverUrl: string, id: string) => {
88
93
  let body: any
89
94
  try { body = res.json() } catch { body = null }
90
95
  if (body && typeof body === "object" && "session" in body) setSession(serverUrl, body.session ?? null)
96
+ // the server asks this device to register for notifications: a signed-in session without an address, and push channels to hear
97
+ if (body && typeof body === "object" && body.push === true) _pushRuntime.current?.requestRegister()
91
98
  if (body && body.ok === true) return body.result
92
99
  if (body && body.ok === false) throw new RpcError(body.status ?? res.status, body.message ?? "")
93
100
  throw new RpcError(res.status, `Endpoint ${id}: unexpected response (HTTP ${res.status})`)
@@ -287,3 +294,30 @@ export const __channel = (serverUrl: string, id: string) => ({
287
294
  throw new RpcError(0, `Channel ${id}: the server publishes, the app subscribes — call a server function that publishes`)
288
295
  },
289
296
  })
297
+
298
+ // ───────────────────────────── push channels ─────────────────────────────
299
+
300
+ /**
301
+ * Build the client proxy of push channel `id` — what a `pushChannel()` export of a `*.server.ts` file is
302
+ * in the app (its type is the server's: src/server/pushChannel.ts). The notifications come off the push
303
+ * plugin, through the runtime its wrapper installed (`__pushEnable()`, the first line of the stubs of a
304
+ * project with push channels — ./pushSeam.ts): `onTap` hears this channel's taps (the one that started
305
+ * the app included), `onMessage` its arrivals while the app is in the foreground. On a host without the
306
+ * plugin a handler is recorded and never called, like the plugin's own listeners.
307
+ */
308
+ export const __pushChannel = (serverUrl: string, id: string) => {
309
+ const listen = (kind: "tap" | "message", handler: unknown): ChannelSubscription => {
310
+ if (typeof handler !== "function") throw new RpcError(0, `Push channel ${id}: ${kind === "tap" ? "onTap" : "onMessage"}(handler) — no handler given`)
311
+ const runtime = _pushRuntime.current
312
+ if (!runtime) throw new RpcError(0, `Push channel ${id}: the push runtime is not installed — the compiler puts __pushEnable() in the stub of a project with push channels`)
313
+ const off = runtime.on(kind, id, handler as (payload: unknown) => void)
314
+ return { close: off }
315
+ }
316
+ return {
317
+ onTap: (handler: unknown): ChannelSubscription => listen("tap", handler),
318
+ onMessage: (handler: unknown): ChannelSubscription => listen("message", handler),
319
+ send(): never {
320
+ throw new RpcError(0, `Push channel ${id}: the server sends, the app listens — call a server function that sends${serverUrl ? "" : " (and compile with serverUrl)"}`)
321
+ },
322
+ }
323
+ }
@@ -1,15 +1,25 @@
1
1
  // Key/value persistence backed by the host (`_creatorStorage`). Same shape as the web localStorage
2
- // subset projects use.
2
+ // subset projects use — including `localStorage['theme'] = 'dark'` / `localStorage.theme`, the
3
+ // browser habit: a Proxy routes any key that is not one of the three methods to the host, so the
4
+ // habit saves instead of setting a field on a plain object that nothing persists.
3
5
 
4
-
5
- export const localStorage = {
6
+ const methods = {
6
7
  getItem(key: string): string | null {
7
8
  return _creatorStorage.get(key)
8
9
  },
9
10
  setItem(key: string, value: string): void {
10
- _creatorStorage.set(key, value)
11
+ _creatorStorage.set(key, String(value))
11
12
  },
12
13
  removeItem(key: string): void {
13
14
  _creatorStorage.remove(key)
14
15
  },
15
16
  }
17
+
18
+ export type LocalStorage = typeof methods & { [key: string]: any }
19
+
20
+ export const localStorage: LocalStorage = new Proxy(methods as LocalStorage, {
21
+ get: (t, key) => typeof key === "string" && !(key in t) ? t.getItem(key) ?? undefined : (t as any)[key],
22
+ set: (t, key, value) => { if (typeof key === "string" && !(key in t)) t.setItem(key, value); return true },
23
+ deleteProperty: (t, key) => { if (typeof key === "string" && !(key in t)) t.removeItem(key); return true },
24
+ has: (t, key) => key in t || (typeof key === "string" && t.getItem(key) !== null),
25
+ })
@@ -12,7 +12,13 @@
12
12
  *
13
13
  * Tokens: 32 random bytes (base64url) held by the client transport; only `sha256(token)` is stored.
14
14
  * A request without a valid token gets a fresh guest session and the token travels back in the
15
- * envelope (`session`); sign-in/out ROTATE the token the same way.
15
+ * envelope (`session`); sign-in/out ROTATE the token the same way (the row stays: what it holds of the
16
+ * device — its push address — survives a sign-out, and a sign-in on the same device finds it there).
17
+ *
18
+ * The push address: the transport sends the device's address at the platform's push relay in the
19
+ * `x-lecodes-push` header of every call (once the device registered); `resolve` keeps the session's
20
+ * `pushAddress` equal to it. That is the whole binding of a device to a user — a push channel's `send`
21
+ * reaches a user through the live sessions that carry an address (../push/host.ts).
16
22
  *
17
23
  * Identities: every sign-in method is an `Identity` row `(provider, subject)` of a user — `email:<address>`
18
24
  * or `login:<name>` (its `hash` = the password). The one rule that keeps passwords honest: proving an
@@ -114,6 +120,17 @@ const parseLogin = (login: unknown): { provider: "email" | "login", subject: str
114
120
 
115
121
  const identityKey = (provider: IdentityProvider, subject: string) => `${provider}:${subject}`
116
122
 
123
+ /** The header the transport tells the device's push address in (src/runtime/rpc.ts). */
124
+ export const PUSH_ADDRESS_HEADER = "x-lecodes-push"
125
+ const PUSH_ADDRESS_RE = /^[A-Za-z0-9_-]{1,128}$/
126
+ /** The address of this request's device, when the header carries a well-formed one. */
127
+ const pushAddressOf = (ctx: RequestContext): string | null => {
128
+ const headers = ctx.headers ?? {}
129
+ const raw = headers[PUSH_ADDRESS_HEADER] ?? Object.entries(headers).find(([k]) => k.toLowerCase() === PUSH_ADDRESS_HEADER)?.[1]
130
+ const value = typeof raw === "string" ? raw.trim() : ""
131
+ return PUSH_ADDRESS_RE.test(value) ? value : null
132
+ }
133
+
117
134
  /** A sliding-window counter per key (in-worker; the persistent limits live on the rows). */
118
135
  const window = (map: Map<string, number[]>, key: string, now: number, span: number): number[] => {
119
136
  const list = (map.get(key) ?? []).filter(x => now - x < span)
@@ -146,7 +163,7 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
146
163
  const log = cfg.log ?? ((level, text) => (level === "warn" ? console.warn(text) : console.info(text)))
147
164
 
148
165
  // token → session (60 s). Cleared on every mutation — correctness over reuse.
149
- const sessionCache = new Map<string, { sessionId: number, userId: number | null, at: number }>()
166
+ const sessionCache = new Map<string, { sessionId: number, userId: number | null, pushAddress: string | null, at: number }>()
150
167
  const clearCache = () => { sessionCache.clear() }
151
168
 
152
169
  const requireAuth = () => {
@@ -165,9 +182,10 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
165
182
 
166
183
  const mint = async (ctx: RequestContext): Promise<AuthState> => {
167
184
  const token = randomToken()
168
- const { id } = await sessions.insert({ tokenHash: await sha256(token) })
169
- const state: AuthState = { sessionId: id, userId: null, issuedToken: token }
170
- sessionCache.set(token, { sessionId: id, userId: null, at: now() })
185
+ const pushAddress = pushAddressOf(ctx)
186
+ const { id } = await sessions.insert({ tokenHash: await sha256(token), ...(pushAddress ? { pushAddress } : {}) })
187
+ const state: AuthState = { sessionId: id, userId: null, pushAddress, issuedToken: token }
188
+ sessionCache.set(token, { sessionId: id, userId: null, pushAddress, at: now() })
171
189
  ctx.auth = state
172
190
  ctx.sessionToken = token
173
191
  return state
@@ -188,21 +206,34 @@ export const createAuthHost = (cfg: AuthHostConfig): AuthHost => {
188
206
  if (opts.rotate) await rotate(ctx)
189
207
  }
190
208
 
209
+ /** The session's push address follows the header: written when it differs (a registration, a new address
210
+ * after the OS rotated the token) — never cleared by a call without the header: the app may not have
211
+ * asked the plugin yet in this run. */
212
+ const followPushAddress = async (token: string, sessionId: number, held: string | null, told: string | null): Promise<string | null> => {
213
+ if (!told || told === held) return held
214
+ await sessions.update(sessionId, { pushAddress: told })
215
+ const c = sessionCache.get(token)
216
+ if (c) c.pushAddress = told
217
+ return told
218
+ }
219
+
191
220
  const resolve = async (ctx: RequestContext): Promise<void> => {
192
221
  if (!tables) return
193
222
  const token = ctx.sessionToken
194
223
  if (token) {
195
224
  const c = sessionCache.get(token)
196
225
  if (c && now() - c.at < CACHE_TTL_MS) {
197
- ctx.auth = { sessionId: c.sessionId, userId: c.userId }
226
+ const pushAddress = await followPushAddress(token, c.sessionId, c.pushAddress, pushAddressOf(ctx))
227
+ ctx.auth = { sessionId: c.sessionId, userId: c.userId, pushAddress }
198
228
  return
199
229
  }
200
- const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, user: { id: true } }).first()
230
+ const row = await sessions.where({ tokenHash: await sha256(token) }).select({ id: true, revoked: true, lastSeenAt: true, pushAddress: true, user: { id: true } }).first()
201
231
  if (row && !row.revoked) {
202
232
  const userId: number | null = row.user ? row.user.id : null
203
233
  if (now() - (row.lastSeenAt ?? 0) > SEEN_UPDATE_MS) sessions.update(row.id, { lastSeenAt: now() }).then(undefined, () => {})
204
- sessionCache.set(token, { sessionId: row.id, userId, at: now() })
205
- ctx.auth = { sessionId: row.id, userId }
234
+ sessionCache.set(token, { sessionId: row.id, userId, pushAddress: row.pushAddress ?? null, at: now() })
235
+ const pushAddress = await followPushAddress(token, row.id, row.pushAddress ?? null, pushAddressOf(ctx))
236
+ ctx.auth = { sessionId: row.id, userId, pushAddress }
206
237
  return
207
238
  }
208
239
  }