@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +40 -0
  3. package/dist/cli.d.ts +3 -0
  4. package/dist/cli.d.ts.map +1 -0
  5. package/dist/cli.js +47 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/codegen/gen-types.d.ts +10 -0
  8. package/dist/codegen/gen-types.d.ts.map +1 -0
  9. package/dist/codegen/gen-types.js +190 -0
  10. package/dist/codegen/gen-types.js.map +1 -0
  11. package/dist/index.d.ts +13 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +21 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/logger.d.ts +63 -0
  16. package/dist/logger.d.ts.map +1 -0
  17. package/dist/logger.js +147 -0
  18. package/dist/logger.js.map +1 -0
  19. package/dist/migrate.d.ts +37 -0
  20. package/dist/migrate.d.ts.map +1 -0
  21. package/dist/migrate.js +84 -0
  22. package/dist/migrate.js.map +1 -0
  23. package/dist/rpc.d.ts +74 -0
  24. package/dist/rpc.d.ts.map +1 -0
  25. package/dist/rpc.js +2 -0
  26. package/dist/rpc.js.map +1 -0
  27. package/dist/tl/crc32.d.ts +2 -0
  28. package/dist/tl/crc32.d.ts.map +1 -0
  29. package/dist/tl/crc32.js +42 -0
  30. package/dist/tl/crc32.js.map +1 -0
  31. package/dist/tl/ir.d.ts +68 -0
  32. package/dist/tl/ir.d.ts.map +1 -0
  33. package/dist/tl/ir.js +63 -0
  34. package/dist/tl/ir.js.map +1 -0
  35. package/dist/tl/parser.d.ts +22 -0
  36. package/dist/tl/parser.d.ts.map +1 -0
  37. package/dist/tl/parser.js +122 -0
  38. package/dist/tl/parser.js.map +1 -0
  39. package/dist/tl/value.d.ts +26 -0
  40. package/dist/tl/value.d.ts.map +1 -0
  41. package/dist/tl/value.js +44 -0
  42. package/dist/tl/value.js.map +1 -0
  43. package/dist/tools/freeze-layer.d.ts +26 -0
  44. package/dist/tools/freeze-layer.d.ts.map +1 -0
  45. package/dist/tools/freeze-layer.js +86 -0
  46. package/dist/tools/freeze-layer.js.map +1 -0
  47. package/dist/wire.d.ts +12 -0
  48. package/dist/wire.d.ts.map +1 -0
  49. package/dist/wire.js +50 -0
  50. package/dist/wire.js.map +1 -0
  51. package/package.json +76 -0
  52. package/schema/scheme_0_protocol.tl +150 -0
  53. package/src/cli.ts +48 -0
  54. package/src/codegen/gen-types.ts +203 -0
  55. package/src/index.ts +22 -0
  56. package/src/logger.ts +210 -0
  57. package/src/migrate.ts +101 -0
  58. package/src/rpc.ts +70 -0
  59. package/src/tl/crc32.ts +44 -0
  60. package/src/tl/ir.ts +105 -0
  61. package/src/tl/parser.ts +144 -0
  62. package/src/tl/value.ts +60 -0
  63. package/src/tools/freeze-layer.ts +108 -0
  64. package/src/wire.ts +54 -0
@@ -0,0 +1,144 @@
1
+ import { readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import type { TlDef, TlParam } from './ir.js'
4
+ import { parseType } from './ir.js'
5
+ import { getTlObjectCrc32 } from './crc32.js'
6
+
7
+ const LINE_RE = /^([\w.]+)#([0-9a-fA-F]+)\s*(.*?)\s*=\s*([\w.]+)\s*;\s*$/
8
+
9
+ /**
10
+ * Core MTProto constructors that scheme_0_protocol.tl leaves commented out
11
+ * ("parsed manually"). They still need registry entries for id<->name lookup.
12
+ */
13
+ const CORE_LINES: Array<{ kind: 'constructor' | 'method'; line: string }> = [
14
+ { kind: 'constructor', line: 'vector#1cb5c415 = Vector;' },
15
+ { kind: 'constructor', line: 'rpc_result#f35c6d01 req_msg_id:long result:Object = RpcResult;' },
16
+ { kind: 'constructor', line: 'msg_container#73f1f8dc messages:vector<%Message> = MessageContainer;' },
17
+ {
18
+ kind: 'constructor',
19
+ line: 'message#5bb8e511 msg_id:long seqno:int bytes:int body:Object = Message;',
20
+ },
21
+ { kind: 'constructor', line: 'msg_copy#e06046b2 orig_message:Message = MessageCopy;' },
22
+ { kind: 'constructor', line: 'gzip_packed#3072cfa1 packed_data:bytes = Object;' },
23
+ ]
24
+
25
+ function parseParams(paramsStr: string): TlParam[] {
26
+ if (!paramsStr) return []
27
+ return paramsStr
28
+ .split(/\s+/)
29
+ .filter(tok => tok.includes(':') && !tok.includes('{') && !tok.includes('}'))
30
+ .map(tok => {
31
+ const idx = tok.indexOf(':')
32
+ const name = tok.slice(0, idx)
33
+ const raw = tok.slice(idx + 1)
34
+ return { name, raw, type: parseType(raw) }
35
+ })
36
+ }
37
+
38
+ export interface ParsedLine {
39
+ def: TlDef
40
+ /** declared id differs from the crc32 of the normalized line */
41
+ crcMismatch: boolean
42
+ }
43
+
44
+ function parseLine(
45
+ line: string,
46
+ kind: 'constructor' | 'method',
47
+ isProtocol: boolean,
48
+ validateCrc: boolean,
49
+ ): ParsedLine | null {
50
+ const m = line.match(LINE_RE)
51
+ if (!m) return null
52
+ const [, name, hashRaw, paramsStr, type] = m
53
+ const id = hashRaw!.toLowerCase().padStart(8, '0')
54
+ const def: TlDef = {
55
+ id,
56
+ idNum: parseInt(id, 16) >>> 0,
57
+ name: name!,
58
+ kind,
59
+ params: parseParams(paramsStr ?? ''),
60
+ type: type!,
61
+ isProtocol,
62
+ }
63
+ const crcMismatch = validateCrc ? getTlObjectCrc32(line.trim()) !== id : false
64
+ return { def, crcMismatch }
65
+ }
66
+
67
+ export interface ParseResult {
68
+ defs: TlDef[]
69
+ crcMismatches: Array<{ name: string; id: string; computed: string }>
70
+ }
71
+
72
+ export function parseTlText(text: string, isProtocol: boolean): ParseResult {
73
+ const defs: TlDef[] = []
74
+ const crcMismatches: ParseResult['crcMismatches'] = []
75
+ let kind: 'constructor' | 'method' = 'constructor'
76
+
77
+ for (const rawLine of text.split('\n')) {
78
+ const line = rawLine.trim()
79
+ if (!line || line.startsWith('//') || line.startsWith('*') || line.startsWith('/*')) continue
80
+ if (line.includes('---types---')) {
81
+ kind = 'constructor'
82
+ continue
83
+ }
84
+ if (line.includes('---functions---')) {
85
+ kind = 'method'
86
+ continue
87
+ }
88
+ const parsed = parseLine(line, kind, isProtocol, true)
89
+ if (!parsed) continue
90
+ defs.push(parsed.def)
91
+ if (parsed.crcMismatch) {
92
+ crcMismatches.push({
93
+ name: parsed.def.name,
94
+ id: parsed.def.id,
95
+ computed: getTlObjectCrc32(line),
96
+ })
97
+ }
98
+ }
99
+
100
+ return { defs, crcMismatches }
101
+ }
102
+
103
+ function parseCoreLines(): TlDef[] {
104
+ return CORE_LINES.map(({ kind, line }) => {
105
+ const parsed = parseLine(line, kind, true, false)
106
+ if (!parsed) throw new Error(`Failed to parse core line: ${line}`)
107
+ return parsed.def
108
+ })
109
+ }
110
+
111
+ /**
112
+ * Loads every `*.tl` file from a directory into the IR. `scheme_0_protocol.tl`
113
+ * is flagged as protocol; the manually-parsed MTProto core constructors are
114
+ * merged in. Duplicate constructor ids are de-duplicated (first wins).
115
+ */
116
+ export function parseSchemaDir(dir: string): ParseResult {
117
+ const files = readdirSync(dir)
118
+ .filter(f => f.endsWith('.tl'))
119
+ .sort()
120
+
121
+ const seen = new Set<string>()
122
+ const defs: TlDef[] = []
123
+ const crcMismatches: ParseResult['crcMismatches'] = []
124
+
125
+ for (const def of parseCoreLines()) {
126
+ if (seen.has(def.id)) continue
127
+ seen.add(def.id)
128
+ defs.push(def)
129
+ }
130
+
131
+ for (const file of files) {
132
+ const isProtocol = file === 'scheme_0_protocol.tl'
133
+ const text = readFileSync(join(dir, file), 'utf-8')
134
+ const res = parseTlText(text, isProtocol)
135
+ crcMismatches.push(...res.crcMismatches)
136
+ for (const def of res.defs) {
137
+ if (seen.has(def.id)) continue
138
+ seen.add(def.id)
139
+ defs.push(def)
140
+ }
141
+ }
142
+
143
+ return { defs, crcMismatches }
144
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * In-code representation of a decoded TL value.
3
+ *
4
+ * A constructed type is a tagged plain object `{ _: 'predicate.name', ...fields }`
5
+ * (matching the existing `toPrimitiveObject` convention). Wire primitives map to:
6
+ * int -> number, long/flags -> bigint|number, double -> number, string -> string,
7
+ * bytes/int128/int256 -> Buffer, Bool/true -> boolean, vector -> array.
8
+ */
9
+ export type TlValue = number | bigint | boolean | string | Buffer | TlObject | TlValue[] | null | undefined
10
+
11
+ export interface TlObject {
12
+ _: string
13
+ [field: string]: TlValue
14
+ }
15
+
16
+ /** JSON-safe encoding so values can cross the JSON-RPC boundary. */
17
+ export type JsonValue =
18
+ | number
19
+ | boolean
20
+ | string
21
+ | null
22
+ | { $bigint: string }
23
+ | { $bin: string }
24
+ | JsonValue[]
25
+ | { [k: string]: JsonValue }
26
+
27
+ function isBuffer(v: unknown): v is Buffer {
28
+ return Buffer.isBuffer(v)
29
+ }
30
+
31
+ export function toJson(value: TlValue): JsonValue {
32
+ if (value === null || value === undefined) return null
33
+ if (typeof value === 'bigint') return { $bigint: value.toString() }
34
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value
35
+ if (isBuffer(value)) return { $bin: value.toString('base64') }
36
+ if (Array.isArray(value)) return value.map(toJson)
37
+ // TlObject
38
+ const out: { [k: string]: JsonValue } = {}
39
+ for (const [k, v] of Object.entries(value)) out[k] = toJson(v as TlValue)
40
+ return out
41
+ }
42
+
43
+ export function fromJson(value: JsonValue): TlValue {
44
+ if (value === null) return null
45
+ if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') return value
46
+ if (Array.isArray(value)) return value.map(fromJson)
47
+ if (typeof value === 'object') {
48
+ if ('$bigint' in value && typeof value.$bigint === 'string') return BigInt(value.$bigint)
49
+ if ('$bin' in value && typeof value.$bin === 'string') return Buffer.from(value.$bin, 'base64')
50
+ const out: { [k: string]: TlValue } = {}
51
+ for (const [k, v] of Object.entries(value)) out[k] = fromJson(v as JsonValue)
52
+ return out as TlObject
53
+ }
54
+ return null
55
+ }
56
+
57
+ /** Pretty JSON string for terminal logging (bigint/Buffer made readable). */
58
+ export function stringify(value: TlValue, indent = 2): string {
59
+ return JSON.stringify(toJson(value), null, indent)
60
+ }
@@ -0,0 +1,108 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { parseSchemaDir } from '../tl/parser.js'
5
+
6
+ interface SnapshotEntry {
7
+ id: string
8
+ predicate?: string
9
+ method?: string
10
+ params: Array<{ name: string; type: string }>
11
+ type: string
12
+ }
13
+
14
+ export interface FreezeResult {
15
+ constructors: number
16
+ methods: number
17
+ /** Path of the JSON snapshot the gateway loads. */
18
+ out: string
19
+ /** Path of the human-readable `.tl` mirror (not loaded; for inspection/diffs). */
20
+ tlOut: string
21
+ crcWarnings: number
22
+ }
23
+
24
+ /** A single `name#id p:type … = ResultType;` line. */
25
+ function tlLine(e: SnapshotEntry): string {
26
+ const name = e.predicate ?? e.method ?? '?'
27
+ const params = e.params.map(p => `${p.name}:${p.type}`).join(' ')
28
+ return `${name}#${e.id}${params ? ' ' + params : ''} = ${e.type};`
29
+ }
30
+
31
+ /** Reconstruct a readable `.tl` from the frozen IR (constructors, then methods). */
32
+ function toTlText(constructors: SnapshotEntry[], methods: SnapshotEntry[], layer: number): string {
33
+ const lines = [`// Frozen snapshot of layer ${layer}. Generated by \`freeze\` — do not edit.`, '']
34
+ lines.push(...constructors.map(tlLine))
35
+ if (methods.length) lines.push('', '---functions---', '', ...methods.map(tlLine))
36
+ return lines.join('\n') + '\n'
37
+ }
38
+
39
+ /**
40
+ * Freezes the `.tl` schema in `schemaDir` into a per-layer snapshot
41
+ * `<outDir>/scheme_<layer>.json` (loaded by the gateway) plus a human-readable
42
+ * `<outDir>/scheme_<layer>.tl` mirror (for inspection/diffs; not loaded). Run
43
+ * this when you SHIP a layer: the `.tl` you edit is the in-progress newest layer;
44
+ * the frozen snapshot is the historical record the gateway uses to encode for
45
+ * clients on that layer.
46
+ *
47
+ * The snapshot is BUSINESS-only: the immutable MTProto protocol/core types are
48
+ * excluded (they live in @mt-tl/tl and are loaded globally), so a business
49
+ * layer never carries protocol definitions.
50
+ *
51
+ * Schema ownership lives in the consumer app, so paths are explicit — apps wrap
52
+ * this in their own `freeze` script.
53
+ */
54
+ export function freezeLayer(schemaDir: string, outDir: string, layer: number): FreezeResult {
55
+ const { defs, crcMismatches } = parseSchemaDir(schemaDir)
56
+
57
+ const constructors: SnapshotEntry[] = []
58
+ const methods: SnapshotEntry[] = []
59
+ const seen = new Set<string>()
60
+ for (const d of defs) {
61
+ // Per-layer snapshots are BUSINESS-only. The MTProto protocol layer
62
+ // (scheme_0_protocol + the manually-parsed core ctors `parseSchemaDir`
63
+ // injects) is immutable and layer-independent — it lives in @mt-tl/tl
64
+ // and the gateway always loads it globally, so it must NOT be baked into
65
+ // each business snapshot (mirrors Telegram's split: mtproto_api vs api).
66
+ if (d.isProtocol) continue
67
+ if (seen.has(d.id)) continue
68
+ seen.add(d.id)
69
+ const params = d.params.map(p => ({ name: p.name, type: p.raw }))
70
+ if (d.kind === 'method') methods.push({ id: d.id, method: d.name, params, type: d.type })
71
+ else constructors.push({ id: d.id, predicate: d.name, params, type: d.type })
72
+ }
73
+
74
+ const byId = (a: SnapshotEntry, b: SnapshotEntry) => parseInt(a.id, 16) - parseInt(b.id, 16)
75
+ constructors.sort(byId)
76
+ methods.sort(byId)
77
+
78
+ mkdirSync(outDir, { recursive: true })
79
+ const out = join(outDir, `scheme_${layer}.json`)
80
+ const tlOut = join(outDir, `scheme_${layer}.tl`)
81
+ writeFileSync(out, JSON.stringify({ constructors, methods }, null, 2))
82
+ writeFileSync(tlOut, toTlText(constructors, methods, layer))
83
+
84
+ return {
85
+ constructors: constructors.length,
86
+ methods: methods.length,
87
+ out,
88
+ tlOut,
89
+ crcWarnings: crcMismatches.length,
90
+ }
91
+ }
92
+
93
+ // CLI: `tsx freeze-layer.ts <layer> <schemaDir> <outDir>`
94
+ // or env: `SCHEMA_DIR=… SCHEMA_LAYERS_DIR=… tsx freeze-layer.ts <layer>`
95
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
96
+ const layer = Number(process.argv[2])
97
+ const schemaDir = process.argv[3] || process.env.SCHEMA_DIR
98
+ const outDir = process.argv[4] || process.env.SCHEMA_LAYERS_DIR
99
+ if (!Number.isInteger(layer) || layer <= 0 || !schemaDir || !outDir) {
100
+ console.error('usage: freeze-layer <layer> <schemaDir> <outDir>')
101
+ process.exit(1)
102
+ }
103
+ const r = freezeLayer(schemaDir, outDir, layer)
104
+ console.log(
105
+ `Froze layer ${layer}: ${r.constructors} constructors, ${r.methods} methods -> ${r.out} (+ ${r.tlOut})` +
106
+ (r.crcWarnings ? ` (${r.crcWarnings} crc warnings)` : ''),
107
+ )
108
+ }
package/src/wire.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { JsonValue } from './tl/value.js'
2
+ import type { RpcContext, RpcRequest, RpcResponse, SessionEffect } from './rpc.js'
3
+
4
+ /**
5
+ * Wire format for the gateway↔worker RPC, shared so both sides agree:
6
+ * request { jsonrpc, id, method, params, context }
7
+ * response { result? , error?: { code, message }, effects? }
8
+ * Gateway encodes requests / decodes responses; workers do the inverse.
9
+ */
10
+ export function encodeRequest(req: RpcRequest): unknown {
11
+ return {
12
+ jsonrpc: '2.0',
13
+ id: req.id,
14
+ method: req.method,
15
+ params: req.params,
16
+ context: req.context,
17
+ }
18
+ }
19
+
20
+ export function decodeRequest(json: unknown): RpcRequest | null {
21
+ if (!json || typeof json !== 'object') return null
22
+ const o = json as Record<string, unknown>
23
+ if (typeof o.method !== 'string' || !o.context || typeof o.context !== 'object') return null
24
+ return {
25
+ id: o.id === undefined ? '' : String(o.id),
26
+ method: o.method,
27
+ params: (o.params ?? {}) as JsonValue,
28
+ context: o.context as RpcContext,
29
+ }
30
+ }
31
+
32
+ export function encodeResponse(res: RpcResponse): unknown {
33
+ return res
34
+ }
35
+
36
+ export function decodeResponse(json: unknown): RpcResponse {
37
+ if (!json || typeof json !== 'object') {
38
+ return { error: { code: 502, message: 'BAD_RESPONSE' } }
39
+ }
40
+ const o = json as Record<string, unknown>
41
+ const effects = Array.isArray(o.effects) ? (o.effects as SessionEffect[]) : undefined
42
+
43
+ if (o.error && typeof o.error === 'object') {
44
+ const e = o.error as Record<string, unknown>
45
+ return {
46
+ error: {
47
+ code: typeof e.code === 'number' ? e.code : 500,
48
+ message: typeof e.message === 'string' ? e.message : 'ERROR',
49
+ },
50
+ effects,
51
+ }
52
+ }
53
+ return { result: o.result as JsonValue, effects }
54
+ }