ts-bin-rpc 0.1.0-alpha.0 → 0.1.0-alpha.1
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 +9 -7
- package/dist/cli/build.js +245 -0
- package/dist/cli/cli.js +290 -0
- package/dist/cli/emit.js +943 -0
- package/dist/cli/ir.js +26 -0
- package/dist/cli/lock.js +268 -0
- package/dist/cli/print.js +31 -0
- package/dist/cli/source.js +271 -0
- package/package.json +16 -4
package/README.md
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
# ts-bin-rpc
|
|
2
2
|
|
|
3
3
|
A code-first TypeScript RPC with a binary wire format. Your router's TypeScript types are the
|
|
4
|
-
schema:
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
This package is the runtime: `ts-bin-rpc/server`, `ts-bin-rpc/client`, `ts-bin-rpc/node`, the
|
|
9
|
-
numeric brands and the codec runtime the generated code imports.
|
|
4
|
+
schema: the `tbr` command reads them, keeps a lockfile of field IDs and generates codecs. The
|
|
5
|
+
client gets lazy views over the response bytes, and numeric arrays arrive as `TypedArray` columns
|
|
6
|
+
with no parsing.
|
|
10
7
|
|
|
11
8
|
```sh
|
|
12
9
|
npm install ts-bin-rpc@alpha
|
|
13
|
-
npm install -D
|
|
10
|
+
npm install -D typescript@7.1.0-beta # for tbr
|
|
11
|
+
npx tbr generate # --watch to regenerate on save; tbr check in CI
|
|
14
12
|
```
|
|
15
13
|
|
|
14
|
+
The package provides `ts-bin-rpc/server`, `ts-bin-rpc/client`, `ts-bin-rpc/node`, the numeric
|
|
15
|
+
brands, the codec runtime the generated code imports, and `tbr`. TypeScript is an optional peer
|
|
16
|
+
dependency that only `tbr` needs.
|
|
17
|
+
|
|
16
18
|
**Alpha.** The wire format may change before 1.0. Docs: https://tom4ikss.github.io/ts-bin-RPC/.
|
|
17
19
|
Source and issues: https://github.com/Tom4ikss/ts-bin-RPC. MIT license.
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { TYPED_ARRAYS } from './ir.js';
|
|
2
|
+
const NUMBER_BRANDS = { u8: 'u8', i8: 'i8', u16: 'u16', i16: 'i16', u32: 'u32', i32: 'i32', f32: 'f32', f64: 'f64' };
|
|
3
|
+
const BIGINT_BRANDS = { u64: 'u64', i64: 'i64' };
|
|
4
|
+
const UNSUPPORTED = {
|
|
5
|
+
any: "type 'any' is not serializable",
|
|
6
|
+
unknown: "type 'unknown' is not serializable",
|
|
7
|
+
never: "type 'never' is not serializable",
|
|
8
|
+
symbol: 'symbols are not serializable',
|
|
9
|
+
nonPrimitive: "type 'object' is not serializable; declare the fields",
|
|
10
|
+
void: "'void' is only allowed as a procedure result",
|
|
11
|
+
undefined: "'undefined' alone is not serializable",
|
|
12
|
+
null: "'null' alone is not serializable",
|
|
13
|
+
function: 'functions and methods are not serializable',
|
|
14
|
+
tuple: 'tuples are not supported yet; use an object or an array',
|
|
15
|
+
};
|
|
16
|
+
class Builder {
|
|
17
|
+
schema = { procedures: [], objects: {}, enums: {}, unions: {} };
|
|
18
|
+
errors = [];
|
|
19
|
+
memo = new Map();
|
|
20
|
+
owners = new Map();
|
|
21
|
+
src;
|
|
22
|
+
constructor(src) { this.src = src; }
|
|
23
|
+
fail(ctx, message, location = ctx.location) {
|
|
24
|
+
this.errors.push({ path: ctx.path, message, location });
|
|
25
|
+
}
|
|
26
|
+
procedure(entry) {
|
|
27
|
+
const ctx = { path: entry.path, anon: entry.path, location: entry.location };
|
|
28
|
+
if ('problem' in entry)
|
|
29
|
+
return this.fail(ctx, entry.problem);
|
|
30
|
+
if (entry.overloads > 1)
|
|
31
|
+
return this.fail(ctx, 'overloaded procedures are not supported');
|
|
32
|
+
if (entry.params.length > 2)
|
|
33
|
+
return this.fail(ctx, 'a procedure takes at most (input, ctx)');
|
|
34
|
+
let input = null;
|
|
35
|
+
let inputOptional = false;
|
|
36
|
+
const p0 = entry.params[0];
|
|
37
|
+
const k0 = p0 && this.src.describe(p0.type).k;
|
|
38
|
+
// `(_: void, ctx)`: a procedure that needs ctx but takes no input.
|
|
39
|
+
if (p0 && k0 !== 'void' && k0 !== 'undefined') {
|
|
40
|
+
const r = this.slot(p0.type, { path: `${entry.path} → input`, anon: `${entry.path}:input`, location: entry.location });
|
|
41
|
+
if (r?.nullable)
|
|
42
|
+
this.fail(ctx, 'a procedure input cannot be null');
|
|
43
|
+
input = r?.type ?? null;
|
|
44
|
+
inputOptional = p0.optional || !!r?.optional;
|
|
45
|
+
}
|
|
46
|
+
let output = null;
|
|
47
|
+
const ret = this.src.describe(entry.returns);
|
|
48
|
+
if (ret.k !== 'void' && ret.k !== 'undefined') {
|
|
49
|
+
const r = this.slot(entry.returns, { path: `${entry.path} → output`, anon: `${entry.path}:output`, location: entry.location });
|
|
50
|
+
if (r && (r.optional || r.nullable))
|
|
51
|
+
this.fail(ctx, 'a procedure result cannot be null or undefined; wrap it in an object with an optional field');
|
|
52
|
+
output = r?.type ?? null;
|
|
53
|
+
}
|
|
54
|
+
const proc = { path: entry.path, input, inputOptional, output, location: entry.location };
|
|
55
|
+
this.schema.procedures.push(proc);
|
|
56
|
+
}
|
|
57
|
+
// A position that may carry `| undefined` and `| null`: fields and procedure input/output.
|
|
58
|
+
slot(ref, ctx) {
|
|
59
|
+
const d = this.src.describe(ref);
|
|
60
|
+
if (d.k !== 'union') {
|
|
61
|
+
const type = this.type(ref, ctx);
|
|
62
|
+
return type && { type, optional: false, nullable: false };
|
|
63
|
+
}
|
|
64
|
+
let optional = false, nullable = false;
|
|
65
|
+
const rest = [];
|
|
66
|
+
const visit = (members) => {
|
|
67
|
+
for (const m of members) {
|
|
68
|
+
const md = this.src.describe(m);
|
|
69
|
+
if (md.k === 'undefined' || md.k === 'void')
|
|
70
|
+
optional = true;
|
|
71
|
+
else if (md.k === 'null')
|
|
72
|
+
nullable = true;
|
|
73
|
+
else if (md.k === 'union' && md.members.some((x) => isNullish(this.src.describe(x))))
|
|
74
|
+
visit(md.members); // `type MaybeRole = Role | null`
|
|
75
|
+
else
|
|
76
|
+
rest.push(m);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
visit(d.members);
|
|
80
|
+
if (!rest.length) {
|
|
81
|
+
this.fail(ctx, `'${this.src.text(ref)}' has no serializable part`);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
const type = rest.length === 1 ? this.type(rest[0], ctx) : this.multi(ref, rest, d.alias, ctx);
|
|
85
|
+
return type && { type, optional, nullable };
|
|
86
|
+
}
|
|
87
|
+
type(ref, ctx) {
|
|
88
|
+
const d = this.src.describe(ref);
|
|
89
|
+
switch (d.k) {
|
|
90
|
+
case 'string': return { kind: 'string' };
|
|
91
|
+
case 'number': return { kind: 'scalar', scalar: 'f64' };
|
|
92
|
+
case 'boolean': return { kind: 'scalar', scalar: 'bool' };
|
|
93
|
+
case 'bigint': return { kind: 'scalar', scalar: 'i64' };
|
|
94
|
+
case 'literal': return { kind: 'const', value: d.value };
|
|
95
|
+
case 'brand': {
|
|
96
|
+
const scalar = (d.base === 'number' ? NUMBER_BRANDS : BIGINT_BRANDS)[d.tag];
|
|
97
|
+
if (!scalar) {
|
|
98
|
+
this.fail(ctx, `unknown numeric brand '${d.tag}' on ${d.base}`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return { kind: 'scalar', scalar };
|
|
102
|
+
}
|
|
103
|
+
case 'union': {
|
|
104
|
+
if (d.members.some((m) => isNullish(this.src.describe(m)))) {
|
|
105
|
+
this.fail(ctx, `'${this.src.text(ref)}': null/undefined are only allowed on fields and procedure inputs, not inside arrays`);
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
return this.multi(ref, d.members, d.alias, ctx);
|
|
109
|
+
}
|
|
110
|
+
case 'array': {
|
|
111
|
+
const element = this.type(d.element, { ...ctx, path: `${ctx.path}[]`, anon: `${ctx.anon}[]` });
|
|
112
|
+
return element && { kind: 'array', element };
|
|
113
|
+
}
|
|
114
|
+
case 'builtin': {
|
|
115
|
+
if (d.name === 'Date')
|
|
116
|
+
return { kind: 'scalar', scalar: 'timestamp' };
|
|
117
|
+
if (d.name === 'Uint8Array')
|
|
118
|
+
return { kind: 'bytes' };
|
|
119
|
+
if (TYPED_ARRAYS.includes(d.name))
|
|
120
|
+
return { kind: 'typedArray', array: d.name };
|
|
121
|
+
const hint = d.name.includes('Map') || d.name.includes('Set') ? ' (planned after alpha)' : '';
|
|
122
|
+
this.fail(ctx, `'${d.name}' is not supported${hint}`);
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
case 'object': return this.object(ref, d, ctx);
|
|
126
|
+
case 'other':
|
|
127
|
+
this.fail(ctx, `type '${d.text}' is not serializable`);
|
|
128
|
+
return undefined;
|
|
129
|
+
default:
|
|
130
|
+
this.fail(ctx, UNSUPPORTED[d.k] ?? `type '${this.src.text(ref)}' is not serializable`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Unions without null/undefined: booleans, string literal enums, discriminated unions of objects.
|
|
135
|
+
multi(whole, members, alias, ctx) {
|
|
136
|
+
const known = this.memo.get(whole.id);
|
|
137
|
+
if (known)
|
|
138
|
+
return known;
|
|
139
|
+
const ds = members.map((m) => this.src.describe(m));
|
|
140
|
+
if (ds.every((d) => d.k === 'literal' && typeof d.value === 'boolean'))
|
|
141
|
+
return { kind: 'scalar', scalar: 'bool' };
|
|
142
|
+
if (ds.every((d) => d.k === 'literal' && typeof d.value === 'string')) {
|
|
143
|
+
const name = this.claim(alias ?? ctx.anon, whole.id, ctx);
|
|
144
|
+
const values = ds.map((d) => d.value).sort();
|
|
145
|
+
this.schema.enums[name] = { name, values };
|
|
146
|
+
const t = { kind: 'enum', name };
|
|
147
|
+
this.memo.set(whole.id, t);
|
|
148
|
+
return t;
|
|
149
|
+
}
|
|
150
|
+
if (ds.every((d) => d.k === 'object')) {
|
|
151
|
+
const objs = ds;
|
|
152
|
+
const disc = findDiscriminant(this.src, objs);
|
|
153
|
+
if (!disc) {
|
|
154
|
+
this.fail(ctx, `union of objects needs a discriminant: a field present in every variant with distinct string literal values ('${this.src.text(whole)}')`);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
const name = this.claim(alias ?? ctx.anon, whole.id, ctx);
|
|
158
|
+
const t = { kind: 'union', name };
|
|
159
|
+
this.memo.set(whole.id, t);
|
|
160
|
+
const variants = members.map((m, i) => {
|
|
161
|
+
const tag = disc.tags[i];
|
|
162
|
+
const obj = this.type(m, { path: `${ctx.path} (${tag})`, anon: `${name}#${tag}`, location: ctx.location });
|
|
163
|
+
return { tag, object: obj?.kind === 'object' ? obj.name : '' };
|
|
164
|
+
}).sort((a, b) => (a.tag < b.tag ? -1 : 1));
|
|
165
|
+
this.schema.unions[name] = { name, discriminant: disc.field, variants };
|
|
166
|
+
return t;
|
|
167
|
+
}
|
|
168
|
+
if (ds.every((d) => d.k === 'literal' && typeof d.value === 'number')) {
|
|
169
|
+
this.fail(ctx, `numeric literal unions are not supported yet ('${this.src.text(whole)}'); use string literals or a numeric brand`);
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
this.fail(ctx, `union '${this.src.text(whole)}' is not serializable; supported: string literal unions, discriminated unions of objects, T | null, T | undefined`);
|
|
173
|
+
}
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
object(ref, d, ctx) {
|
|
177
|
+
const known = this.memo.get(ref.id);
|
|
178
|
+
if (known)
|
|
179
|
+
return known;
|
|
180
|
+
if (d.indexSignature) {
|
|
181
|
+
this.fail(ctx, `index signatures and Record<string, T> are not supported yet ('${this.src.text(ref)}')`);
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
const name = this.claim(d.name ?? ctx.anon, ref.id, ctx, d.location);
|
|
185
|
+
const t = { kind: 'object', name };
|
|
186
|
+
this.memo.set(ref.id, t); // before the fields: recursive types refer back by name
|
|
187
|
+
const def = { name, fields: [], location: d.location };
|
|
188
|
+
this.schema.objects[name] = def;
|
|
189
|
+
for (const p of d.props) {
|
|
190
|
+
const f = this.field(p, name);
|
|
191
|
+
if (f)
|
|
192
|
+
def.fields.push(f);
|
|
193
|
+
}
|
|
194
|
+
return t;
|
|
195
|
+
}
|
|
196
|
+
field(p, owner) {
|
|
197
|
+
const ctx = { path: `${owner}.${p.name}`, anon: `${owner}.${p.name}`, location: p.location };
|
|
198
|
+
if (p.name.startsWith('__@')) {
|
|
199
|
+
this.fail(ctx, 'symbol-keyed properties are not serializable');
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
const r = this.slot(p.type, ctx);
|
|
203
|
+
if (!r)
|
|
204
|
+
return undefined;
|
|
205
|
+
return { name: p.name, type: r.type, optional: r.optional || p.optional, nullable: r.nullable, renamedFrom: p.renamedFrom, location: p.location };
|
|
206
|
+
}
|
|
207
|
+
// Names are the identity of types in the lockfile: two different types may not share one.
|
|
208
|
+
claim(name, id, ctx, location = ctx.location) {
|
|
209
|
+
const prev = this.owners.get(name);
|
|
210
|
+
if (prev && prev.id !== id) {
|
|
211
|
+
const where = prev.location ? ` (other one at ${prev.location.file}:${prev.location.line})` : '';
|
|
212
|
+
this.fail(ctx, `two different types are named '${name}'${where}; rename one of them`, location);
|
|
213
|
+
}
|
|
214
|
+
else if (!prev) {
|
|
215
|
+
this.owners.set(name, { id, location });
|
|
216
|
+
}
|
|
217
|
+
return name;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function isNullish(d) {
|
|
221
|
+
return d.k === 'undefined' || d.k === 'null' || d.k === 'void';
|
|
222
|
+
}
|
|
223
|
+
function findDiscriminant(src, objs) {
|
|
224
|
+
const candidates = objs[0].props.map((p) => p.name);
|
|
225
|
+
for (const field of candidates) {
|
|
226
|
+
const tags = [];
|
|
227
|
+
for (const o of objs) {
|
|
228
|
+
const p = o.props.find((x) => x.name === field);
|
|
229
|
+
const d = p && !p.optional ? src.describe(p.type) : undefined;
|
|
230
|
+
if (d?.k !== 'literal' || typeof d.value !== 'string')
|
|
231
|
+
break;
|
|
232
|
+
tags.push(d.value);
|
|
233
|
+
}
|
|
234
|
+
if (tags.length === objs.length && new Set(tags).size === tags.length)
|
|
235
|
+
return { field, tags };
|
|
236
|
+
}
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
export function buildSchema(src, routerFile, exportName) {
|
|
240
|
+
const b = new Builder(src);
|
|
241
|
+
for (const entry of src.router(routerFile, exportName))
|
|
242
|
+
b.procedure(entry);
|
|
243
|
+
b.schema.procedures.sort((x, y) => (x.path < y.path ? -1 : 1));
|
|
244
|
+
return { schema: b.schema, errors: b.errors };
|
|
245
|
+
}
|
package/dist/cli/cli.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env -S node --experimental-strip-types --no-warnings
|
|
2
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx, extraExtensions, ignoreCase) {
|
|
3
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
4
|
+
if (extraExtensions) {
|
|
5
|
+
for (var extension in extraExtensions) {
|
|
6
|
+
var suffix = path.slice(-extension.length);
|
|
7
|
+
if (suffix === extension || ignoreCase && suffix.toLowerCase() === extension.toLowerCase()) {
|
|
8
|
+
var output = extraExtensions[extension];
|
|
9
|
+
return output === extension ? path : path.slice(0, -extension.length) + output;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
14
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return path;
|
|
18
|
+
};
|
|
19
|
+
// tbr generate | check | decode: schema IR, lockfile, generated codecs and a message dump.
|
|
20
|
+
import fs from 'node:fs';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { pathToFileURL } from 'node:url';
|
|
23
|
+
import { parseArgs } from 'node:util';
|
|
24
|
+
import { decodeError, FLAG_ERROR, VerifyError } from 'ts-bin-rpc/runtime';
|
|
25
|
+
import { buildSchema } from './build.js';
|
|
26
|
+
import { emitCodecs } from './emit.js';
|
|
27
|
+
import { emptyLock, parseLock, stringifyLock, updateLock } from './lock.js';
|
|
28
|
+
import { formatLocation } from './ir.js';
|
|
29
|
+
import { printErrors, printSchema } from './print.js';
|
|
30
|
+
import { Ts71Source } from './source.js';
|
|
31
|
+
const USAGE = `usage: tbr generate | check [options]
|
|
32
|
+
tbr decode <file|-> (--proc <path> [--input] | --type <Name>) [options]
|
|
33
|
+
|
|
34
|
+
--project <tsconfig> TypeScript project (default: tsconfig.json)
|
|
35
|
+
--router <file#export> router to compile (default: src/router.ts#appRouter)
|
|
36
|
+
--lock <file> lockfile (default: tbr.lock.json next to the tsconfig)
|
|
37
|
+
--out <file> generated codecs (default: tbr.gen.ts next to the router)
|
|
38
|
+
--dry-run generate: print the schema and the lockfile instead of writing
|
|
39
|
+
--allow-breaking generate: accept breaking changes
|
|
40
|
+
--watch, -w generate: regenerate on every change to the project's files
|
|
41
|
+
--proc <path> decode: the message is the output of this procedure (with --input: its input)
|
|
42
|
+
--type <Name> decode: the message is this object type
|
|
43
|
+
`;
|
|
44
|
+
export function run(argv, out = (s) => process.stdout.write(s), options = {}) {
|
|
45
|
+
const { values, positionals } = parseArgs({
|
|
46
|
+
args: argv,
|
|
47
|
+
allowPositionals: true,
|
|
48
|
+
options: {
|
|
49
|
+
project: { type: 'string', default: 'tsconfig.json' },
|
|
50
|
+
router: { type: 'string', default: 'src/router.ts#appRouter' },
|
|
51
|
+
lock: { type: 'string' },
|
|
52
|
+
out: { type: 'string' },
|
|
53
|
+
'dry-run': { type: 'boolean', default: false },
|
|
54
|
+
'allow-breaking': { type: 'boolean', default: false },
|
|
55
|
+
watch: { type: 'boolean', short: 'w', default: false },
|
|
56
|
+
proc: { type: 'string' },
|
|
57
|
+
type: { type: 'string' },
|
|
58
|
+
input: { type: 'boolean', default: false },
|
|
59
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const command = positionals[0];
|
|
63
|
+
if (values.help || (command !== 'generate' && command !== 'check' && command !== 'decode')) {
|
|
64
|
+
out(USAGE);
|
|
65
|
+
return values.help ? 0 : 2;
|
|
66
|
+
}
|
|
67
|
+
const project = path.resolve(values.project);
|
|
68
|
+
const root = path.dirname(project);
|
|
69
|
+
const [routerFile, exportName = 'appRouter'] = values.router.split('#');
|
|
70
|
+
const lockPath = path.resolve(values.lock ?? path.join(root, 'tbr.lock.json'));
|
|
71
|
+
const outPath = path.resolve(values.out ?? path.join(path.dirname(path.resolve(root, routerFile)), 'tbr.gen.ts'));
|
|
72
|
+
if (command === 'decode') {
|
|
73
|
+
if (!positionals[1] || !!values.proc === !!values.type) {
|
|
74
|
+
out(USAGE);
|
|
75
|
+
return 2;
|
|
76
|
+
}
|
|
77
|
+
return decode(positionals[1], outPath, values, out);
|
|
78
|
+
}
|
|
79
|
+
const paths = { root, router: path.resolve(root, routerFile), exportName, lockPath, outPath };
|
|
80
|
+
if (values.watch) {
|
|
81
|
+
if (command !== 'generate') {
|
|
82
|
+
out('--watch works with generate only\n');
|
|
83
|
+
return 2;
|
|
84
|
+
}
|
|
85
|
+
return watch(project, paths, values, out, options.signal);
|
|
86
|
+
}
|
|
87
|
+
const src = new Ts71Source(project);
|
|
88
|
+
try {
|
|
89
|
+
return command === 'check' ? check(src, paths, out) : generate(src, paths, readLock(lockPath), values, out);
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
src.close();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function readLock(lockPath) {
|
|
96
|
+
const exists = fs.existsSync(lockPath);
|
|
97
|
+
return { exists, lock: exists ? parseLock(fs.readFileSync(lockPath, 'utf8')) : emptyLock() };
|
|
98
|
+
}
|
|
99
|
+
const rel = (p) => path.relative(process.cwd(), p) || p;
|
|
100
|
+
/** The schema, or null after printing why it cannot be built. */
|
|
101
|
+
function schemaOf(src, p, out) {
|
|
102
|
+
const built = buildSchema(src, p.router, p.exportName);
|
|
103
|
+
if (!built.errors.length)
|
|
104
|
+
return built.schema;
|
|
105
|
+
out(`${built.errors.length} type(s) cannot be sent over the wire:\n` + printErrors(built.errors));
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
function check(src, p, out) {
|
|
109
|
+
const schema = schemaOf(src, p, out);
|
|
110
|
+
if (!schema)
|
|
111
|
+
return 1;
|
|
112
|
+
const prev = readLock(p.lockPath);
|
|
113
|
+
if (!prev.exists) {
|
|
114
|
+
out(`${rel(p.lockPath)} does not exist; run tbr generate\n`);
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const { lock, changes } = updateLock(prev.lock, schema);
|
|
118
|
+
if (stringifyLock(lock) === fs.readFileSync(p.lockPath, 'utf8')) {
|
|
119
|
+
out(`${rel(p.lockPath)} is up to date\n`);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
out(`${rel(p.lockPath)} is out of date:\n` + printChanges(changes));
|
|
123
|
+
out(changes.some((c) => c.severity !== 'safe') ? 'the contract has breaking changes\n' : 'run tbr generate\n');
|
|
124
|
+
return 1;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Updates the lockfile from `prev` and writes the codecs. In watch mode `prev` is the lockfile as it was
|
|
128
|
+
* when watching started, so a field that is added and renamed between two saves never reaches it.
|
|
129
|
+
*/
|
|
130
|
+
function generate(src, p, prev, o, out) {
|
|
131
|
+
const schema = schemaOf(src, p, out);
|
|
132
|
+
if (!schema)
|
|
133
|
+
return 1;
|
|
134
|
+
const { lock, changes } = updateLock(prev.lock, schema);
|
|
135
|
+
const text = stringifyLock(lock);
|
|
136
|
+
const current = fs.existsSync(p.lockPath) ? fs.readFileSync(p.lockPath, 'utf8') : null;
|
|
137
|
+
if (changes.length)
|
|
138
|
+
out(printChanges(changes));
|
|
139
|
+
const blocking = changes.filter((c) => c.severity === 'error' || (c.severity === 'breaking' && !o['allow-breaking']));
|
|
140
|
+
if (blocking.length) {
|
|
141
|
+
const hint = blocking.some((c) => c.severity === 'error') ? '' : '; rerun with --allow-breaking to accept them';
|
|
142
|
+
out(`${blocking.length} change(s) not applied${hint}\n`);
|
|
143
|
+
return 1;
|
|
144
|
+
}
|
|
145
|
+
if (o['dry-run']) {
|
|
146
|
+
out(printSchema(schema) + `\n// ${rel(p.lockPath)}\n` + text);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
if (text === current)
|
|
150
|
+
out(`${rel(p.lockPath)} is up to date\n`);
|
|
151
|
+
else {
|
|
152
|
+
fs.writeFileSync(p.lockPath, text);
|
|
153
|
+
out(`${current === null ? 'created' : 'updated'} ${rel(p.lockPath)}\n`);
|
|
154
|
+
}
|
|
155
|
+
let from = path.relative(path.dirname(p.outPath), p.router).split(path.sep).join('/');
|
|
156
|
+
if (!from.startsWith('.'))
|
|
157
|
+
from = `./${from}`;
|
|
158
|
+
const code = emitCodecs(schema, lock, { router: { from, name: p.exportName } });
|
|
159
|
+
if (fs.existsSync(p.outPath) && fs.readFileSync(p.outPath, 'utf8') === code)
|
|
160
|
+
out(`${rel(p.outPath)} is up to date\n`);
|
|
161
|
+
else {
|
|
162
|
+
fs.writeFileSync(p.outPath, code);
|
|
163
|
+
out(`wrote ${rel(p.outPath)}\n`);
|
|
164
|
+
}
|
|
165
|
+
return 0;
|
|
166
|
+
}
|
|
167
|
+
const WATCHED = /\.[mc]?tsx?$|(^|[\\/])tsconfig[^\\/]*\.json$/;
|
|
168
|
+
const DEBOUNCE_MS = 30;
|
|
169
|
+
/**
|
|
170
|
+
* generate --watch: one TypeScript project kept open, a fresh snapshot per batch of changes (the compiler
|
|
171
|
+
* reuses unchanged files), regeneration on every save. Resolves when `signal` aborts.
|
|
172
|
+
*/
|
|
173
|
+
function watch(project, p, o, out, signal) {
|
|
174
|
+
const src = new Ts71Source(project);
|
|
175
|
+
let base = readLock(p.lockPath);
|
|
176
|
+
let written = base.exists ? fs.readFileSync(p.lockPath, 'utf8') : null;
|
|
177
|
+
const pending = new Map();
|
|
178
|
+
let timer;
|
|
179
|
+
const regenerate = (label) => {
|
|
180
|
+
const start = performance.now();
|
|
181
|
+
let code;
|
|
182
|
+
try {
|
|
183
|
+
code = generate(src, p, base, o, out);
|
|
184
|
+
}
|
|
185
|
+
catch (e) {
|
|
186
|
+
out(`${e instanceof Error ? e.message : String(e)}\n`);
|
|
187
|
+
code = 1;
|
|
188
|
+
}
|
|
189
|
+
if (fs.existsSync(p.lockPath))
|
|
190
|
+
written = fs.readFileSync(p.lockPath, 'utf8');
|
|
191
|
+
out(`${label} in ${(performance.now() - start).toFixed(0)} ms${code ? ', waiting for changes' : ''}\n`);
|
|
192
|
+
};
|
|
193
|
+
const flush = () => {
|
|
194
|
+
timer = undefined;
|
|
195
|
+
const files = { changed: [], created: [], deleted: [] };
|
|
196
|
+
for (const [f, kind] of pending)
|
|
197
|
+
files[kind].push(f);
|
|
198
|
+
pending.clear();
|
|
199
|
+
const start = performance.now();
|
|
200
|
+
src.update(files);
|
|
201
|
+
const names = [...files.changed, ...files.created, ...files.deleted].map(rel);
|
|
202
|
+
out(`\n${names.join(', ')} changed (snapshot ${(performance.now() - start).toFixed(0)} ms)\n`);
|
|
203
|
+
regenerate('regenerated');
|
|
204
|
+
};
|
|
205
|
+
regenerate('generated');
|
|
206
|
+
out(`watching ${path.relative(process.cwd(), p.root) || '.'} for changes\n`);
|
|
207
|
+
const watcher = fs.watch(p.root, { recursive: true }, (event, name) => {
|
|
208
|
+
if (!name)
|
|
209
|
+
return;
|
|
210
|
+
const file = path.join(p.root, name);
|
|
211
|
+
if (file === p.lockPath) {
|
|
212
|
+
// Someone else changed the lockfile (git checkout, a teammate's pull): it is the new base.
|
|
213
|
+
const now = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null;
|
|
214
|
+
if (now === written)
|
|
215
|
+
return;
|
|
216
|
+
written = now;
|
|
217
|
+
base = readLock(p.lockPath);
|
|
218
|
+
out(`\n${rel(file)} changed outside of watch; using it as the base\n`);
|
|
219
|
+
regenerate('regenerated');
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (file === p.outPath || !WATCHED.test(name) || name.split(/[\\/]/).some((s) => s === 'node_modules' || s.startsWith('.')))
|
|
223
|
+
return;
|
|
224
|
+
const kind = event === 'change' ? 'changed' : fs.existsSync(file) ? 'created' : 'deleted';
|
|
225
|
+
pending.set(file, pending.get(file) === 'created' && kind === 'changed' ? 'created' : kind);
|
|
226
|
+
clearTimeout(timer);
|
|
227
|
+
timer = setTimeout(flush, DEBOUNCE_MS);
|
|
228
|
+
});
|
|
229
|
+
return new Promise((resolve) => {
|
|
230
|
+
const stop = () => {
|
|
231
|
+
clearTimeout(timer);
|
|
232
|
+
watcher.close();
|
|
233
|
+
src.close();
|
|
234
|
+
resolve(0);
|
|
235
|
+
};
|
|
236
|
+
if (signal?.aborted)
|
|
237
|
+
stop();
|
|
238
|
+
else
|
|
239
|
+
signal?.addEventListener('abort', stop, { once: true });
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
async function decode(file, genPath, o, out) {
|
|
243
|
+
const bytes = new Uint8Array(fs.readFileSync(file === '-' ? 0 : file));
|
|
244
|
+
try {
|
|
245
|
+
if (bytes.length > 3 && bytes[3] & FLAG_ERROR) {
|
|
246
|
+
const e = decodeError(bytes);
|
|
247
|
+
const data = e.data && JSON.parse(new TextDecoder().decode(e.data));
|
|
248
|
+
out(dump({ error: { code: e.code, message: e.message, data } }));
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(genPath).href)));
|
|
252
|
+
const what = o.type ? `type '${o.type}'` : `${o.input ? 'input' : 'output'} of '${o.proc}'`;
|
|
253
|
+
const proc = o.proc ? mod.procedures[o.proc] : undefined;
|
|
254
|
+
const codec = o.type ? mod.codecs[o.type] : o.input ? proc?.input : proc?.output;
|
|
255
|
+
if (!codec) {
|
|
256
|
+
out(`${path.relative(process.cwd(), genPath)} has no ${what}\n`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
out(dump(codec.decode(bytes)));
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
catch (e) {
|
|
263
|
+
if (!(e instanceof VerifyError))
|
|
264
|
+
throw e;
|
|
265
|
+
out(`invalid message: ${e.message}\n`);
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
/** JSON with bigints as strings and typed arrays as arrays. */
|
|
270
|
+
function dump(v) {
|
|
271
|
+
const json = JSON.stringify(v, (_, x) => (typeof x === 'bigint' ? x.toString() : ArrayBuffer.isView(x) && !(x instanceof DataView) ? Array.from(x, Number) : x), 2);
|
|
272
|
+
return json + '\n';
|
|
273
|
+
}
|
|
274
|
+
function printChanges(changes) {
|
|
275
|
+
const order = { error: 0, breaking: 1, safe: 2 };
|
|
276
|
+
return [...changes]
|
|
277
|
+
.sort((a, b) => order[a.severity] - order[b.severity])
|
|
278
|
+
.map((c) => ` ${c.severity.padEnd(8)} ${c.path}: ${c.message}${c.location ? ` (${formatLocation(c.location)})` : ''}\n`)
|
|
279
|
+
.join('');
|
|
280
|
+
}
|
|
281
|
+
if (process.argv[1] && fs.realpathSync(process.argv[1]) === import.meta.filename) {
|
|
282
|
+
process.stdout.on('error', (e) => {
|
|
283
|
+
if (e.code === 'EPIPE')
|
|
284
|
+
process.exit(process.exitCode ?? 0); // `tbr generate --dry-run | head`
|
|
285
|
+
throw e;
|
|
286
|
+
});
|
|
287
|
+
const stop = new AbortController();
|
|
288
|
+
process.once('SIGINT', () => stop.abort()); // --watch: close the TypeScript process, then exit
|
|
289
|
+
Promise.resolve(run(process.argv.slice(2), undefined, { signal: stop.signal })).then((code) => (process.exitCode = code));
|
|
290
|
+
}
|