@mt-tl/tl 0.1.0
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/LICENSE +21 -0
- package/README.md +40 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +47 -0
- package/dist/cli.js.map +1 -0
- package/dist/codegen/gen-types.d.ts +10 -0
- package/dist/codegen/gen-types.d.ts.map +1 -0
- package/dist/codegen/gen-types.js +190 -0
- package/dist/codegen/gen-types.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +21 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.d.ts +63 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +147 -0
- package/dist/logger.js.map +1 -0
- package/dist/migrate.d.ts +37 -0
- package/dist/migrate.d.ts.map +1 -0
- package/dist/migrate.js +84 -0
- package/dist/migrate.js.map +1 -0
- package/dist/rpc.d.ts +74 -0
- package/dist/rpc.d.ts.map +1 -0
- package/dist/rpc.js +2 -0
- package/dist/rpc.js.map +1 -0
- package/dist/tl/crc32.d.ts +2 -0
- package/dist/tl/crc32.d.ts.map +1 -0
- package/dist/tl/crc32.js +42 -0
- package/dist/tl/crc32.js.map +1 -0
- package/dist/tl/ir.d.ts +68 -0
- package/dist/tl/ir.d.ts.map +1 -0
- package/dist/tl/ir.js +63 -0
- package/dist/tl/ir.js.map +1 -0
- package/dist/tl/parser.d.ts +22 -0
- package/dist/tl/parser.d.ts.map +1 -0
- package/dist/tl/parser.js +122 -0
- package/dist/tl/parser.js.map +1 -0
- package/dist/tl/value.d.ts +26 -0
- package/dist/tl/value.d.ts.map +1 -0
- package/dist/tl/value.js +44 -0
- package/dist/tl/value.js.map +1 -0
- package/dist/tools/freeze-layer.d.ts +26 -0
- package/dist/tools/freeze-layer.d.ts.map +1 -0
- package/dist/tools/freeze-layer.js +86 -0
- package/dist/tools/freeze-layer.js.map +1 -0
- package/dist/wire.d.ts +12 -0
- package/dist/wire.d.ts.map +1 -0
- package/dist/wire.js +50 -0
- package/dist/wire.js.map +1 -0
- package/package.json +76 -0
- package/schema/scheme_0_protocol.tl +150 -0
- package/src/cli.ts +48 -0
- package/src/codegen/gen-types.ts +203 -0
- package/src/index.ts +22 -0
- package/src/logger.ts +210 -0
- package/src/migrate.ts +101 -0
- package/src/rpc.ts +70 -0
- package/src/tl/crc32.ts +44 -0
- package/src/tl/ir.ts +105 -0
- package/src/tl/parser.ts +144 -0
- package/src/tl/value.ts +60 -0
- package/src/tools/freeze-layer.ts +108 -0
- package/src/wire.ts +54 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url'
|
|
2
|
+
|
|
3
|
+
export * from './tl/ir.js'
|
|
4
|
+
export * from './tl/value.js'
|
|
5
|
+
export * from './tl/parser.js'
|
|
6
|
+
export * from './tl/crc32.js'
|
|
7
|
+
export * from './rpc.js'
|
|
8
|
+
export * from './wire.js'
|
|
9
|
+
export * from './migrate.js'
|
|
10
|
+
// Shared structured logger — the server engine, the handler layer, and your app
|
|
11
|
+
// import it for one consistent log style (see docs/guide/observability.md).
|
|
12
|
+
export * from './logger.js'
|
|
13
|
+
// Codegen + layer tooling — consumers generate TS types and freeze layer
|
|
14
|
+
// snapshots from THEIR `.tl` schema.
|
|
15
|
+
export { generateSchemaTs, writeSchemaTs } from './codegen/gen-types.js'
|
|
16
|
+
export { freezeLayer, type FreezeResult } from './tools/freeze-layer.js'
|
|
17
|
+
|
|
18
|
+
// @mt-tl/tl ships only the fixed MTProto **protocol** schema; business `.tl`
|
|
19
|
+
// lives in the consumer app. This is the protocol-only default; apps pass their
|
|
20
|
+
// own schema dir to the gateway.
|
|
21
|
+
/** Absolute path to the bundled MTProto protocol `.tl` schema directory. */
|
|
22
|
+
export const protocolSchemaDir = fileURLToPath(new URL('../schema', import.meta.url))
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny dependency-free structured logger, shared across the MTProto packages
|
|
3
|
+
* (the server engine, the handler layer, and your app — import it for one
|
|
4
|
+
* consistent log style). Levels gate output; fields are key/value context.
|
|
5
|
+
*
|
|
6
|
+
* - `LOG_LEVEL` sets the threshold (`trace`<`debug`<`info`<`warn`<`error`<`silent`).
|
|
7
|
+
* - `LOG_FORMAT=json` emits one JSON object per line (ship to a log pipeline);
|
|
8
|
+
* anything else is a readable line for local dev.
|
|
9
|
+
* - `LOG_ERROR_STACK=true|false` forces whether `Error.stack` is serialized
|
|
10
|
+
* (default: on for `pretty`, off for `json` — prod opts in).
|
|
11
|
+
* - `pretty` output is ANSI-colored when stdout is a TTY (dim keys, colored
|
|
12
|
+
* level) for readability; honors `NO_COLOR` / `FORCE_COLOR`.
|
|
13
|
+
* - Tests default to `silent` (set `LOG_LEVEL` to override).
|
|
14
|
+
*
|
|
15
|
+
* Levels, by convention across this codebase:
|
|
16
|
+
* - `trace` — byte/hex protocol firehose (every recv, framing headers, decrypt).
|
|
17
|
+
* - `debug` — useful protocol events (decoded method, salt re-send, handshake step).
|
|
18
|
+
* - `info` — request/response one-liners + lifecycle links/unlinks (sockets,
|
|
19
|
+
* sessions, auth keys, users) + update deliveries.
|
|
20
|
+
* - `warn` — recoverable anomalies (decode failure, unknown method, integrity
|
|
21
|
+
* rejection, insecure config).
|
|
22
|
+
* - `error` — a request that failed or an update that could not be delivered.
|
|
23
|
+
*/
|
|
24
|
+
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'
|
|
25
|
+
|
|
26
|
+
const ORDER: Record<LogLevel, number> = {
|
|
27
|
+
trace: 5,
|
|
28
|
+
debug: 10,
|
|
29
|
+
info: 20,
|
|
30
|
+
warn: 30,
|
|
31
|
+
error: 40,
|
|
32
|
+
silent: 99,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type Fields = Record<string, unknown>
|
|
36
|
+
|
|
37
|
+
export interface Logger {
|
|
38
|
+
/** Byte/hex protocol firehose — guard heavy field building with {@link isLevelEnabled}. */
|
|
39
|
+
trace(msg: string, fields?: Fields): void
|
|
40
|
+
debug(msg: string, fields?: Fields): void
|
|
41
|
+
info(msg: string, fields?: Fields): void
|
|
42
|
+
warn(msg: string, fields?: Fields): void
|
|
43
|
+
error(msg: string, fields?: Fields): void
|
|
44
|
+
/** Returns a logger that merges `bindings` into every line (e.g. a scope/conn id). */
|
|
45
|
+
child(bindings: Fields): Logger
|
|
46
|
+
/** True when a message at `level` would be emitted — guard expensive field building. */
|
|
47
|
+
isLevelEnabled(level: LogLevel): boolean
|
|
48
|
+
/** The active threshold level. */
|
|
49
|
+
readonly level: LogLevel
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface LoggerOptions {
|
|
53
|
+
level?: LogLevel
|
|
54
|
+
/** 'json' for machine-readable lines, 'pretty' for humans. */
|
|
55
|
+
format?: 'json' | 'pretty'
|
|
56
|
+
name?: string
|
|
57
|
+
bindings?: Fields
|
|
58
|
+
/**
|
|
59
|
+
* Include `Error.stack` when serializing an Error field. Default: `true` for
|
|
60
|
+
* `pretty` (dev), `false` for `json` (prod opts in). `LOG_ERROR_STACK` overrides.
|
|
61
|
+
*/
|
|
62
|
+
errorStack?: boolean
|
|
63
|
+
/**
|
|
64
|
+
* ANSI-color the `pretty` output (dim keys, colored level). Default: auto — on
|
|
65
|
+
* when stdout is a TTY and `NO_COLOR` is unset (off when a custom `write` is
|
|
66
|
+
* given). Never colors `json`.
|
|
67
|
+
*/
|
|
68
|
+
color?: boolean
|
|
69
|
+
/** Sink (defaults to stdout/stderr by level). Override in tests. */
|
|
70
|
+
write?: (line: string) => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function defaultLevel(): LogLevel {
|
|
74
|
+
const env = process.env.LOG_LEVEL as LogLevel | undefined
|
|
75
|
+
if (env && env in ORDER) return env
|
|
76
|
+
if (process.env.VITEST || process.env.NODE_ENV === 'test') return 'silent'
|
|
77
|
+
return 'info'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function defaultFormat(): 'json' | 'pretty' {
|
|
81
|
+
return process.env.LOG_FORMAT === 'json' ? 'json' : 'pretty'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function defaultErrorStack(format: 'json' | 'pretty'): boolean {
|
|
85
|
+
const env = process.env.LOG_ERROR_STACK
|
|
86
|
+
if (env === 'true' || env === '1') return true
|
|
87
|
+
if (env === 'false' || env === '0') return false
|
|
88
|
+
return format === 'pretty'
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function defaultColor(format: 'json' | 'pretty'): boolean {
|
|
92
|
+
if (format === 'json') return false
|
|
93
|
+
if (process.env.NO_COLOR !== undefined) return false
|
|
94
|
+
if (process.env.FORCE_COLOR) return true
|
|
95
|
+
return !!process.stdout.isTTY
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ANSI styling for the pretty (TTY) format.
|
|
99
|
+
const RESET = '\x1b[0m'
|
|
100
|
+
const DIM = '\x1b[2m'
|
|
101
|
+
const BOLD = '\x1b[1m'
|
|
102
|
+
const GRAY = '\x1b[90m'
|
|
103
|
+
const LEVEL_COLOR: Record<Exclude<LogLevel, 'silent'>, string> = {
|
|
104
|
+
trace: '\x1b[90m', // gray
|
|
105
|
+
debug: '\x1b[36m', // cyan
|
|
106
|
+
info: '\x1b[32m', // green
|
|
107
|
+
warn: '\x1b[33m', // yellow
|
|
108
|
+
error: '\x1b[31m', // red
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function makeSerialize(errorStack: boolean): (value: unknown) => unknown {
|
|
112
|
+
return function serialize(value: unknown): unknown {
|
|
113
|
+
if (value instanceof Error) {
|
|
114
|
+
const base: Fields = { name: value.name, message: value.message }
|
|
115
|
+
if (errorStack && value.stack) base.stack = value.stack
|
|
116
|
+
return base
|
|
117
|
+
}
|
|
118
|
+
if (typeof value === 'bigint') return value.toString()
|
|
119
|
+
return value
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function prettyFields(fields: Fields, serialize: (v: unknown) => unknown, color: boolean): string {
|
|
124
|
+
const parts: string[] = []
|
|
125
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
126
|
+
const sv = serialize(v)
|
|
127
|
+
const val = typeof sv === 'object' && sv !== null ? JSON.stringify(sv) : String(sv)
|
|
128
|
+
// Dim the key so the eye separates key from value; values stay bright.
|
|
129
|
+
parts.push(color ? `${DIM}${k}=${RESET}${val}` : `${k}=${val}`)
|
|
130
|
+
}
|
|
131
|
+
if (!parts.length) return ''
|
|
132
|
+
// Wider gap between fields when colored (the TTY view we optimize for reading).
|
|
133
|
+
const sep = color ? ' ' : ' '
|
|
134
|
+
return sep + parts.join(sep)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function createLogger(options: LoggerOptions = {}): Logger {
|
|
138
|
+
const level = options.level ?? defaultLevel()
|
|
139
|
+
const format = options.format ?? defaultFormat()
|
|
140
|
+
const errorStack = options.errorStack ?? defaultErrorStack(format)
|
|
141
|
+
// A custom sink (tests/embedders) gets no color unless explicitly asked.
|
|
142
|
+
const color = options.color ?? (options.write ? false : defaultColor(format))
|
|
143
|
+
const name = options.name
|
|
144
|
+
const bindings = options.bindings ?? {}
|
|
145
|
+
const threshold = ORDER[level]
|
|
146
|
+
const serialize = makeSerialize(errorStack)
|
|
147
|
+
|
|
148
|
+
const emit = (lvl: Exclude<LogLevel, 'silent'>, msg: string, fields?: Fields) => {
|
|
149
|
+
if (ORDER[lvl] < threshold) return
|
|
150
|
+
const merged: Fields = { ...bindings, ...(fields ?? {}) }
|
|
151
|
+
const write = options.write ?? (lvl === 'error' || lvl === 'warn' ? errLine : outLine)
|
|
152
|
+
if (format === 'json') {
|
|
153
|
+
const obj: Fields = { time: new Date().toISOString(), level: lvl, ...(name ? { name } : {}), msg }
|
|
154
|
+
for (const [k, v] of Object.entries(merged)) obj[k] = serialize(v)
|
|
155
|
+
write(JSON.stringify(obj))
|
|
156
|
+
} else {
|
|
157
|
+
const ts = new Date().toISOString().slice(11, 23)
|
|
158
|
+
const lvlText = lvl.toUpperCase().padEnd(5)
|
|
159
|
+
if (color) {
|
|
160
|
+
const tag = name ? ` ${DIM}[${name}]${RESET}` : ''
|
|
161
|
+
write(
|
|
162
|
+
`${GRAY}${ts}${RESET} ${LEVEL_COLOR[lvl]}${lvlText}${RESET}${tag} ${BOLD}${msg}${RESET}` +
|
|
163
|
+
prettyFields(merged, serialize, true),
|
|
164
|
+
)
|
|
165
|
+
} else {
|
|
166
|
+
const tag = name ? ` [${name}]` : ''
|
|
167
|
+
write(`${ts} ${lvlText}${tag} ${msg}` + prettyFields(merged, serialize, false))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
level,
|
|
174
|
+
trace: (msg, fields) => emit('trace', msg, fields),
|
|
175
|
+
debug: (msg, fields) => emit('debug', msg, fields),
|
|
176
|
+
info: (msg, fields) => emit('info', msg, fields),
|
|
177
|
+
warn: (msg, fields) => emit('warn', msg, fields),
|
|
178
|
+
error: (msg, fields) => emit('error', msg, fields),
|
|
179
|
+
isLevelEnabled: lvl => ORDER[lvl] >= threshold,
|
|
180
|
+
child: extra =>
|
|
181
|
+
createLogger({
|
|
182
|
+
...options,
|
|
183
|
+
level,
|
|
184
|
+
format,
|
|
185
|
+
errorStack,
|
|
186
|
+
color,
|
|
187
|
+
name,
|
|
188
|
+
bindings: { ...bindings, ...extra },
|
|
189
|
+
}),
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function outLine(line: string): void {
|
|
194
|
+
process.stdout.write(line + '\n')
|
|
195
|
+
}
|
|
196
|
+
function errLine(line: string): void {
|
|
197
|
+
process.stderr.write(line + '\n')
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** A logger that drops everything (explicit opt-out in tests/embedders). */
|
|
201
|
+
export const noopLogger: Logger = {
|
|
202
|
+
level: 'silent',
|
|
203
|
+
trace() {},
|
|
204
|
+
debug() {},
|
|
205
|
+
info() {},
|
|
206
|
+
warn() {},
|
|
207
|
+
error() {},
|
|
208
|
+
isLevelEnabled: () => false,
|
|
209
|
+
child: () => noopLogger,
|
|
210
|
+
}
|
package/src/migrate.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { TlObject, TlValue } from './tl/value.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One rung of a predicate's migration ladder. `up` maps THIS version's shape to
|
|
5
|
+
* the NEXT version's; `down` maps the next version back to this one. The newest
|
|
6
|
+
* (canonical) rung has neither.
|
|
7
|
+
*/
|
|
8
|
+
export interface MigrationRung {
|
|
9
|
+
/** Layer this shape was introduced. */
|
|
10
|
+
since: number
|
|
11
|
+
up?: (obj: Record<string, unknown>) => Record<string, unknown>
|
|
12
|
+
down?: (obj: Record<string, unknown>) => Record<string, unknown>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Per-predicate migration ladders. The gateway normalizes inbound values to the
|
|
17
|
+
* canonical (newest) shape (`up`) before forwarding, and renders canonical values
|
|
18
|
+
* down to a client's layer (`down`) before encoding — so workers only ever see
|
|
19
|
+
* the canonical shape. Scales to N versions: one rung per version; adding a
|
|
20
|
+
* version appends a rung and never touches the others.
|
|
21
|
+
*
|
|
22
|
+
* Only non-additively-changed predicates need a ladder; everything else is
|
|
23
|
+
* handled by decode-union + layered-encode with no rungs (identity here).
|
|
24
|
+
*/
|
|
25
|
+
export class MigrationRegistry {
|
|
26
|
+
private byPredicate = new Map<string, MigrationRung[]>()
|
|
27
|
+
|
|
28
|
+
/** Register a predicate's ladder (rungs in any order; sorted by `since`). */
|
|
29
|
+
register(predicate: string, rungs: MigrationRung[]): this {
|
|
30
|
+
this.byPredicate.set(
|
|
31
|
+
predicate,
|
|
32
|
+
[...rungs].sort((a, b) => a.since - b.since),
|
|
33
|
+
)
|
|
34
|
+
return this
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
has(predicate: string): boolean {
|
|
38
|
+
return this.byPredicate.has(predicate)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get size(): number {
|
|
42
|
+
return this.byPredicate.size
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Normalize a value decoded at `fromLayer` up to the canonical shape. */
|
|
46
|
+
up(value: TlValue, fromLayer: number): TlValue {
|
|
47
|
+
return this.recurse(value, obj => this.upObject(obj, fromLayer))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Render a canonical value down to `toLayer`'s shape. */
|
|
51
|
+
down(value: TlValue, toLayer: number): TlValue {
|
|
52
|
+
return this.recurse(value, obj => this.downObject(obj, toLayer))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Children-first walk: normalize nested objects, then transform the parent.
|
|
56
|
+
private recurse(value: TlValue, fn: (obj: TlObject) => TlObject): TlValue {
|
|
57
|
+
if (Array.isArray(value)) return value.map(v => this.recurse(v, fn))
|
|
58
|
+
if (value && typeof value === 'object' && !Buffer.isBuffer(value) && '_' in value) {
|
|
59
|
+
const obj = value as TlObject
|
|
60
|
+
const next: TlObject = { _: obj._ }
|
|
61
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
62
|
+
if (k !== '_') next[k] = this.recurse(v as TlValue, fn)
|
|
63
|
+
}
|
|
64
|
+
return fn(next)
|
|
65
|
+
}
|
|
66
|
+
return value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private upObject(obj: TlObject, fromLayer: number): TlObject {
|
|
70
|
+
const rungs = this.byPredicate.get(obj._)
|
|
71
|
+
if (!rungs) return obj
|
|
72
|
+
let cur: Record<string, unknown> = obj
|
|
73
|
+
for (let i = startRung(rungs, fromLayer); i <= rungs.length - 2; i++) {
|
|
74
|
+
const up = rungs[i]!.up
|
|
75
|
+
if (up) cur = up(cur)
|
|
76
|
+
}
|
|
77
|
+
return cur as TlObject
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private downObject(obj: TlObject, toLayer: number): TlObject {
|
|
81
|
+
const rungs = this.byPredicate.get(obj._)
|
|
82
|
+
if (!rungs) return obj
|
|
83
|
+
const target = startRung(rungs, toLayer)
|
|
84
|
+
let cur: Record<string, unknown> = obj
|
|
85
|
+
for (let i = rungs.length - 2; i >= target; i--) {
|
|
86
|
+
const down = rungs[i]!.down
|
|
87
|
+
if (down) cur = down(cur)
|
|
88
|
+
}
|
|
89
|
+
return cur as TlObject
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Index of the rung active at `layer` (the latest with since <= layer; else 0). */
|
|
94
|
+
function startRung(rungs: MigrationRung[], layer: number): number {
|
|
95
|
+
let idx = 0
|
|
96
|
+
for (let i = 0; i < rungs.length; i++) {
|
|
97
|
+
if (rungs[i]!.since <= layer) idx = i
|
|
98
|
+
else break
|
|
99
|
+
}
|
|
100
|
+
return idx
|
|
101
|
+
}
|
package/src/rpc.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { JsonValue } from './tl/value.js'
|
|
2
|
+
|
|
3
|
+
/** Per-request connection context the engine attaches to every business call; surfaced as `ctx.request`. */
|
|
4
|
+
export interface RpcContext {
|
|
5
|
+
/** The MTProto session id (hex). */
|
|
6
|
+
sessionId: string
|
|
7
|
+
/** The connection's auth key id (hex). */
|
|
8
|
+
authKeyId: string
|
|
9
|
+
/**
|
|
10
|
+
* The **subject** bound to this auth key — your app's *internal* user id,
|
|
11
|
+
* opaque to the framework and safe to share across your services (e.g. a
|
|
12
|
+
* uuid). Set it via `ctx.login(subject)`; `undefined` for an anonymous key.
|
|
13
|
+
*
|
|
14
|
+
* This is deliberately NOT the wire `user_id` your TL schema exposes to
|
|
15
|
+
* clients (Telegram-style `int`/`long`). The framework only routes/persists
|
|
16
|
+
* by `subject`; mapping `subject ⇄ public user_id` is your app's job (see the
|
|
17
|
+
* demo's `users` module). Keeping them separate lets the public id stay an
|
|
18
|
+
* `int` while the protocol runs entirely on your internal id.
|
|
19
|
+
*/
|
|
20
|
+
subject?: string
|
|
21
|
+
/** The client's negotiated TL layer. */
|
|
22
|
+
apiLayer: number
|
|
23
|
+
/** `initConnection.api_id` — the client app id, if reported. */
|
|
24
|
+
apiId?: number
|
|
25
|
+
/** `initConnection.device_model`, if reported. */
|
|
26
|
+
deviceModel?: string
|
|
27
|
+
/** `initConnection.system_version`, if reported. */
|
|
28
|
+
systemVersion?: string
|
|
29
|
+
/** `initConnection.app_version`, if reported. */
|
|
30
|
+
appVersion?: string
|
|
31
|
+
/** `initConnection.lang_code`, if reported. */
|
|
32
|
+
langCode?: string
|
|
33
|
+
/** Client IP (from the carrier / `X-Forwarded-For`), if known. */
|
|
34
|
+
ip?: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** A decoded business method call handed to the handler layer. */
|
|
38
|
+
export interface RpcRequest {
|
|
39
|
+
/** Derived from the client's reqMsgId. */
|
|
40
|
+
id: string
|
|
41
|
+
/** TL method name, e.g. "dust.getConfig". */
|
|
42
|
+
method: string
|
|
43
|
+
/** Decoded params as tagged JSON (bigint -> {$bigint}, Buffer -> {$bin}). */
|
|
44
|
+
params: JsonValue
|
|
45
|
+
/** The connection context (see {@link RpcContext}). */
|
|
46
|
+
context: RpcContext
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A side-effect a handler records (via `ctx.login`/`logout`/`revoke`) for the
|
|
51
|
+
* engine to apply to auth-key state, returned alongside the normal result/error.
|
|
52
|
+
* Keeps the engine agnostic to your auth scheme: a `signIn` handler returns
|
|
53
|
+
* `bindUser`, and the engine binds the `subject` (your internal user id) to the
|
|
54
|
+
* auth key.
|
|
55
|
+
*/
|
|
56
|
+
export type SessionEffect =
|
|
57
|
+
| { type: 'bindUser'; subject: string }
|
|
58
|
+
| { type: 'unbindUser' }
|
|
59
|
+
| { type: 'revokeKey' }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Response envelope (transport-agnostic). Exactly one of `result` / `error` is
|
|
63
|
+
* set; `effects` may accompany either.
|
|
64
|
+
*/
|
|
65
|
+
export interface RpcResponse {
|
|
66
|
+
/** A TL result as tagged JSON ({ _: name, ... }, $bigint/$bin), or a primitive. */
|
|
67
|
+
result?: JsonValue
|
|
68
|
+
error?: { code: number; message: string }
|
|
69
|
+
effects?: SessionEffect[]
|
|
70
|
+
}
|
package/src/tl/crc32.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import CRC32 from 'crc-32'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Computes a TL constructor id (crc32 of the normalized definition line),
|
|
5
|
+
* mirroring the existing backend's `get-tl-object-crc32.js` exactly so our
|
|
6
|
+
* validation agrees with the schema's declared ids.
|
|
7
|
+
*
|
|
8
|
+
* Normalization (applied repeatedly until stable):
|
|
9
|
+
* :bytes -> :string
|
|
10
|
+
* ?bytes -> ?string
|
|
11
|
+
* #<hex> -> (constructor id removed)
|
|
12
|
+
* name:flags.N?true -> (removed; presence-only flags don't affect the id)
|
|
13
|
+
* < > and {} and ; -> stripped / spaced
|
|
14
|
+
* collapse double / edge spaces
|
|
15
|
+
*/
|
|
16
|
+
function normalizeSchemaLine(line: string): string {
|
|
17
|
+
const rules: Array<[RegExp, string]> = [
|
|
18
|
+
[/:bytes /g, ':string '],
|
|
19
|
+
[/\?bytes /g, '?string '],
|
|
20
|
+
[/#[a-f0-9]+ /g, ' '],
|
|
21
|
+
[/ [a-zA-Z0-9_]+:flags\.[0-9]+\?true/g, ''],
|
|
22
|
+
[/</g, ' '],
|
|
23
|
+
[/>/g, ' '],
|
|
24
|
+
[/;/g, ''],
|
|
25
|
+
[/\{/g, ''],
|
|
26
|
+
[/\}/g, ''],
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
let out = line
|
|
30
|
+
let prev: string
|
|
31
|
+
do {
|
|
32
|
+
prev = out
|
|
33
|
+
for (const [re, to] of rules) out = out.replace(re, to)
|
|
34
|
+
out = out.replace(/ {2}/g, ' ').replace(/^ /, '').replace(/ $/, '')
|
|
35
|
+
} while (out !== prev)
|
|
36
|
+
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getTlObjectCrc32(line: string): string {
|
|
41
|
+
const hashNum = CRC32.bstr(normalizeSchemaLine(line))
|
|
42
|
+
const unsigned = hashNum < 0 ? hashNum + 0x100000000 : hashNum
|
|
43
|
+
return unsigned.toString(16).padStart(8, '0')
|
|
44
|
+
}
|
package/src/tl/ir.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intermediate representation (IR) for the TL schema.
|
|
3
|
+
*
|
|
4
|
+
* A `.tl` file is parsed into a flat list of {@link TlDef} (constructors and
|
|
5
|
+
* methods). Each parameter's textual type is parsed once, at load time, into a
|
|
6
|
+
* structured {@link TlType} so the generic codec can walk it without re-parsing
|
|
7
|
+
* strings on every (de)serialization.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type TlType =
|
|
11
|
+
| { kind: 'int' } // 4 bytes LE -> number
|
|
12
|
+
| { kind: 'long' } // 8 bytes LE -> bigint
|
|
13
|
+
| { kind: 'double' } // 8 bytes IEEE754 LE -> number
|
|
14
|
+
| { kind: 'string' } // length-prefixed utf-8 -> string
|
|
15
|
+
| { kind: 'bytes' } // length-prefixed raw -> Buffer
|
|
16
|
+
| { kind: 'int128' } // 16 raw bytes -> Buffer
|
|
17
|
+
| { kind: 'int256' } // 32 raw bytes -> Buffer
|
|
18
|
+
| { kind: 'bool' } // boxed Bool -> boolean
|
|
19
|
+
| { kind: 'true' } // bare `true`, only as a flag presence marker -> boolean
|
|
20
|
+
| { kind: 'flags' } // the `#` bitmask field
|
|
21
|
+
| { kind: 'flag'; flagsField: string; bit: number; inner: TlType } // conditional field
|
|
22
|
+
| { kind: 'vector'; boxed: boolean; inner: TlType }
|
|
23
|
+
| { kind: 'object' } // `Object` / `!X` / `X` — a nested boxed object (read its ctor id)
|
|
24
|
+
| { kind: 'boxed'; name: string } // a named polymorphic type — read/write with ctor id
|
|
25
|
+
| { kind: 'bare'; name: string } // a named bare constructor (`%X` / lowercase) — no ctor id
|
|
26
|
+
|
|
27
|
+
export interface TlParam {
|
|
28
|
+
name: string
|
|
29
|
+
/** Original textual type, kept for diagnostics / crc. */
|
|
30
|
+
raw: string
|
|
31
|
+
type: TlType
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TlDef {
|
|
35
|
+
/** 8-char lowercase hex constructor id. */
|
|
36
|
+
id: string
|
|
37
|
+
/** Unsigned 32-bit numeric form of {@link id}. */
|
|
38
|
+
idNum: number
|
|
39
|
+
/** predicate (constructor) or method name, e.g. `dust.getConfig`. */
|
|
40
|
+
name: string
|
|
41
|
+
kind: 'constructor' | 'method'
|
|
42
|
+
params: TlParam[]
|
|
43
|
+
/** Boxed result/return type, e.g. `dust.CalculatedExchange`. */
|
|
44
|
+
type: string
|
|
45
|
+
/** True for the immutable MTProto protocol layer (scheme_0_protocol + core). */
|
|
46
|
+
isProtocol: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const PRIMITIVES = new Set(['int', 'long', 'double', 'string', 'bytes', 'int128', 'int256', 'Bool', 'true'])
|
|
50
|
+
|
|
51
|
+
export function parseType(raw: string): TlType {
|
|
52
|
+
const t = raw.trim()
|
|
53
|
+
|
|
54
|
+
if (t === '#') return { kind: 'flags' }
|
|
55
|
+
|
|
56
|
+
// conditional field: <flagsField>.<bit>?<inner>, e.g. api_hash:flags.1?string
|
|
57
|
+
const cond = t.match(/^(\w+)\.(\d+)\?(.+)$/)
|
|
58
|
+
if (cond) {
|
|
59
|
+
return {
|
|
60
|
+
kind: 'flag',
|
|
61
|
+
flagsField: cond[1]!,
|
|
62
|
+
bit: Number(cond[2]),
|
|
63
|
+
inner: parseType(cond[3]!),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Vector<T> (boxed) or vector<T> (bare)
|
|
68
|
+
const vec = t.match(/^([Vv])ector<(.+)>$/)
|
|
69
|
+
if (vec) {
|
|
70
|
+
return { kind: 'vector', boxed: vec[1] === 'V', inner: parseType(vec[2]!) }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (PRIMITIVES.has(t)) {
|
|
74
|
+
switch (t) {
|
|
75
|
+
case 'int':
|
|
76
|
+
return { kind: 'int' }
|
|
77
|
+
case 'long':
|
|
78
|
+
return { kind: 'long' }
|
|
79
|
+
case 'double':
|
|
80
|
+
return { kind: 'double' }
|
|
81
|
+
case 'string':
|
|
82
|
+
return { kind: 'string' }
|
|
83
|
+
case 'bytes':
|
|
84
|
+
return { kind: 'bytes' }
|
|
85
|
+
case 'int128':
|
|
86
|
+
return { kind: 'int128' }
|
|
87
|
+
case 'int256':
|
|
88
|
+
return { kind: 'int256' }
|
|
89
|
+
case 'Bool':
|
|
90
|
+
return { kind: 'bool' }
|
|
91
|
+
case 'true':
|
|
92
|
+
return { kind: 'true' }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (t === 'Object' || t === 'X' || t.startsWith('!')) return { kind: 'object' }
|
|
97
|
+
if (t.startsWith('%')) return { kind: 'bare', name: t.slice(1) }
|
|
98
|
+
|
|
99
|
+
// Named type. Boxed iff the final segment is capitalized (e.g. dust.DustBalance,
|
|
100
|
+
// Currency); otherwise a bare constructor reference (e.g. future_salt).
|
|
101
|
+
const lastSeg = t.includes('.') ? t.slice(t.lastIndexOf('.') + 1) : t
|
|
102
|
+
const first = lastSeg[0] ?? ''
|
|
103
|
+
if (first >= 'A' && first <= 'Z') return { kind: 'boxed', name: t }
|
|
104
|
+
return { kind: 'bare', name: t }
|
|
105
|
+
}
|