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.
- package/README.md +104 -76
- package/dist/global.d.ts +3 -5
- package/dist/host.d.ts +3 -0
- package/dist/types/inject.d.ts +4 -4
- package/dist/types/net/codec.d.ts +3 -2
- package/dist/types/net/core.d.ts +10 -1
- package/dist/types/net/index.d.ts +22 -5
- package/dist/types/net/replication.d.ts +23 -2
- package/dist/types/runtime/device.d.ts +7 -0
- package/dist/types/runtime/rpc.d.ts +11 -17
- package/dist/types/runtime/wire.d.ts +53 -0
- package/dist/types/server/auth/api.d.ts +42 -0
- package/dist/types/server/auth/appConfig.d.ts +1 -5
- package/dist/types/server/auth/models.d.ts +119 -70
- package/dist/types/server/auth/types.d.ts +19 -43
- package/dist/types/server/channel.d.ts +57 -19
- package/dist/types/server/context.d.ts +2 -2
- package/dist/types/server/db/defineDb.d.ts +10 -0
- package/dist/types/server/db/index.d.ts +1 -1
- package/dist/types/server/db/types.d.ts +76 -6
- package/dist/types/server/inject.d.ts +0 -1
- package/dist/types/ui/UINode.d.ts +19 -5
- package/dist/types/ui/UIScreen.d.ts +1 -0
- package/dist/types/ui/UITabs.d.ts +8 -6
- package/dist/types/ui/theme.d.ts +48 -13
- package/dist/types/version.d.ts +1 -1
- package/dist/types.json +1 -1
- package/package.json +4 -2
- package/prompts/README.md +1 -1
- package/prompts/design.md +19 -19
- package/prompts/dist/2d-game.md +45 -31
- package/prompts/dist/3d-app.md +45 -31
- package/prompts/dist/ar-app.md +45 -31
- package/prompts/dist/design.md +25 -24
- package/prompts/dist/ui-app.md +45 -31
- package/prompts/ui-design.md +6 -5
- package/prompts/ui.md +25 -22
- package/src/animate/tween/read.ts +146 -0
- package/src/bridges/device.d.ts +9 -0
- package/src/bridges/tree.d.ts +5 -0
- package/src/canvas/gen/cssColor.ts +1 -1
- package/src/canvas/gen/recorder.ts +1 -1
- package/src/canvas/gen/spec.ts +1 -1
- package/src/chisel.ts +1 -1
- package/src/compile/bundler.ts +6 -0
- package/src/compile/compileProject.ts +3 -1
- package/src/compile/index.ts +3 -1
- package/src/compile/serverSplit.ts +58 -11
- package/src/compile/serverTypes.ts +189 -8
- package/src/host.d.ts +3 -0
- package/src/inject.ts +7 -7
- package/src/net/codec.ts +19 -10
- package/src/net/core.ts +18 -5
- package/src/net/index.ts +30 -9
- package/src/net/replication.ts +63 -28
- package/src/runtime/device.ts +12 -0
- package/src/runtime/rpc.ts +101 -40
- package/src/runtime/wire.ts +35 -0
- package/src/server/auth/api.ts +94 -0
- package/src/server/auth/appConfig.ts +2 -3
- package/src/server/auth/host.ts +244 -174
- package/src/server/auth/models.ts +45 -62
- package/src/server/auth/types.ts +19 -34
- package/src/server/channel.ts +97 -29
- package/src/server/channelHub.ts +153 -0
- package/src/server/context.ts +2 -2
- package/src/server/db/defineDb.ts +96 -36
- package/src/server/db/index.ts +1 -1
- package/src/server/db/types.ts +76 -8
- package/src/server/host.ts +25 -10
- package/src/server/inject.ts +2 -2
- package/src/server/runtime.ts +34 -12
- package/src/ui/UINode.ts +22 -5
- package/src/ui/UIScreen.ts +5 -0
- package/src/ui/UITabs.ts +19 -17
- package/src/ui/styleColor.ts +10 -1
- package/src/ui/theme.ts +96 -41
- package/src/version.ts +1 -1
- package/tests/helpers/fakeTree.ts +1 -0
- package/dist/types/plugins/oauth.d.ts +0 -25
- package/dist/types/server/auth/global.d.ts +0 -56
- package/src/plugins/oauth.ts +0 -61
- package/src/server/auth/global.ts +0 -80
- 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) {
|
package/src/compile/index.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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[]
|
|
140
|
+
export const deriveEndpointParams = async (entries: CompileEntry[], modules: Record<string, ServerExport[]>, typescript?: TS): Promise<DeriveResult> => {
|
|
56
141
|
let ts: TS
|
|
57
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(`${
|
|
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, `${
|
|
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
|
|
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
|
|
122
|
-
//
|
|
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`);
|
|
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
|
-
|
|
23
|
-
|
|
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 ===
|
|
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
|
-
|
|
107
|
-
|
|
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 =
|
|
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
|
|
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++,
|
|
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) {
|