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.
- package/README.md +104 -76
- package/dist/global.d.ts +2 -5
- package/dist/host.d.ts +3 -0
- package/dist/types/inject.d.ts +3 -3
- 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 +3 -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/bridges/device.d.ts +9 -0
- package/src/bridges/tree.d.ts +5 -0
- 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 +6 -6
- 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
|
@@ -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
|
@@ -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
|
|
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/runtime/device.ts
CHANGED
|
@@ -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. */
|
package/src/runtime/rpc.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
89
|
-
/** The socket dropped and came back: subscriptions were re-sent, but
|
|
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 (
|
|
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
|
-
|
|
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,
|
|
113
|
-
const sub: Sub = { id: this.nextId++, ch,
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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:
|
|
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
|
|
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:
|
|
203
|
+
private onFrame(msg: ServerFrame) {
|
|
167
204
|
switch (msg?.t) {
|
|
168
|
-
|
|
169
|
-
case "
|
|
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
|
-
|
|
174
|
-
s.
|
|
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
|
-
|
|
179
|
-
|
|
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:
|
|
229
|
+
private send(frame: ClientFrame) { this.ws?.send(JSON.stringify(frame)) }
|
|
187
230
|
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
/**
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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 }
|