lecodes-sdk 2.0.3 → 2.0.5

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 (84) hide show
  1. package/README.md +104 -76
  2. package/dist/global.d.ts +3 -5
  3. package/dist/host.d.ts +3 -0
  4. package/dist/types/inject.d.ts +4 -4
  5. package/dist/types/net/codec.d.ts +3 -2
  6. package/dist/types/net/core.d.ts +10 -1
  7. package/dist/types/net/index.d.ts +22 -5
  8. package/dist/types/net/replication.d.ts +23 -2
  9. package/dist/types/runtime/device.d.ts +7 -0
  10. package/dist/types/runtime/rpc.d.ts +11 -17
  11. package/dist/types/runtime/wire.d.ts +53 -0
  12. package/dist/types/server/auth/api.d.ts +42 -0
  13. package/dist/types/server/auth/appConfig.d.ts +1 -5
  14. package/dist/types/server/auth/models.d.ts +119 -70
  15. package/dist/types/server/auth/types.d.ts +19 -43
  16. package/dist/types/server/channel.d.ts +57 -19
  17. package/dist/types/server/context.d.ts +2 -2
  18. package/dist/types/server/db/defineDb.d.ts +10 -0
  19. package/dist/types/server/db/index.d.ts +1 -1
  20. package/dist/types/server/db/types.d.ts +76 -6
  21. package/dist/types/server/inject.d.ts +0 -1
  22. package/dist/types/ui/UINode.d.ts +19 -5
  23. package/dist/types/ui/UIScreen.d.ts +1 -0
  24. package/dist/types/ui/UITabs.d.ts +8 -6
  25. package/dist/types/ui/theme.d.ts +48 -13
  26. package/dist/types/version.d.ts +1 -1
  27. package/dist/types.json +1 -1
  28. package/package.json +4 -2
  29. package/prompts/README.md +1 -1
  30. package/prompts/design.md +19 -19
  31. package/prompts/dist/2d-game.md +45 -31
  32. package/prompts/dist/3d-app.md +45 -31
  33. package/prompts/dist/ar-app.md +45 -31
  34. package/prompts/dist/design.md +25 -24
  35. package/prompts/dist/ui-app.md +45 -31
  36. package/prompts/ui-design.md +6 -5
  37. package/prompts/ui.md +25 -22
  38. package/src/animate/tween/read.ts +146 -0
  39. package/src/bridges/device.d.ts +9 -0
  40. package/src/bridges/tree.d.ts +5 -0
  41. package/src/canvas/gen/cssColor.ts +1 -1
  42. package/src/canvas/gen/recorder.ts +1 -1
  43. package/src/canvas/gen/spec.ts +1 -1
  44. package/src/chisel.ts +1 -1
  45. package/src/compile/bundler.ts +6 -0
  46. package/src/compile/compileProject.ts +3 -1
  47. package/src/compile/index.ts +3 -1
  48. package/src/compile/serverSplit.ts +58 -11
  49. package/src/compile/serverTypes.ts +189 -8
  50. package/src/host.d.ts +3 -0
  51. package/src/inject.ts +7 -7
  52. package/src/net/codec.ts +19 -10
  53. package/src/net/core.ts +18 -5
  54. package/src/net/index.ts +30 -9
  55. package/src/net/replication.ts +63 -28
  56. package/src/runtime/device.ts +12 -0
  57. package/src/runtime/rpc.ts +101 -40
  58. package/src/runtime/wire.ts +35 -0
  59. package/src/server/auth/api.ts +94 -0
  60. package/src/server/auth/appConfig.ts +2 -3
  61. package/src/server/auth/host.ts +244 -174
  62. package/src/server/auth/models.ts +45 -62
  63. package/src/server/auth/types.ts +19 -34
  64. package/src/server/channel.ts +97 -29
  65. package/src/server/channelHub.ts +153 -0
  66. package/src/server/context.ts +2 -2
  67. package/src/server/db/defineDb.ts +96 -36
  68. package/src/server/db/index.ts +1 -1
  69. package/src/server/db/types.ts +76 -8
  70. package/src/server/host.ts +25 -10
  71. package/src/server/inject.ts +2 -2
  72. package/src/server/runtime.ts +34 -12
  73. package/src/ui/UINode.ts +22 -5
  74. package/src/ui/UIScreen.ts +5 -0
  75. package/src/ui/UITabs.ts +19 -17
  76. package/src/ui/styleColor.ts +10 -1
  77. package/src/ui/theme.ts +96 -41
  78. package/src/version.ts +1 -1
  79. package/tests/helpers/fakeTree.ts +1 -0
  80. package/dist/types/plugins/oauth.d.ts +0 -25
  81. package/dist/types/server/auth/global.d.ts +0 -56
  82. package/src/plugins/oauth.ts +0 -61
  83. package/src/server/auth/global.ts +0 -80
  84. package/tests/helpers/memoryMarci.ts +0 -124
@@ -7,7 +7,7 @@ import { bundleProjectWithMap, scanModulesChisel, type MemoryFiles } from "./bun
7
7
  import { detectEntry } from "./detectEntry"
8
8
  import { buildHeader } from "./header"
9
9
  import { inlineSourceMapComment, offsetSourceMap } from "./sourcemap"
10
- import { stubServerEntries } from "./serverSplit"
10
+ import { ClientPublishError, clientPublishes, stubServerEntries } from "./serverSplit"
11
11
 
12
12
  export type CompileFileType = "text" | "resource" | "shader"
13
13
 
@@ -185,6 +185,8 @@ export const pluginsOfBundle = (mapJson: string): string[] => {
185
185
 
186
186
  export const compileProjectWithMap = async (opts: CompileOptions): Promise<CompileResult> => {
187
187
  // Client bundle: every *.server.ts becomes its RPC/channel stub (server code stays server-side).
188
+ const published = clientPublishes(opts.entries)
189
+ if (published.length) throw new ClientPublishError(published)
188
190
  const entries = stubServerEntries(opts.entries, opts.serverUrl ?? "")
189
191
  const entry = opts.entryOverride ?? detectEntry(entries, scanModulesChisel).entry
190
192
  if (!entry) {
@@ -4,6 +4,8 @@
4
4
  export { bundleProject, bundleProjectWithMap, scanModulesChisel, CLIENT_INJECT, SERVER_INJECT, type MemoryFiles } from "./bundler"
5
5
  export {
6
6
  compileServerBundle,
7
+ clientPublishes,
8
+ ClientPublishError,
7
9
  scanServerExports,
8
10
  scanServerModules,
9
11
  stubServerEntries,
@@ -17,7 +19,7 @@ export {
17
19
  type ServerExportKind,
18
20
  type ServerManifest,
19
21
  } from "./serverSplit"
20
- export { deriveEndpointParams, EndpointTypeError, type DeriveResult } from "./serverTypes"
22
+ export { deriveEndpointParams, EndpointTypeError, SyncEndpointError, UnawaitedUserQueryError, WrongSideGlobalError, type DeriveResult } from "./serverTypes"
21
23
  export {
22
24
  detectEntry,
23
25
  scanModulesFallback,
@@ -9,7 +9,7 @@
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, anything else is server-only.
13
13
  */
14
14
 
15
15
  import type { AppConfig } from "../server/auth/appConfig"
@@ -129,25 +129,70 @@ export const serverStubSource = (path: string, exports: ServerExport[], serverUr
129
129
  return `// client stub of ${mod} — generated by the lecodes compiler\n${lines.join("\n")}\n`
130
130
  }
131
131
 
132
+ // ───────────────────────────── a channel in the app ─────────────────────────────
133
+
134
+ /** A channel export has ONE type on both sides, so `publish` type-checks in the app — where the
135
+ * export is the subscribing proxy. Said at compile time, with what to do instead. */
136
+ export class ClientPublishError extends Error {
137
+ readonly problems: string[]
138
+ constructor(problems: string[]) {
139
+ 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 ")}`)
140
+ this.name = "ClientPublishError"
141
+ this.problems = problems
142
+ }
143
+ }
144
+
145
+ /** `<channel>.publish(` in the project's client files, for every channel imported from a `*.server.ts`. */
146
+ export const clientPublishes = (entries: CompileEntry[]): string[] => {
147
+ const channels = new Map<string, Set<string>>()
148
+ for (const [path, exports] of Object.entries(scanServerModules(entries))) {
149
+ const names = exports.filter(e => e.kind === "channel").map(e => e.name)
150
+ if (names.length) channels.set(path.replace(/\.[tj]s$/, ""), new Set(names))
151
+ }
152
+ if (channels.size === 0) return []
153
+ const out: string[] = []
154
+ for (const e of entries) {
155
+ if (e.type !== "text" || isServerPath(e.path) || !/\.[tj]sx?$/.test(e.path)) continue
156
+ const src = e.text ?? ""
157
+ const dir = normalizePath(e.path).replace(/\/[^/]*$/, "")
158
+ const locals: string[] = []
159
+ for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']/g)) {
160
+ if (!m[2].startsWith(".")) continue
161
+ const names = channels.get(normalizePath(`${dir}/${m[2]}`).replace(/\.[tj]s$/, ""))
162
+ if (!names) continue
163
+ for (const part of m[1].split(",")) {
164
+ const [imported, local] = part.split(/\s+as\s+/).map(s => s.trim())
165
+ if (imported && names.has(imported)) locals.push(local ?? imported)
166
+ }
167
+ }
168
+ if (!locals.length) continue
169
+ const code = blankCommentsAndStrings(src)
170
+ for (const local of locals) {
171
+ for (const m of code.matchAll(new RegExp(`(?<![\\w$.])${local.replace(/\$/g, "\\$")}\\s*\\.\\s*publish\\s*\\(`, "g"))) {
172
+ out.push(`${serverModuleId(e.path)}:${code.slice(0, m.index).split("\n").length}: ${local}.publish(…)`)
173
+ }
174
+ }
175
+ }
176
+ return out
177
+ }
178
+
132
179
  // ───────────────────────────── server entry ─────────────────────────────
133
180
 
134
181
  export const SERVER_ENTRY_PATH = "/.server-entry.ts"
135
182
  /** The SDK's app-config placeholder (src/server/auth/appConfig.ts) — the compile overrides it in the file map
136
- * with the project's real values (project files win over SDK files), so `auth.model.user()` and friends see
183
+ * with the project's real values (project files win over SDK files), so an SDK module that imports it sees
137
184
  * app.json as an ordinary import. */
138
185
  export const APP_CONFIG_PATH = "/__sdk/server/auth/appConfig.ts"
139
186
 
140
187
  /** Compile-time manifest embedded in the server bundle (validators land here — see serverTypes.ts). */
141
188
  export type ServerManifest = {
142
189
  modules: Record<string, ServerExport[]>
143
- /** endpoint id → JSON-schema-ish param list (filled by the TS-checker pass; absent = unvalidated). */
190
+ /** endpoint id → JSON-schema-ish param list (filled by the TS-checker pass; absent = unvalidated).
191
+ * A channel id → the parameters of its `groupBy` (what the app passes to `subscribe`). */
144
192
  params?: Record<string, unknown[]>
145
- /** The `app.json` subset the server runtime reads (name, auth providers); the bundle itself gets it as the
193
+ /** The `app.json` subset the server runtime reads (the app's name); the bundle itself gets it as the
146
194
  * compiled-in `appConfig` module (APP_CONFIG_PATH). */
147
195
  app?: AppConfig
148
- /** Some server file references the `auth` global: the runner then resolves a device session on every
149
- * request (minting one on first contact). Projects that never touch `auth` pay nothing. */
150
- usesAuth?: boolean
151
196
  }
152
197
 
153
198
  /** The synthetic entry: imports every export by name (chisel has no namespace imports) and registers
@@ -196,12 +241,15 @@ export type CompileServerOptions = {
196
241
  /** Derive per-endpoint argument validators from the TS parameter types (serverTypes.ts) and embed
197
242
  * them in the manifest. Default true; throws `EndpointTypeError` on non-JSON parameter types. */
198
243
  validate?: boolean
244
+ /** The TypeScript the validators are derived with, for a caller that brings its own (the CLI takes
245
+ * the project's); absent = the `typescript` package this module can import. */
246
+ typescript?: typeof import("typescript")
199
247
  /** Precomputed param schemas to embed instead of deriving them. */
200
248
  params?: Record<string, unknown[]>
201
249
  /** Server bundles are readable by default (stack traces in the server console). */
202
250
  minify?: boolean
203
251
  sourcemap?: boolean
204
- /** The project's `app.json` (name + auth providers) — embedded in the manifest and exposed to the bundle. */
252
+ /** The project's `app.json` subset (its name) — embedded in the manifest and exposed to the bundle. */
205
253
  app?: AppConfig
206
254
  }
207
255
 
@@ -224,12 +272,11 @@ export const compileServerBundle = async (opts: CompileServerOptions): Promise<{
224
272
  }
225
273
  let params = opts.params
226
274
  if (!params && opts.validate !== false) {
227
- const derived = await deriveEndpointParams(opts.entries, modules)
275
+ const derived = await deriveEndpointParams(opts.entries, modules, opts.typescript)
228
276
  for (const w of derived.warnings) console.warn(`warning: ${w}`)
229
277
  params = derived.params
230
278
  }
231
- const usesAuth = opts.entries.some(e => e.type === "text" && isServerPath(e.path) && /\bauth\s*\./.test(blankCommentsAndStrings(e.text ?? "")))
232
- const manifest: ServerManifest = { modules: Object.fromEntries(Object.entries(modules).map(([p, ex]) => [serverModuleId(p), ex])), params, app: opts.app, usesAuth }
279
+ const manifest: ServerManifest = { modules: Object.fromEntries(Object.entries(modules).map(([p, ex]) => [serverModuleId(p), ex])), params, app: opts.app }
233
280
  if (opts.app) files[APP_CONFIG_PATH] = `export const app = ${JSON.stringify(opts.app)}\n`
234
281
  files[SERVER_ENTRY_PATH] = serverEntrySource(modules, manifest)
235
282
  const { code } = await bundleProjectWithMap(
@@ -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
@@ -39,10 +39,11 @@ export { device, type HapticStyle, type MotionOptions } from "./runtime/device"
39
39
  export { Input, InputChannel, type InputKeyEvent, type InputGamepadEvent, type InputEventName, type GamepadState, type GamepadAxisName } from "./runtime/input"
40
40
  export { WebSocket } from "./runtime/net"
41
41
  // Multiplayer (docs/multiplayer-plan.md): roles, players, JSON messages over the _creatorNet transport.
42
- export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, type NetStatus, type NetEvents, type NetLaunch, type NetInput, type NetKind, type NetTransform } from "./net"
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) ----
package/src/net/codec.ts CHANGED
@@ -6,23 +6,31 @@
6
6
  // Nothing else: no strings, no nesting, no optionals — snapshot fields are flat and fixed-size by
7
7
  // design. Both sides run the same bundle, so the layout is derived, never transmitted; the `sig`
8
8
  // feeds the schema hash the handshake compares.
9
+ //
10
+ // A numeric field is INTERPOLATED between two snapshots unless the kind names it `discrete` (an
11
+ // index, a count, an id): a discrete field takes the later snapshot's value whole, as a boolean does.
12
+ // That is a rule of the reader — the wire is the same f32.
9
13
 
10
14
  export type FieldKind = 0 | 1 | 2 // f32 · bool · f32[]
11
- export type Field = { key: string; kind: FieldKind; len: number }
15
+ export type Field = { key: string; kind: FieldKind; len: number; step: boolean }
12
16
  export type Layout = { fields: Field[]; floats: number; bools: number; bytes: number; sig: string }
13
17
 
14
18
  const describe = (v: unknown): string => Array.isArray(v) ? `array of ${v.length}` : v === null ? "null" : typeof v
15
19
 
16
- export const makeLayout = (defaults: Record<string, unknown>, what: string): Layout => {
20
+ export const makeLayout = (defaults: Record<string, unknown>, what: string, discrete: readonly string[] = []): Layout => {
17
21
  const fields: Field[] = []
18
22
  let floats = 0
19
23
  let bools = 0
24
+ for (const key of discrete) {
25
+ if (!(key in defaults)) throw new Error(`${what}: discrete field '${key}' is not in the defaults`)
26
+ }
20
27
  for (const key of Object.keys(defaults)) {
21
28
  const v = defaults[key]
22
- if (typeof v === "number") { fields.push({ key, kind: 0, len: 1 }); floats++ }
23
- else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1 }); bools++ }
29
+ const step = discrete.includes(key)
30
+ if (typeof v === "number") { fields.push({ key, kind: 0, len: 1, step }); floats++ }
31
+ else if (typeof v === "boolean") { fields.push({ key, kind: 1, len: 1, step: true }); bools++ }
24
32
  else if (Array.isArray(v) && v.length > 0 && v.length <= 64 && v.every((n) => typeof n === "number")) {
25
- fields.push({ key, kind: 2, len: v.length }); floats += v.length
33
+ fields.push({ key, kind: 2, len: v.length, step }); floats += v.length
26
34
  } else {
27
35
  throw new Error(`${what}: field '${key}' must be a number, a boolean or a fixed-length number[] (got ${describe(v)})`)
28
36
  }
@@ -97,14 +105,15 @@ export const unpackFrom = (view: DataView, offset: number, layout: Layout, into:
97
105
  return o
98
106
  }
99
107
 
100
- /** `out = a + (b − a) · t` for the numeric fields; booleans take `b`'s. */
108
+ /** `out = a + (b − a) · t` for the numeric fields; booleans and the discrete ones take `b`'s. */
101
109
  export const lerpState = (layout: Layout, a: any, b: any, t: number, out: any): void => {
102
110
  for (const f of layout.fields) {
103
- if (f.kind === 0) out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
104
- else if (f.kind === 2) {
111
+ if (f.kind === 2) {
105
112
  const pa = a[f.key] as number[], pb = b[f.key] as number[], po = out[f.key] as number[]
106
- for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * t
107
- } else out[f.key] = b[f.key]
113
+ const k = f.step ? 1 : t
114
+ for (let i = 0; i < f.len; i++) po[i] = pa[i] + (pb[i] - pa[i]) * k
115
+ } else if (f.step) out[f.key] = b[f.key]
116
+ else out[f.key] = a[f.key] + (b[f.key] - a[f.key]) * t
108
117
  }
109
118
  }
110
119
 
package/src/net/core.ts CHANGED
@@ -48,7 +48,7 @@ export type NetStats = { rtt: number; loss: number; sentKbps: number; receivedKb
48
48
 
49
49
  export const CH_RELIABLE = 0
50
50
  export const CH_UNRELIABLE = 1
51
- export const PROTO = 2
51
+ export const PROTO = 3
52
52
  export const LOCAL_SLOT = -1
53
53
 
54
54
  // Record kinds from creator-net (creator-net.h CNET_RECORD_*).
@@ -67,6 +67,9 @@ export class NetPlayer {
67
67
  /** A local bag for game facts (score, team, ready). NOT replicated — anything a client must see
68
68
  * goes through a message or replicated state. */
69
69
  data: any = {}
70
+ /** Server: what this player's `Net.connect(…, { hello })` carried (the game's own: a build, a
71
+ * token) — the value `Net.listen({ accept })` was asked about. Undefined everywhere else. */
72
+ hello: unknown = undefined
70
73
  /** @internal transport slot on the server; LOCAL_SLOT for the in-process player */
71
74
  _slot: number
72
75
  /** @internal server side: this player's input ring (replication.ts) */
@@ -129,6 +132,10 @@ export const state = {
129
132
  connectResolve: null as null | (() => void),
130
133
  connectReject: null as null | ((e: Error) => void),
131
134
  myName: "Player",
135
+ /** client: the game's own part of the `$hello` (`Net.connect(…, { hello })`), opaque to the SDK */
136
+ hello: undefined as unknown,
137
+ /** server: the game's say in the handshake (`Net.listen({ accept })`) — a reason refuses */
138
+ accept: null as null | ((hello: unknown, who: { name: string }) => string | null | void),
132
139
  }
133
140
 
134
141
  // Replication's seams into the roster / pump (set by replication.ts when a session starts — from a
@@ -255,6 +262,8 @@ export const resetToOffline = (): void => {
255
262
  state.nextId = 1
256
263
  state.connectResolve = null
257
264
  state.connectReject = null
265
+ state.hello = undefined
266
+ state.accept = null
258
267
  }
259
268
 
260
269
  // ---- server side ------------------------------------------------------------------------------------
@@ -283,13 +292,17 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
283
292
  const [name, data] = parsed
284
293
  if (name === "$hello") {
285
294
  if (playerBySlot(slot)) return
286
- const reason = !data || data.proto !== PROTO ? "version" : hooks.helloCheck?.(data) ?? null
295
+ const name = String(data?.name ?? `Player ${state.nextId}`)
296
+ // the SDK's own checks first, then the game's say (it never sees a client of another protocol)
297
+ const reason = !data || data.proto !== PROTO ? "version"
298
+ : hooks.helloCheck?.(data) ?? state.accept?.(data.hello, { name }) ?? null
287
299
  if (reason) {
288
- sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason }]))
300
+ sendRaw(slot, CH_RELIABLE, JSON.stringify(["$reject", { reason: String(reason) }]))
289
301
  b.kick(slot)
290
302
  return
291
303
  }
292
- const p = addPlayer(state.nextId++, String(data.name ?? `Player ${state.nextId - 1}`), slot)
304
+ const p = addPlayer(state.nextId++, name, slot)
305
+ p.hello = data.hello
293
306
  sendRaw(slot, CH_RELIABLE, JSON.stringify(["$welcome", {
294
307
  id: p.id,
295
308
  players: state.players.filter((q) => q !== p).map((q) => ({ id: q.id, name: q.name })),
@@ -310,7 +323,7 @@ const onServerRecord = (kind: number, slot: number, arg: number, payload: unknow
310
323
  const onClientRecord = (kind: number, slot: number, arg: number, payload: unknown): void => {
311
324
  const b = bridge()!
312
325
  if (kind === REC_CONNECTED) {
313
- sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, ...(hooks.helloExtra?.() ?? {}) }]))
326
+ sendRaw(0, CH_RELIABLE, JSON.stringify(["$hello", { proto: PROTO, name: state.myName, hello: state.hello, ...(hooks.helloExtra?.() ?? {}) }]))
314
327
  return
315
328
  }
316
329
  if (kind === REC_CONNECT_FAILED || kind === REC_DISCONNECTED) {