lecodes-sdk 2.0.4 → 2.0.6

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 (72) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +2 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +3 -3
  5. package/dist/types/runtime/device.d.ts +7 -0
  6. package/dist/types/runtime/rpc.d.ts +11 -17
  7. package/dist/types/runtime/wire.d.ts +53 -0
  8. package/dist/types/server/auth/api.d.ts +42 -0
  9. package/dist/types/server/auth/appConfig.d.ts +1 -5
  10. package/dist/types/server/auth/models.d.ts +119 -70
  11. package/dist/types/server/auth/types.d.ts +19 -43
  12. package/dist/types/server/channel.d.ts +57 -19
  13. package/dist/types/server/context.d.ts +2 -2
  14. package/dist/types/server/db/defineDb.d.ts +10 -0
  15. package/dist/types/server/db/index.d.ts +1 -1
  16. package/dist/types/server/db/types.d.ts +76 -6
  17. package/dist/types/server/inject.d.ts +0 -1
  18. package/dist/types/ui/UINode.d.ts +19 -5
  19. package/dist/types/ui/UIScreen.d.ts +1 -0
  20. package/dist/types/ui/UITabs.d.ts +8 -6
  21. package/dist/types/ui/theme.d.ts +48 -13
  22. package/dist/types/version.d.ts +1 -1
  23. package/dist/types.json +1 -1
  24. package/package.json +3 -2
  25. package/prompts/README.md +1 -1
  26. package/prompts/design.md +19 -19
  27. package/prompts/dist/2d-game.md +45 -31
  28. package/prompts/dist/3d-app.md +45 -31
  29. package/prompts/dist/ar-app.md +45 -31
  30. package/prompts/dist/design.md +25 -24
  31. package/prompts/dist/ui-app.md +45 -31
  32. package/prompts/ui-design.md +6 -5
  33. package/prompts/ui.md +25 -22
  34. package/src/bridges/device.d.ts +9 -0
  35. package/src/bridges/tree.d.ts +5 -0
  36. package/src/chisel.ts +1 -1
  37. package/src/compile/bundler.ts +6 -0
  38. package/src/compile/compileProject.ts +3 -1
  39. package/src/compile/index.ts +3 -1
  40. package/src/compile/serverSplit.ts +58 -11
  41. package/src/compile/serverTypes.ts +189 -8
  42. package/src/host.d.ts +3 -0
  43. package/src/inject.ts +6 -6
  44. package/src/runtime/device.ts +12 -0
  45. package/src/runtime/rpc.ts +101 -40
  46. package/src/runtime/wire.ts +35 -0
  47. package/src/server/auth/api.ts +94 -0
  48. package/src/server/auth/appConfig.ts +2 -3
  49. package/src/server/auth/host.ts +244 -174
  50. package/src/server/auth/models.ts +45 -62
  51. package/src/server/auth/types.ts +19 -34
  52. package/src/server/channel.ts +97 -29
  53. package/src/server/channelHub.ts +153 -0
  54. package/src/server/context.ts +2 -2
  55. package/src/server/db/defineDb.ts +96 -36
  56. package/src/server/db/index.ts +1 -1
  57. package/src/server/db/types.ts +76 -8
  58. package/src/server/host.ts +25 -10
  59. package/src/server/inject.ts +2 -2
  60. package/src/server/runtime.ts +34 -12
  61. package/src/ui/UINode.ts +22 -5
  62. package/src/ui/UIScreen.ts +5 -0
  63. package/src/ui/UITabs.ts +19 -17
  64. package/src/ui/styleColor.ts +10 -1
  65. package/src/ui/theme.ts +96 -41
  66. package/src/version.ts +1 -1
  67. package/tests/helpers/fakeTree.ts +1 -0
  68. package/dist/types/plugins/oauth.d.ts +0 -25
  69. package/dist/types/server/auth/global.d.ts +0 -56
  70. package/src/plugins/oauth.ts +0 -61
  71. package/src/server/auth/global.ts +0 -80
  72. package/tests/helpers/memoryMarci.ts +0 -124
@@ -13,13 +13,17 @@
13
13
  * compile error: functions, classes with methods, `Date`, `Map`/`Set`, symbols, recursive types —
14
14
  * "endpoint args must be JSON-serializable". Return types are not validated.
15
15
  *
16
+ * A channel's `groupBy(fn)` is the same case: `fn` is called with what the app passed to
17
+ * `subscribe`, so its parameters get a schema under the channel's id.
18
+ *
16
19
  * `typescript` is loaded lazily (it's a devDependency of the SDK and a dependency of the platform
17
20
  * compile step); the browser-safe parts of the compiler never import this module's runtime.
18
21
  */
19
22
 
20
23
  import type { CompileEntry } from "./compileProject"
21
24
  import type { ParamSchema, Schema } from "../server/validate"
22
- import { serverModuleId, type ServerExport } from "./serverSplit"
25
+ import { isServerPath, serverModuleId, type ServerExport } from "./serverSplit"
26
+ import { readInjectSources } from "./bundler"
23
27
 
24
28
  type TS = typeof import("typescript")
25
29
 
@@ -42,6 +46,87 @@ export class EndpointTypeError extends Error {
42
46
  }
43
47
  }
44
48
 
49
+ /**
50
+ * In the app an endpoint's call is always a promise: it goes over the network. An endpoint that is
51
+ * not `async` is imported with the type it was written with (`number`), so `const n = add(1, 2)`
52
+ * type-checks and holds a promise. Declared `async`, the two agree.
53
+ */
54
+ export class SyncEndpointError extends Error {
55
+ readonly problems: string[]
56
+ constructor(problems: string[]) {
57
+ super(`an endpoint is called over the network, so in the app its result is a promise — declare it async:\n ${problems.join("\n ")}`)
58
+ this.name = "SyncEndpointError"
59
+ this.problems = problems
60
+ }
61
+ }
62
+
63
+ /**
64
+ * The app and the server are two programs with two sets of globals: `UIText`, `Vec3`, `toast` exist
65
+ * in the app only, `defineDb`, `ApiError`, `channel` in server files only. The project's types
66
+ * declare both sets everywhere (one tsconfig), so a global used on the wrong side type-checks — and
67
+ * is a ReferenceError when that line runs. The compile refuses it instead.
68
+ */
69
+ export class WrongSideGlobalError extends Error {
70
+ readonly problems: string[]
71
+ constructor(problems: string[]) {
72
+ super(`the app and the server have their own globals — one is used on the wrong side:\n ${problems.join("\n ")}`)
73
+ this.name = "WrongSideGlobalError"
74
+ this.problems = problems
75
+ }
76
+ }
77
+
78
+ /** The value names an inject module exports (`export { a, b as c, type T } from "…"`). */
79
+ const injectNames = (ts: TS, source: string): Set<string> => {
80
+ const out = new Set<string>()
81
+ const sf = ts.createSourceFile("inject.ts", source, ts.ScriptTarget.ESNext, false)
82
+ for (const st of sf.statements) {
83
+ if (!ts.isExportDeclaration(st) || st.isTypeOnly || !st.exportClause || !ts.isNamedExports(st.exportClause)) continue
84
+ for (const el of st.exportClause.elements) if (!el.isTypeOnly) out.add(el.name.text)
85
+ }
86
+ return out
87
+ }
88
+
89
+ /**
90
+ * Uses of a global of the other side, in the project's files of `program`. A name counts when it is
91
+ * a VALUE the file does not declare or import itself (the checker finds no symbol for it: this
92
+ * program has no SDK types) — so a local `t`, a parameter `request`, a property `x.model` are not
93
+ * touched. A name both sides have (`ref`), and one the server's runtime has by itself (`fetch`), are
94
+ * nobody's mistake.
95
+ */
96
+ const wrongSideGlobals = (ts: TS, program: import("typescript").Program, checker: import("typescript").TypeChecker, projectFiles: Set<string>): string[] => {
97
+ const sources = readInjectSources()
98
+ const app = injectNames(ts, sources.client), server = injectNames(ts, sources.server)
99
+ const appOnly = new Set([...app].filter(n => !server.has(n) && !(n in globalThis)))
100
+ const serverOnly = new Set([...server].filter(n => !app.has(n)))
101
+ const out: string[] = []
102
+ for (const sf of program.getSourceFiles()) {
103
+ if (!projectFiles.has(sf.fileName) || sf.isDeclarationFile) continue
104
+ const onServer = isServerPath(sf.fileName)
105
+ const foreign = onServer ? appOnly : serverOnly
106
+ const file = serverModuleId(sf.fileName)
107
+ const seen = new Set<string>()
108
+ const visit = (node: import("typescript").Node) => {
109
+ if (ts.isTypeNode(node)) return // a type is erased: no harm at run time
110
+ if (ts.isIdentifier(node) && foreign.has(node.text) && !seen.has(node.text)) {
111
+ const parent = node.parent as import("typescript").Node & { name?: import("typescript").Node, propertyName?: import("typescript").Node }
112
+ // the name of a declaration, of a property (`x.model`, `{ model: 1 }`), of an import / export specifier
113
+ const isName = (parent.name === node || parent.propertyName === node) && !ts.isShorthandPropertyAssignment(parent)
114
+ const symbol = isName ? null : ts.isShorthandPropertyAssignment(parent) ? checker.getShorthandAssignmentValueSymbol(parent) : checker.getSymbolAtLocation(node)
115
+ if (!isName && !symbol) {
116
+ seen.add(node.text) // once per file: the first use
117
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
118
+ out.push(onServer
119
+ ? `${file}:${line + 1}: ${node.text} is a global of the app — it does not exist in server code`
120
+ : `${file}:${line + 1}: ${node.text} is a global of server files (*.server.ts) — it does not exist in the app: call an endpoint that uses it`)
121
+ }
122
+ }
123
+ ts.forEachChild(node, visit)
124
+ }
125
+ visit(sf)
126
+ }
127
+ return out
128
+ }
129
+
45
130
  export type DeriveResult = {
46
131
  /** endpoint id → parameter schemas (only endpoints the checker could see). */
47
132
  params: Record<string, ParamSchema[]>
@@ -52,9 +137,10 @@ export type DeriveResult = {
52
137
  * Derive `{ "<module>#<fn>": ParamSchema[] }` for every function export of every `*.server.ts` entry.
53
138
  * Throws `EndpointTypeError` when a parameter type can't be validated at runtime.
54
139
  */
55
- export const deriveEndpointParams = async (entries: CompileEntry[], modules: Record<string, ServerExport[]>): Promise<DeriveResult> => {
140
+ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Record<string, ServerExport[]>, typescript?: TS): Promise<DeriveResult> => {
56
141
  let ts: TS
57
- try {
142
+ if (typescript) ts = typescript
143
+ else try {
58
144
  ts = (await import("typescript")).default ?? (await import("typescript")) as unknown as TS
59
145
  } catch {
60
146
  throw new Error("serverTypes: the `typescript` package is required to compile server endpoints")
@@ -66,7 +152,8 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
66
152
  if (!/\.[tj]sx?$/.test(e.path)) continue
67
153
  files.set(normalizePath(e.path), e.text)
68
154
  }
69
- const roots = Object.keys(modules).map(normalizePath).filter(p => files.has(p))
155
+ // the server modules, and every other file of the project: the app's are read for the globals they use
156
+ const roots = [...new Set([...Object.keys(modules).map(normalizePath).filter(p => files.has(p)), ...files.keys()])]
70
157
 
71
158
  const options: import("typescript").CompilerOptions = {
72
159
  target: ts.ScriptTarget.ESNext,
@@ -104,6 +191,11 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
104
191
  const params: Record<string, ParamSchema[]> = {}
105
192
  const warnings: string[] = []
106
193
  const problems: string[] = []
194
+ const sync: string[] = []
195
+ // a promise, or anything awaited the same way (a query handed back as it is); `any` is not judged
196
+ const awaitable = (type: import("typescript").Type): boolean =>
197
+ !!(type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown))
198
+ || (type.flags & ts.TypeFlags.Union ? (type as import("typescript").UnionType).types.every(awaitable) : !!type.getProperty("then"))
107
199
 
108
200
  for (const [path, exports] of Object.entries(modules)) {
109
201
  const sf = program.getSourceFile(normalizePath(path))
@@ -112,26 +204,45 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
112
204
  if (!modSym) continue
113
205
  const exported = new Map(checker.getExportsOfModule(modSym).map(s => [s.name, s]))
114
206
  for (const ex of exports) {
115
- if (ex.kind !== "fn") continue
207
+ if (ex.kind === "other") continue
116
208
  const id = `${serverModuleId(path)}#${ex.name}`
117
209
  let sym = exported.get(ex.name)
118
210
  if (!sym) { warnings.push(`${id}: export not found by the checker — unvalidated`); continue }
119
211
  if (sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym)
120
212
  const decl = sym.valueDeclaration ?? sym.declarations?.[0]
121
213
  if (!decl) { warnings.push(`${id}: no declaration — unvalidated`); continue }
122
- const type = checker.getTypeOfSymbolAtLocation(sym, decl)
214
+ let type: import("typescript").Type
215
+ let where = id
216
+ if (ex.kind === "channel") {
217
+ // `channel` is a global this program has no types for: the hook is found in the text of the export
218
+ const hook = groupByHook(ts, decl)
219
+ if (!hook) { params[id] = []; continue }
220
+ type = checker.getTypeAtLocation(hook)
221
+ where = `${id}.groupBy`
222
+ } else {
223
+ type = checker.getTypeOfSymbolAtLocation(sym, decl)
224
+ }
123
225
  const sig = type.getCallSignatures()[0]
124
- if (!sig) { warnings.push(`${id}: not callable — unvalidated`); continue }
226
+ if (!sig) { warnings.push(`${where}: not callable — unvalidated`); continue }
227
+ if (ex.kind === "fn" && !awaitable(checker.getReturnTypeOfSignature(sig))) {
228
+ const { line } = sf.getLineAndCharacterOfPosition(decl.getStart(sf))
229
+ sync.push(`${serverModuleId(path)}:${line + 1}: ${ex.name} — write \`async\` before it`)
230
+ }
125
231
  const list: ParamSchema[] = []
126
232
  for (const p of sig.getParameters()) {
127
233
  const pd = p.valueDeclaration as import("typescript").ParameterDeclaration | undefined
234
+ // nothing here gives a hook's parameter a type but its own annotation — without one it is `any`, unchecked
235
+ if (ex.kind === "channel" && pd && !pd.type && !pd.initializer) {
236
+ problems.push(`${where}(${p.name}): give the parameter a type — it is what the app passes to subscribe, and it is checked against it`)
237
+ continue
238
+ }
128
239
  const ptype = checker.getTypeOfSymbolAtLocation(p, pd ?? decl)
129
240
  const rest = !!pd?.dotDotDotToken
130
241
  const optional = !!pd?.questionToken || !!pd?.initializer || rest
131
242
  try {
132
243
  // `x?: T` carries `undefined` in its type — the optional flag covers it, strip it from the schema
133
244
  const members = ptype.flags & ts.TypeFlags.Union ? (ptype as import("typescript").UnionType).types : [ptype]
134
- const schema = lowerMembers(ts, checker, pd?.questionToken ? members.filter(t => !(t.flags & ts.TypeFlags.Undefined)) : members, `${id}(${p.name})`, new Set())
245
+ const schema = lowerMembers(ts, checker, pd?.questionToken ? members.filter(t => !(t.flags & ts.TypeFlags.Undefined)) : members, `${where}(${p.name})`, new Set())
135
246
  list.push({ name: p.name, schema, optional, rest })
136
247
  } catch (e) {
137
248
  problems.push((e as Error).message)
@@ -141,9 +252,79 @@ export const deriveEndpointParams = async (entries: CompileEntry[], modules: Rec
141
252
  }
142
253
  }
143
254
  if (problems.length) throw new EndpointTypeError(problems)
255
+ if (sync.length) throw new SyncEndpointError(sync)
256
+ const wrongSide = wrongSideGlobals(ts, program, checker, new Set(files.keys()))
257
+ if (wrongSide.length) throw new WrongSideGlobalError(wrongSide)
258
+ const unawaited: string[] = []
259
+ for (const path of Object.keys(modules)) {
260
+ const sf = program.getSourceFile(normalizePath(path))
261
+ if (sf) unawaited.push(...unawaitedUserQueries(ts, sf, serverModuleId(path)))
262
+ }
263
+ if (unawaited.length) throw new UnawaitedUserQueryError(unawaited)
144
264
  return { params, warnings }
145
265
  }
146
266
 
267
+ /** The function given to `.groupBy(…)` in a channel export's initializer (`channel<M>().authorize(…).groupBy(fn)`). */
268
+ const groupByHook = (ts: TS, decl: import("typescript").Declaration): import("typescript").Expression | null => {
269
+ let node: import("typescript").Expression | undefined = ts.isVariableDeclaration(decl) ? decl.initializer : undefined
270
+ while (node) {
271
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isNonNullExpression(node)) { node = node.expression; continue }
272
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return null
273
+ if (node.expression.name.text === "groupBy") return node.arguments[0] ?? null
274
+ node = node.expression.expression
275
+ }
276
+ return null
277
+ }
278
+
279
+ // ───────────────────────────── the user of a request is a QUERY ─────────────────────────────
280
+
281
+ /**
282
+ * `db.auth.requireUser(...)` / `db.auth.user()` build a query: the check of who is calling happens when
283
+ * it is AWAITED. Written as a statement, or tested in a condition, it does nothing — and an endpoint
284
+ * that looks guarded is open to anyone. So such a query must be awaited (or returned) where it is made.
285
+ */
286
+ export class UnawaitedUserQueryError extends Error {
287
+ readonly problems: string[]
288
+ constructor(problems: string[]) {
289
+ super(`the user of a request is read with await — a query that is not awaited checks nothing:\n ${problems.join("\n ")}`)
290
+ this.name = "UnawaitedUserQueryError"
291
+ this.problems = problems
292
+ }
293
+ }
294
+
295
+ const unawaitedUserQueries = (ts: TS, sf: import("typescript").SourceFile, file: string): string[] => {
296
+ const out: string[] = []
297
+ // `<x>.requireUser(…)` whatever it is called on; `.user()` only on something named `auth` (`auth.user()`, `db.auth.user()`)
298
+ const isUserQuery = (node: import("typescript").Node): node is import("typescript").CallExpression => {
299
+ if (!ts.isCallExpression(node) || !ts.isPropertyAccessExpression(node.expression)) return false
300
+ const name = node.expression.name.text
301
+ if (name === "requireUser") return true
302
+ if (name !== "user" || node.arguments.length) return false
303
+ const on = node.expression.expression
304
+ return (ts.isIdentifier(on) && on.text === "auth") || (ts.isPropertyAccessExpression(on) && on.name.text === "auth")
305
+ }
306
+ const awaited = (node: import("typescript").Node): boolean => {
307
+ const parent = node.parent
308
+ if (ts.isParenthesizedExpression(parent) || ts.isAsExpression(parent) || ts.isNonNullExpression(parent)) return awaited(parent)
309
+ // `.select(…)` continues the same query
310
+ if (ts.isPropertyAccessExpression(parent) && parent.expression === node && parent.name.text === "select" && ts.isCallExpression(parent.parent)) return awaited(parent.parent)
311
+ if (ts.isAwaitExpression(parent) || ts.isReturnStatement(parent)) return true
312
+ if (ts.isArrowFunction(parent) && parent.body === node) return true
313
+ // one of several things awaited together: `await Promise.all([db.auth.requireUser(), …])`
314
+ if (ts.isArrayLiteralExpression(parent) && ts.isCallExpression(parent.parent)) return awaited(parent.parent)
315
+ return false
316
+ }
317
+ const visit = (node: import("typescript").Node) => {
318
+ if (isUserQuery(node) && !awaited(node)) {
319
+ const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf))
320
+ out.push(`${file}:${line + 1}: ${node.expression.getText(sf)}(…) — write \`await\` before it`)
321
+ }
322
+ ts.forEachChild(node, visit)
323
+ }
324
+ visit(sf)
325
+ return out
326
+ }
327
+
147
328
  // ───────────────────────────── type → schema ─────────────────────────────
148
329
 
149
330
  const lower = (ts: TS, checker: import("typescript").TypeChecker, type: import("typescript").Type, where: string, stack: Set<import("typescript").Type>): Schema => {
package/src/host.d.ts CHANGED
@@ -31,6 +31,9 @@ declare global {
31
31
  /** Compile-time macro: `asset('./data.json')` yields the file's PARSED data (a .json file is
32
32
  * data, not code — it ships inside the bundle). See the `string` overload for everything else. */
33
33
  function asset(path: `${string}.json`): any
34
+ /** Compile-time macro: `asset('./logo.svg')` yields the file as an SVG image source — for
35
+ * `UIImage(...)` and `bgImage`. */
36
+ function asset(path: `${string}.svg`): { readonly svg: string, tintColor: string | null }
34
37
  /** Compile-time macro: `asset('./hero.png')` is desugared by the bundler into the module import
35
38
  * for that resource. The file must exist — a path that resolves to nothing fails the compile
36
39
  * (`asset not found: ./hero.png (main.ts:3)`); there is no runtime fallback. Calls inside
package/src/inject.ts CHANGED
@@ -42,7 +42,8 @@ export { WebSocket } from "./runtime/net"
42
42
  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"
43
43
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
- export { __rpc, __channel, __serverOnly, RpcError, type ClientChannel } from "./runtime/rpc"
45
+ export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
46
+ export type { ChannelSubscription } from "./server/channel"
46
47
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
47
48
  // Game audio (docs/audio-plan.md): decoded clips, a voice pool, buses with effects; 3D through the
48
49
  // AudioSource / AudioZone aspects (gl/) and scene.audio.
@@ -118,9 +119,9 @@ export type { DismissOptions, PresentOptions, Transition, TransitionName, Transi
118
119
  // The base node type + the union a container accepts as children — documented as the signature of
119
120
  // UIColumn/UIRow/append/etc., so they must be global (type-only; erased at runtime).
120
121
  export type { UINode, UINodeChild } from "./ui/UINode"
121
- // App theme variables (docs/ui-theme-plan.md): theme({...}) merges + returns typed var()
122
- // accessors; system vars are static props (theme.primaryColor, theme["comfort-top"], …).
123
- export { theme, type ThemeValues, type ThemeAccessors } from "./ui/theme"
122
+ // App theme variables: theme({...}) merges + returns the var() accessors; the roles the SDK reads
123
+ // are static props (theme.accent, theme["comfort-top"], …). `el.theme({...})` scopes a subtree.
124
+ export { theme, type ThemeValues, type ThemeAccessors, type ThemeRoles } from "./ui/theme"
124
125
 
125
126
  // ---- animate ----
126
127
  // animate() runs on the keyframe core (a VALUE track) since 2026-09-14: same handle, easing and clocks
@@ -136,7 +137,7 @@ export { type EasingInput } from "./animate/tween/easing"
136
137
  export { easeIn, easeOut, easeInOut } from "./animate/easings"
137
138
 
138
139
  // ---- plugins (optional, host-provided capabilities — typed NativeView/service wrappers). The first-party
139
- // ones are VENDORED from the plugin repo (src/plugins/gen, `bun run vendor:plugins`); oauth / service are the SDK's. ----
140
+ // ones are VENDORED from the plugin repo (src/plugins/gen, `bun run vendor:plugins`); service is the SDK's. ----
140
141
  export { QRScanner } from "./plugins/gen/qr-scanner/sdk/qr-scanner"
141
142
  export { CameraView, type CameraFacing } from "./plugins/gen/camera/sdk/camera"
142
143
  export { Geolocation, type GeoPosition, type GeoOptions, type GeoWatch } from "./plugins/gen/geolocation/sdk/geolocation"
@@ -146,7 +147,6 @@ export {
146
147
  type LineLayer, type LineLayerOptions, type UserLocationOptions,
147
148
  } from "./plugins/gen/map/sdk/map"
148
149
  export { Push, type PushPayload, type PushStatus, type PushRegisterOptions, type PushEvent } from "./plugins/gen/push/sdk/push"
149
- export { OAuth, type OAuthCredential, type OAuthProviderName } from "./plugins/oauth"
150
150
  export { Service } from "./plugins/service"
151
151
 
152
152
  // ---- scenes as data (.scene.ts files — see src/scene/defineScene.ts) ----
@@ -133,6 +133,18 @@ export const device = {
133
133
  vibrate(style: HapticStyle = "medium"): void {
134
134
  _creatorDevice.vibrate?.(style)
135
135
  },
136
+ /** The host's performance overlay: one line over everything with the frames per second the
137
+ * display actually got, how evenly they came (`66 fps · 73% even` is judder, `60 fps · 100% even`
138
+ * is smooth), and the GPU's and the main thread's milliseconds per frame. The host measures and
139
+ * draws it itself about once a second — nothing of it runs in the app's JS, so it does not change
140
+ * what it measures. It belongs to the host, not to the project: it stays on when another project
141
+ * is opened. Reads `false`, and writing does nothing, on hosts without one (web, headless). */
142
+ get statsOverlay(): boolean {
143
+ return _creatorDevice.statsOverlay?.() ?? false
144
+ },
145
+ set statsOverlay(on: boolean) {
146
+ _creatorDevice.setStatsOverlay?.(!!on)
147
+ },
136
148
  /** Device-orientation sensor (gyro + accelerometer, fused) for tilt/steering and magic-window /
137
149
  * 360° panoramas. Poll `attitude` / `gravity` inside setLoop; they return the freshest fused
138
150
  * sample, so the sensor rate need not match your frame rate. Host-gated: a no-op with no sensor. */
@@ -10,10 +10,7 @@
10
10
  * Wire (implemented by the runner):
11
11
  * POST <serverUrl>/api/<id> body {"args":[…]}, `authorization: Bearer <session>` when known
12
12
  * → 200 {"ok":true,"result":…,"session"?:"…"} | {"ok":false,"status":n,"message":"…","session"?:"…"}
13
- * WS <serverUrl>/ws frames: → {t:"hello",session} {t:"sub",id,ch,topic} {t:"unsub",id}
14
- * ← {t:"session",session} {t:"ok",id} {t:"err",id,status,message} {t:"ev",ch,topic,event,data}
15
- * (`ev` is addressed by channel + topic, not by subscription id, so the server can fan one publish out
16
- * to every socket of a topic natively; the client delivers it to each matching subscription.)
13
+ * WS <serverUrl>/ws the channel socket — its frames are ./wire.ts
17
14
  *
18
15
  * The session token is transport-owned (cookie-like): stored under `lecodes.session:<serverUrl>` in
19
16
  * localStorage, sent on every call and on the socket, replaced whenever a response carries `session`.
@@ -23,6 +20,8 @@
23
20
  import { fetch } from "./fetch"
24
21
  import { localStorage } from "./storage"
25
22
  import { WebSocket } from "./net"
23
+ import type { ChannelGroup, ClientFrame, ServerFrame } from "./wire"
24
+ import type { ChannelSubscription } from "../server/channel"
26
25
 
27
26
  /** Rejection value of a failed endpoint call: `status` mirrors the server's `ApiError`
28
27
  * (0 = the request itself failed: no network / no server). */
@@ -41,12 +40,17 @@ const sessionKey = (serverUrl: string) => `lecodes.session:${serverUrl}`
41
40
  const getSession = (serverUrl: string): string | null => {
42
41
  try { return localStorage.getItem(sessionKey(serverUrl)) } catch { return null }
43
42
  }
44
- const setSession = (serverUrl: string, token: string | null) => {
43
+ const storeSession = (serverUrl: string, token: string | null) => {
45
44
  try {
46
45
  if (token) localStorage.setItem(sessionKey(serverUrl), token)
47
46
  else localStorage.removeItem(sessionKey(serverUrl))
48
47
  } catch { /* storage-less host: the session lives for this run only */ }
49
- sockets.get(serverUrl)?.onSessionChanged(token)
48
+ }
49
+ /** A response carried a token: when it is another one, the channel socket is told (its subscriptions are the old session's). */
50
+ const setSession = (serverUrl: string, token: string | null) => {
51
+ const before = getSession(serverUrl)
52
+ storeSession(serverUrl, token)
53
+ if (token !== before) sockets.get(serverUrl)?.onSessionChanged(token)
50
54
  }
51
55
 
52
56
  // ───────────────────────────── endpoints ─────────────────────────────
@@ -57,6 +61,9 @@ const noServer = (id: string) => new RpcError(0, `Endpoint ${id}: the project ha
57
61
  export const __rpc = (serverUrl: string, id: string) => {
58
62
  const call = async (...args: unknown[]): Promise<any> => {
59
63
  if (!serverUrl) throw noServer(id)
64
+ // an optional argument left out is `undefined` in its place — JSON has no such value (it would
65
+ // travel as null and fail the parameter's type), and it is the last ones
66
+ while (args.length && args[args.length - 1] === undefined) args.pop()
60
67
  const headers: Record<string, string> = { "content-type": "application/json" }
61
68
  const session = getSession(serverUrl)
62
69
  if (session) headers.authorization = `Bearer ${session}`
@@ -85,15 +92,23 @@ export const __serverOnly = (id: string): any =>
85
92
 
86
93
  // ───────────────────────────── channels ─────────────────────────────
87
94
 
88
- type Handlers = Record<string, ((payload: any) => void) | undefined> & {
89
- /** The socket dropped and came back: subscriptions were re-sent, but events in between are lost — refetch. */
95
+ type Options = {
96
+ /** The socket dropped and came back: subscriptions were re-sent, but what was published in between is lost — read the state again. */
90
97
  reconnect?: () => void
91
- /** The subscription was refused (`onJoin` threw) or the socket can't connect. */
98
+ /** The subscription was refused (a hook of the channel threw). It is asked again when the session changes. */
92
99
  error?: (e: RpcError) => void
93
100
  }
94
- type Sub = { id: number, ch: string, topic: string, handlers: Handlers, ready: boolean }
101
+ /** `group` is the server's answer (the `ok` frame): undefined until it came. `refused` = the server
102
+ * said no under that session: the subscription is kept and asked again under another one. */
103
+ type Sub = {
104
+ id: number, ch: string, args: unknown[], handler: (message: any) => void, options: Options,
105
+ group: ChannelGroup | null | undefined, refused: { session: string | null } | null,
106
+ }
95
107
 
96
108
  const BACKOFF_MS = [1000, 2000, 5000, 10000, 30000]
109
+ /** How long the socket outlives its last subscription: one screen closing its own and the next
110
+ * opening its own is not a new connection. */
111
+ const LINGER_MS = 3000
97
112
 
98
113
  /** One multiplexed socket per server URL, shared by every channel of the app. */
99
114
  class ChannelSocket {
@@ -103,28 +118,45 @@ class ChannelSocket {
103
118
  private nextId = 1
104
119
  private attempts = 0
105
120
  private timer: ReturnType<typeof setTimeout> | null = null
121
+ private linger: ReturnType<typeof setTimeout> | null = null
106
122
  private closedByUs = false
107
123
  private everOpened = false
108
124
  private serverUrl: string
109
125
 
110
126
  constructor(serverUrl: string) { this.serverUrl = serverUrl }
111
127
 
112
- subscribe(ch: string, topic: string, handlers: Handlers): { close(): void } {
113
- const sub: Sub = { id: this.nextId++, ch, topic, handlers, ready: false }
128
+ subscribe(ch: string, args: unknown[], handler: (message: any) => void, options: Options): ChannelSubscription {
129
+ const sub: Sub = { id: this.nextId++, ch, args, handler, options, group: undefined, refused: null }
114
130
  this.subs.set(sub.id, sub)
115
- if (this.open) this.send({ t: "sub", id: sub.id, ch, topic })
131
+ if (this.linger !== null) { clearTimeout(this.linger); this.linger = null }
132
+ if (this.open) this.send({ t: "sub", id: sub.id, ch, args })
116
133
  else this.connect()
117
134
  return {
118
135
  close: () => {
136
+ // the id changes when the subscription is sent again (a new session)
119
137
  if (!this.subs.delete(sub.id)) return
120
- if (this.open) this.send({ t: "unsub", id: sub.id })
121
- if (this.subs.size === 0) this.shutdown()
138
+ if (this.open && !sub.refused) this.send({ t: "unsub", id: sub.id })
139
+ this.idle()
122
140
  },
123
141
  }
124
142
  }
125
143
 
144
+ /** The session changed outside this socket (a sign-in, a sign-out): what the server granted was
145
+ * granted to someone else. It drops this socket's subscriptions on the `hello`; they are sent
146
+ * again under NEW ids, so an answer still on its way to an old one finds nothing. The refused
147
+ * ones are sent too: the new user may be let in. */
126
148
  onSessionChanged(token: string | null) {
127
- if (this.open) this.send({ t: "hello", session: token })
149
+ if (!this.open) return
150
+ this.send({ t: "hello", session: token })
151
+ const subs = [...this.subs.values()]
152
+ this.subs.clear()
153
+ for (const sub of subs) {
154
+ sub.id = this.nextId++
155
+ sub.group = undefined
156
+ sub.refused = null
157
+ this.subs.set(sub.id, sub)
158
+ this.send({ t: "sub", id: sub.id, ch: sub.ch, args: sub.args })
159
+ }
128
160
  }
129
161
 
130
162
  private connect() {
@@ -136,18 +168,22 @@ class ChannelSocket {
136
168
  ws.addEventListener("open", () => {
137
169
  this.open = true
138
170
  this.attempts = 0
139
- this.send({ t: "hello", session: getSession(this.serverUrl) })
171
+ const session = getSession(this.serverUrl)
172
+ this.send({ t: "hello", session })
140
173
  const reconnected = this.everOpened
141
174
  this.everOpened = true
142
175
  for (const sub of this.subs.values()) {
143
- sub.ready = false
144
- this.send({ t: "sub", id: sub.id, ch: sub.ch, topic: sub.topic })
145
- if (reconnected) sub.handlers.reconnect?.()
176
+ // refused under this very session: the answer would be the same
177
+ if (sub.refused && sub.refused.session === session) continue
178
+ sub.group = undefined
179
+ sub.refused = null
180
+ this.send({ t: "sub", id: sub.id, ch: sub.ch, args: sub.args })
181
+ if (reconnected) sub.options.reconnect?.()
146
182
  }
147
183
  })
148
184
  ws.addEventListener("message", (data) => {
149
185
  if (typeof data !== "string") return
150
- let msg: any
186
+ let msg: ServerFrame
151
187
  try { msg = JSON.parse(data) } catch { return }
152
188
  this.onFrame(msg)
153
189
  })
@@ -155,7 +191,8 @@ class ChannelSocket {
155
191
  if (this.ws !== ws) return
156
192
  this.ws = null
157
193
  this.open = false
158
- if (this.closedByUs || this.subs.size === 0) return
194
+ if (this.closedByUs) return
195
+ if (this.subs.size === 0) { this.shutdown(); return }
159
196
  const delay = BACKOFF_MS[Math.min(this.attempts++, BACKOFF_MS.length - 1)]
160
197
  this.timer = setTimeout(() => { this.timer = null; this.connect() }, delay)
161
198
  }
@@ -163,35 +200,49 @@ class ChannelSocket {
163
200
  ws.addEventListener("error", onDown)
164
201
  }
165
202
 
166
- private onFrame(msg: any) {
203
+ private onFrame(msg: ServerFrame) {
167
204
  switch (msg?.t) {
168
- case "session": setSession(this.serverUrl, msg.session ?? null); break
169
- case "ok": { const s = this.subs.get(msg.id); if (s) s.ready = true; break }
205
+ // the server already serves this socket under the token it sends: stored, nothing re-sent
206
+ case "session": storeSession(this.serverUrl, msg.session ?? null); break
207
+ case "ok": { const s = this.subs.get(msg.id); if (s) s.group = msg.group ?? null; break }
170
208
  case "err": {
171
209
  const s = this.subs.get(msg.id)
172
210
  if (!s) break
173
- this.subs.delete(msg.id)
174
- s.handlers.error?.(new RpcError(msg.status ?? 0, msg.message ?? "subscription refused"))
211
+ // kept until close(): another session may be let in (onSessionChanged)
212
+ s.group = undefined
213
+ s.refused = { session: getSession(this.serverUrl) }
214
+ const e = new RpcError(msg.status ?? 0, msg.message ?? "subscription refused")
215
+ if (s.options.error) s.options.error(e)
216
+ else console.error(`Channel ${s.ch}: the subscription was refused (${e.status}) — ${e.message}`)
175
217
  break
176
218
  }
177
219
  case "ev": {
178
- for (const s of this.subs.values()) {
179
- if (s.ch === msg.ch && s.topic === msg.topic) s.handlers[msg.event]?.(msg.data)
220
+ const group = msg.group ?? null
221
+ for (const s of [...this.subs.values()]) {
222
+ if (s.ch === msg.ch && s.group === group) s.handler(msg.data)
180
223
  }
181
224
  break
182
225
  }
183
226
  }
184
227
  }
185
228
 
186
- private send(frame: unknown) { this.ws?.send(JSON.stringify(frame)) }
229
+ private send(frame: ClientFrame) { this.ws?.send(JSON.stringify(frame)) }
187
230
 
188
- private shutdown() {
231
+ /** Nothing is subscribed any more: the socket goes after LINGER_MS, unless something subscribes. */
232
+ private idle() {
233
+ if (this.subs.size !== 0 || this.linger !== null) return
234
+ this.linger = setTimeout(() => { this.linger = null; if (this.subs.size === 0) this.shutdown() }, LINGER_MS)
235
+ }
236
+
237
+ shutdown() {
189
238
  this.closedByUs = true
190
239
  if (this.timer !== null) { clearTimeout(this.timer); this.timer = null }
240
+ if (this.linger !== null) { clearTimeout(this.linger); this.linger = null }
191
241
  this.ws?.close()
192
242
  this.ws = null
193
243
  this.open = false
194
- sockets.delete(this.serverUrl)
244
+ this.subs.clear()
245
+ if (sockets.get(this.serverUrl) === this) sockets.delete(this.serverUrl)
195
246
  }
196
247
  }
197
248
 
@@ -202,15 +253,25 @@ const socketFor = (serverUrl: string) => {
202
253
  return s
203
254
  }
204
255
 
205
- export interface ClientChannel<E extends Record<string, unknown> = Record<string, unknown>> {
206
- /** Subscribe to `topic`; handlers are keyed by event name (+ `reconnect`/`error`). Returns `{ close }`. */
207
- subscribe(topic: string, handlers: { [K in keyof E]?: (payload: E[K]) => void } & { reconnect?: () => void, error?: (e: RpcError) => void }): { close(): void }
208
- }
256
+ /** @internal close every channel socket now (a test's teardown: no socket lingers into the next one). */
257
+ export const _closeChannelSockets = () => { for (const s of [...sockets.values()]) s.shutdown() }
209
258
 
210
- /** Build the client proxy of channel `id`. */
211
- export const __channel = (serverUrl: string, id: string): ClientChannel<any> => ({
212
- subscribe(topic, handlers) {
259
+ /**
260
+ * Build the client proxy of channel `id` — what a `channel()` export of a `*.server.ts` file is in
261
+ * the app (its type is the server's: src/server/channel.ts). `subscribe([...args,] handler, options?)`:
262
+ * what stands before the handler travels to the channel's `groupBy`.
263
+ */
264
+ export const __channel = (serverUrl: string, id: string) => ({
265
+ subscribe(...all: unknown[]): ChannelSubscription {
213
266
  if (!serverUrl) throw noServer(id)
214
- return socketFor(serverUrl).subscribe(id, topic, handlers as Handlers)
267
+ const at = all.findIndex(a => typeof a === "function")
268
+ if (at < 0) throw new RpcError(0, `Channel ${id}: subscribe([...args,] handler) — no handler given`)
269
+ // an optional argument left out is `undefined` in its place — JSON has no such value, and it is the last ones
270
+ const args = all.slice(0, at)
271
+ while (args.length && args[args.length - 1] === undefined) args.pop()
272
+ return socketFor(serverUrl).subscribe(id, args, all[at] as (message: any) => void, (all[at + 1] as Options | undefined) ?? {})
273
+ },
274
+ publish(): never {
275
+ throw new RpcError(0, `Channel ${id}: the server publishes, the app subscribes — call a server function that publishes`)
215
276
  },
216
277
  })
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The frames of the channel socket (`<serverUrl>/ws`) — the ONE description both ends are written
3
+ * against: the client transport (./rpc.ts) and the server's hub (../server/channelHub.ts, which the
4
+ * runner and the local backend of `lecodes dev` both run). JSON text frames, one per message.
5
+ *
6
+ * → hello who is on this socket. Sent first, and again whenever the session changes: the server
7
+ * then DROPS the socket's subscriptions (they were granted to someone else) and the
8
+ * client sends them again, under new ids.
9
+ * → sub subscribe to channel `ch`; `args` are what the app passed to `subscribe` before the
10
+ * handler — the channel's `groupBy` reads them.
11
+ * → unsub
12
+ * ← session a token minted while the socket was served (the first contact of a guest): the client
13
+ * stores it; nothing is dropped, the server already has it.
14
+ * ← ok the subscription stands; `group` is where the server put it (null = a channel without
15
+ * groups). The client learns its group only here — it never names one.
16
+ * ← err refused (a hook threw) — the subscription is gone.
17
+ * ← ev one published message, addressed by channel + group so the server sends one frame to
18
+ * every socket of a group; the client hands it to each of its subscriptions there.
19
+ *
20
+ * A group's value is not a secret: its subscriber is told it.
21
+ */
22
+
23
+ /** What `groupBy` may answer. `42` and `"42"` are two groups. */
24
+ export type ChannelGroup = string | number
25
+
26
+ export type ClientFrame =
27
+ | { t: "hello", session: string | null }
28
+ | { t: "sub", id: number, ch: string, args: unknown[] }
29
+ | { t: "unsub", id: number }
30
+
31
+ export type ServerFrame =
32
+ | { t: "session", session: string }
33
+ | { t: "ok", id: number, group: ChannelGroup | null }
34
+ | { t: "err", id: number, status: number, message: string }
35
+ | { t: "ev", ch: string, group: ChannelGroup | null, data: unknown }