rpc-dart 0.0.0-stage → 0.1.2
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 +16 -2
- package/bin/rpc-dart.js +193 -0
- package/dist/commands/check.d.ts +11 -0
- package/dist/commands/check.js +39 -0
- package/dist/commands/clean.d.ts +5 -0
- package/dist/commands/clean.js +22 -0
- package/dist/commands/diff.d.ts +26 -0
- package/dist/commands/diff.js +233 -0
- package/dist/commands/doctor.d.ts +14 -0
- package/dist/commands/doctor.js +94 -0
- package/dist/commands/generate.d.ts +22 -0
- package/dist/commands/generate.js +73 -0
- package/dist/commands/init.d.ts +29 -0
- package/dist/commands/init.js +113 -0
- package/dist/commands/watch.d.ts +25 -0
- package/dist/commands/watch.js +91 -0
- package/dist/config.d.ts +42 -0
- package/dist/config.js +81 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/package.json +41 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 rpc-dart contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rpc-dart/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Zero-config CLI that generates typed Dart bindings from tRPC v11 routers.
|
|
4
|
+
|
|
5
|
+
Part of the [rpc-dart](https://github.com/AdityaJandu/rpc-dart) monorepo — end-to-end type safety for tRPC v11 backends consumed from Dart and Flutter.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install cli
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
See the [documentation](../../docs/README.md) for the full CLI reference, concepts, and guides.
|
|
14
|
+
|
|
15
|
+
## License
|
|
16
|
+
|
|
17
|
+
MIT — see [LICENSE](./LICENSE).
|
package/bin/rpc-dart.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
checkContract,
|
|
5
|
+
cleanGenerated,
|
|
6
|
+
ConfigNotFoundError,
|
|
7
|
+
diffContract,
|
|
8
|
+
doctorChecks,
|
|
9
|
+
generate,
|
|
10
|
+
initWorkspace,
|
|
11
|
+
resolveTargets,
|
|
12
|
+
WatchSession,
|
|
13
|
+
} from '../dist/index.js';
|
|
14
|
+
|
|
15
|
+
const args = process.argv.slice(2);
|
|
16
|
+
const command = args[0];
|
|
17
|
+
|
|
18
|
+
function flag(name) {
|
|
19
|
+
const index = args.indexOf(`--${name}`);
|
|
20
|
+
if (index === -1) return undefined;
|
|
21
|
+
return args[index + 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasFlag(name) {
|
|
25
|
+
return args.includes(`--${name}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function flagsObject() {
|
|
29
|
+
return {
|
|
30
|
+
config: flag('config'),
|
|
31
|
+
tsconfig: flag('tsconfig'),
|
|
32
|
+
entry: flag('entry'),
|
|
33
|
+
exportName: flag('export'),
|
|
34
|
+
out: flag('out'),
|
|
35
|
+
transformer: flag('transformer'),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function policiesFrom(target) {
|
|
40
|
+
return target.policies ?? { unserializablePolicy: 'error' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function main() {
|
|
44
|
+
const cwd = process.cwd();
|
|
45
|
+
const configsDir = cwd;
|
|
46
|
+
|
|
47
|
+
switch (command) {
|
|
48
|
+
case 'generate': {
|
|
49
|
+
const targets = await resolveTargets(configsDir, flagsObject());
|
|
50
|
+
for (const target of targets) {
|
|
51
|
+
const result = await generate({
|
|
52
|
+
tsconfigPath: target.tsconfigPath,
|
|
53
|
+
entryFile: target.entry,
|
|
54
|
+
exportName: target.exportName,
|
|
55
|
+
outDir: path.resolve(cwd, target.dart?.outDir ?? 'lib/src/api'),
|
|
56
|
+
transformer: target.transformer,
|
|
57
|
+
policies: policiesFrom(target),
|
|
58
|
+
force: hasFlag('force'),
|
|
59
|
+
});
|
|
60
|
+
const label = target.name ? ` [${target.name}]` : '';
|
|
61
|
+
console.log(
|
|
62
|
+
result.wrote
|
|
63
|
+
? `wrote${label} ${result.files.length} files (contract ${result.hash.slice(0, 15)})`
|
|
64
|
+
: `contract unchanged${label}, no writes (${result.hash.slice(0, 15)})`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
case 'watch': {
|
|
70
|
+
const targets = await resolveTargets(configsDir, flagsObject());
|
|
71
|
+
for (const target of targets) {
|
|
72
|
+
const session = new WatchSession({
|
|
73
|
+
tsconfigPath: target.tsconfigPath,
|
|
74
|
+
entryFile: target.entry,
|
|
75
|
+
exportName: target.exportName,
|
|
76
|
+
outDir: path.resolve(cwd, target.dart?.outDir ?? 'lib/src/api'),
|
|
77
|
+
transformer: target.transformer,
|
|
78
|
+
policies: policiesFrom(target),
|
|
79
|
+
debounceMs: Number(flag('debounce-ms') ?? 300),
|
|
80
|
+
onCycle: (cycle) =>
|
|
81
|
+
console.log(
|
|
82
|
+
cycle.changed
|
|
83
|
+
? `[watch] re-emitted ${cycle.written} files in ${cycle.durationMs}ms (${cycle.hash.slice(0, 15)})`
|
|
84
|
+
: `[watch] contract unchanged in ${cycle.durationMs}ms`,
|
|
85
|
+
),
|
|
86
|
+
onError: (error) => console.error(`[watch] transient error: ${error.message}`),
|
|
87
|
+
});
|
|
88
|
+
session.start();
|
|
89
|
+
}
|
|
90
|
+
console.log('[watch] started - press Ctrl+C to stop');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
case 'check': {
|
|
94
|
+
const targets = await resolveTargets(configsDir, flagsObject());
|
|
95
|
+
let drift = false;
|
|
96
|
+
for (const target of targets) {
|
|
97
|
+
const result = await checkContract({
|
|
98
|
+
tsconfigPath: target.tsconfigPath,
|
|
99
|
+
entryFile: target.entry,
|
|
100
|
+
exportName: target.exportName,
|
|
101
|
+
outDir: path.resolve(cwd, target.dart?.outDir ?? 'lib/src/api'),
|
|
102
|
+
transformer: target.transformer,
|
|
103
|
+
policies: policiesFrom(target),
|
|
104
|
+
});
|
|
105
|
+
if (result.ok) {
|
|
106
|
+
console.log('check: generated bindings are up to date');
|
|
107
|
+
} else {
|
|
108
|
+
drift = true;
|
|
109
|
+
console.error('check: drift detected');
|
|
110
|
+
for (const problem of result.problems) {
|
|
111
|
+
console.error(` - ${problem}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (drift) process.exitCode = 3;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case 'diff': {
|
|
119
|
+
const targets = await resolveTargets(configsDir, flagsObject());
|
|
120
|
+
for (const target of targets) {
|
|
121
|
+
const result = await diffContract({
|
|
122
|
+
tsconfigPath: target.tsconfigPath,
|
|
123
|
+
entryFile: target.entry,
|
|
124
|
+
exportName: target.exportName,
|
|
125
|
+
transformer: target.transformer,
|
|
126
|
+
baselinePath: path.resolve(cwd, flag('baseline') ?? 'ir.snapshot.json'),
|
|
127
|
+
updateBaseline: hasFlag('update-baseline'),
|
|
128
|
+
});
|
|
129
|
+
for (const entry of result.entries) {
|
|
130
|
+
console.log(`${entry.breaking ? 'BREAKING' : 'ok '} ${entry.kind} ${entry.path} ${entry.detail}`);
|
|
131
|
+
}
|
|
132
|
+
console.log(`diff: ${result.breakingCount} breaking, ${result.nonBreakingCount} non-breaking`);
|
|
133
|
+
if (result.hasBreaking && hasFlag('fail-on-breaking')) {
|
|
134
|
+
process.exitCode = 4;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
case 'clean': {
|
|
140
|
+
const targets = await resolveTargets(configsDir, flagsObject());
|
|
141
|
+
for (const target of targets) {
|
|
142
|
+
const result = cleanGenerated(path.resolve(cwd, target.dart?.outDir ?? 'lib/src/api'));
|
|
143
|
+
console.log(`clean: removed ${result.removed.length} owned files`);
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case 'doctor': {
|
|
148
|
+
const targets = await resolveTargets(configsDir, flagsObject());
|
|
149
|
+
for (const target of targets) {
|
|
150
|
+
const label = target.name ? `[${target.name}] ` : '';
|
|
151
|
+
const checks = await doctorChecks({
|
|
152
|
+
tsconfigPath: target.tsconfigPath,
|
|
153
|
+
entryFile: target.entry,
|
|
154
|
+
exportName: target.exportName,
|
|
155
|
+
dartPackagePath: flag('dart-package')
|
|
156
|
+
? path.resolve(cwd, flag('dart-package'))
|
|
157
|
+
: target.dart?.packagePath
|
|
158
|
+
? path.resolve(cwd, target.dart.packagePath)
|
|
159
|
+
: undefined,
|
|
160
|
+
});
|
|
161
|
+
let failing = false;
|
|
162
|
+
for (const check of checks) {
|
|
163
|
+
console.log(`${check.status.toUpperCase().padEnd(5)} ${label}${check.name}: ${check.detail}`);
|
|
164
|
+
if (check.status === 'fail') failing = true;
|
|
165
|
+
}
|
|
166
|
+
if (failing) process.exitCode = 5;
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
case 'init': {
|
|
171
|
+
const result = await initWorkspace(cwd, { flutterDirs: [] });
|
|
172
|
+
console.log(`init: wrote ${result.configPath}`);
|
|
173
|
+
console.log(` entry: ${result.config.entry}`);
|
|
174
|
+
console.log(` exportName: ${result.config.exportName}`);
|
|
175
|
+
console.log(` dart.outDir: ${result.config.dart.outDir}`);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
default:
|
|
179
|
+
console.error(`rpc-dart: unknown command "${command ?? ''}"`);
|
|
180
|
+
console.error('commands: generate | watch | check | diff | clean | doctor | init');
|
|
181
|
+
process.exitCode = 2;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
main().catch((error) => {
|
|
186
|
+
if (error instanceof ConfigNotFoundError) {
|
|
187
|
+
console.error(`rpc-dart: ${error.message}`);
|
|
188
|
+
process.exitCode = 2;
|
|
189
|
+
} else {
|
|
190
|
+
console.error(`rpc-dart: ${error.message}`);
|
|
191
|
+
process.exitCode = 1;
|
|
192
|
+
}
|
|
193
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ExtractPolicies } from '@rpc-dart/trpc-adapter';
|
|
2
|
+
import type { GenerateOptions } from './generate.js';
|
|
3
|
+
export interface CheckResult {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
problems: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* CI gate: re-extracts the contract, verifies the committed manifest hash and
|
|
9
|
+
* every manifest file's content against disk. Non-destructive.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkContract(options: Omit<GenerateOptions, 'force'>, policies?: ExtractPolicies): Promise<CheckResult>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { extractContract } from '@rpc-dart/trpc-adapter';
|
|
5
|
+
const MANIFEST_NAME = 'generated.rpc-dart.json';
|
|
6
|
+
/**
|
|
7
|
+
* CI gate: re-extracts the contract, verifies the committed manifest hash and
|
|
8
|
+
* every manifest file's content against disk. Non-destructive.
|
|
9
|
+
*/
|
|
10
|
+
export async function checkContract(options, policies) {
|
|
11
|
+
const problems = [];
|
|
12
|
+
const { ir } = await extractContract({
|
|
13
|
+
tsconfigPath: options.tsconfigPath,
|
|
14
|
+
entryFile: options.entryFile,
|
|
15
|
+
exportName: options.exportName,
|
|
16
|
+
protocol: { kind: 'trpc', version: '11', transformer: options.transformer ?? 'superjson' },
|
|
17
|
+
policies,
|
|
18
|
+
});
|
|
19
|
+
const manifestPath = path.join(options.outDir, MANIFEST_NAME);
|
|
20
|
+
if (!existsSync(manifestPath)) {
|
|
21
|
+
return { ok: false, problems: ['no generated manifest found - run `rpc-dart generate` first'] };
|
|
22
|
+
}
|
|
23
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
24
|
+
if (manifest.irHash !== ir.hash) {
|
|
25
|
+
problems.push(`contract hash mismatch: router changed since last generate (manifest ${manifest.irHash.slice(0, 15)}, current ${ir.hash.slice(0, 15)})`);
|
|
26
|
+
}
|
|
27
|
+
for (const file of manifest.files) {
|
|
28
|
+
const target = path.join(options.outDir, file.path);
|
|
29
|
+
if (!existsSync(target)) {
|
|
30
|
+
problems.push(`missing generated file: ${file.path}`);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const actual = createHash('sha256').update(readFileSync(target, 'utf-8')).digest('hex');
|
|
34
|
+
if (actual !== file.contentSha256) {
|
|
35
|
+
problems.push(`generated file modified on disk: ${file.path}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return { ok: problems.length === 0, problems };
|
|
39
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MANIFEST_NAME = 'generated.rpc-dart.json';
|
|
4
|
+
/** Deletes exactly the files owned by the generator (manifest-listed). */
|
|
5
|
+
export function cleanGenerated(outDir) {
|
|
6
|
+
const manifestPath = path.join(outDir, MANIFEST_NAME);
|
|
7
|
+
if (!existsSync(manifestPath)) {
|
|
8
|
+
throw new Error(`no generated manifest at ${manifestPath} - nothing owned by rpc-dart to clean`);
|
|
9
|
+
}
|
|
10
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
11
|
+
const removed = [];
|
|
12
|
+
for (const file of manifest.files) {
|
|
13
|
+
const target = path.join(outDir, file.path);
|
|
14
|
+
if (existsSync(target)) {
|
|
15
|
+
rmSync(target);
|
|
16
|
+
removed.push(file.path);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
rmSync(manifestPath);
|
|
20
|
+
removed.push(MANIFEST_NAME);
|
|
21
|
+
return { removed };
|
|
22
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ContractIR } from '@rpc-dart/core';
|
|
2
|
+
export interface DiffEntry {
|
|
3
|
+
kind: 'procedure-removed' | 'procedure-renamed' | 'procedure-added' | 'procedure-kind-changed' | 'input-field-added-required' | 'input-field-made-required' | 'input-type-changed' | 'output-field-removed' | 'output-type-changed' | 'output-nullable-changed' | 'enum-value-removed' | 'input-field-added-optional' | 'input-field-removed' | 'output-field-added' | 'enum-value-added';
|
|
4
|
+
path: string;
|
|
5
|
+
detail: string;
|
|
6
|
+
breaking: boolean;
|
|
7
|
+
}
|
|
8
|
+
export interface DiffResult {
|
|
9
|
+
entries: DiffEntry[];
|
|
10
|
+
breakingCount: number;
|
|
11
|
+
nonBreakingCount: number;
|
|
12
|
+
hasBreaking: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface DiffOptions {
|
|
15
|
+
tsconfigPath: string;
|
|
16
|
+
entryFile: string;
|
|
17
|
+
exportName: string;
|
|
18
|
+
transformer?: 'superjson' | 'json';
|
|
19
|
+
/** Path to a committed full-IR snapshot (written by --update-baseline). */
|
|
20
|
+
baselinePath: string;
|
|
21
|
+
updateBaseline?: boolean;
|
|
22
|
+
}
|
|
23
|
+
/** Compares the current contract against a committed IR snapshot. */
|
|
24
|
+
export declare function diffContract(options: DiffOptions): Promise<DiffResult>;
|
|
25
|
+
/** Structural diff between two contract IRs. */
|
|
26
|
+
export declare function diffIr(baseline: ContractIR, current: ContractIR): DiffEntry[];
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { stableStringify } from '@rpc-dart/core';
|
|
3
|
+
import { extractContract } from '@rpc-dart/trpc-adapter';
|
|
4
|
+
/** Compares the current contract against a committed IR snapshot. */
|
|
5
|
+
export async function diffContract(options) {
|
|
6
|
+
const { ir: current } = await extractContract({
|
|
7
|
+
tsconfigPath: options.tsconfigPath,
|
|
8
|
+
entryFile: options.entryFile,
|
|
9
|
+
exportName: options.exportName,
|
|
10
|
+
protocol: { kind: 'trpc', version: '11', transformer: options.transformer ?? 'superjson' },
|
|
11
|
+
});
|
|
12
|
+
if (options.updateBaseline || !existsSync(options.baselinePath)) {
|
|
13
|
+
if (!options.updateBaseline) {
|
|
14
|
+
throw new Error(`baseline snapshot not found at ${options.baselinePath} - run with updateBaseline to write it`);
|
|
15
|
+
}
|
|
16
|
+
writeFileSync(options.baselinePath, `${JSON.stringify(current, null, 2)}\n`);
|
|
17
|
+
return { entries: [], breakingCount: 0, nonBreakingCount: 0, hasBreaking: false };
|
|
18
|
+
}
|
|
19
|
+
const baseline = JSON.parse(readFileSync(options.baselinePath, 'utf-8'));
|
|
20
|
+
const entries = diffIr(baseline, current);
|
|
21
|
+
if (options.updateBaseline) {
|
|
22
|
+
writeFileSync(options.baselinePath, `${JSON.stringify(current, null, 2)}\n`);
|
|
23
|
+
}
|
|
24
|
+
const breakingCount = entries.filter((e) => e.breaking).length;
|
|
25
|
+
return {
|
|
26
|
+
entries,
|
|
27
|
+
breakingCount,
|
|
28
|
+
nonBreakingCount: entries.length - breakingCount,
|
|
29
|
+
hasBreaking: breakingCount > 0,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function typeSignature(type, ir, seen = new Set()) {
|
|
33
|
+
return stableStringify(stripNamesDeep(type, ir, seen));
|
|
34
|
+
}
|
|
35
|
+
function stripNamesDeep(type, ir, seen) {
|
|
36
|
+
switch (type.kind) {
|
|
37
|
+
case 'object':
|
|
38
|
+
return {
|
|
39
|
+
kind: 'object',
|
|
40
|
+
fields: type.fields.map((f) => ({
|
|
41
|
+
name: f.name,
|
|
42
|
+
type: stripRefDeep(f.type, ir, seen),
|
|
43
|
+
optional: f.optional,
|
|
44
|
+
nullable: f.nullable,
|
|
45
|
+
})),
|
|
46
|
+
};
|
|
47
|
+
case 'enum':
|
|
48
|
+
case 'simpleUnion':
|
|
49
|
+
return { kind: type.kind, values: type.values };
|
|
50
|
+
case 'discriminatedUnion':
|
|
51
|
+
return {
|
|
52
|
+
kind: type.kind,
|
|
53
|
+
tagField: type.tagField,
|
|
54
|
+
variants: type.variants.map((v) => ({ tag: v.tag, object: stripRefDeep(v.object, ir, seen) })),
|
|
55
|
+
};
|
|
56
|
+
case 'list':
|
|
57
|
+
case 'set':
|
|
58
|
+
return { kind: type.kind, element: stripRefDeep(type.element, ir, seen) };
|
|
59
|
+
case 'record':
|
|
60
|
+
return {
|
|
61
|
+
kind: type.kind,
|
|
62
|
+
keyType: stripRefDeep(type.keyType, ir, seen),
|
|
63
|
+
valueType: stripRefDeep(type.valueType, ir, seen),
|
|
64
|
+
};
|
|
65
|
+
case 'tuple':
|
|
66
|
+
return { kind: type.kind, items: type.items.map((t) => stripRefDeep(t, ir, seen)) };
|
|
67
|
+
case 'nullable':
|
|
68
|
+
return { kind: type.kind, inner: stripRefDeep(type.inner, ir, seen) };
|
|
69
|
+
case 'selfRef':
|
|
70
|
+
return { kind: 'selfRef' };
|
|
71
|
+
default:
|
|
72
|
+
return type;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function stripRefDeep(type, ir, seen) {
|
|
76
|
+
if (typeof type === 'string')
|
|
77
|
+
return type;
|
|
78
|
+
if ('$ref' in type) {
|
|
79
|
+
// Resolve the reference so changes INSIDE named types are visible.
|
|
80
|
+
if (seen.has(type.$ref))
|
|
81
|
+
return { $ref: 'recursive' };
|
|
82
|
+
seen.add(type.$ref);
|
|
83
|
+
const def = ir.types[type.$ref];
|
|
84
|
+
if (!def)
|
|
85
|
+
return { $ref: 'unknown' };
|
|
86
|
+
return stripNamesDeep(def, ir, seen);
|
|
87
|
+
}
|
|
88
|
+
return stripNamesDeep(type, ir, seen);
|
|
89
|
+
}
|
|
90
|
+
function fieldsSignature(fields) {
|
|
91
|
+
return stableStringify(fields.map((f) => ({ name: f.name, optional: f.optional, nullable: f.nullable })));
|
|
92
|
+
}
|
|
93
|
+
/** Structural diff between two contract IRs. */
|
|
94
|
+
export function diffIr(baseline, current) {
|
|
95
|
+
const entries = [];
|
|
96
|
+
const baselinePaths = Object.keys(baseline.procedures).sort();
|
|
97
|
+
const currentPaths = Object.keys(current.procedures).sort();
|
|
98
|
+
// Rename detection: removed procedures whose shape reappears under a new path.
|
|
99
|
+
const removed = baselinePaths.filter((p) => !currentPaths.includes(p));
|
|
100
|
+
const added = currentPaths.filter((p) => !baselinePaths.includes(p));
|
|
101
|
+
const consumedAdded = new Set();
|
|
102
|
+
for (const path of removed) {
|
|
103
|
+
const oldRecord = baseline.procedures[path];
|
|
104
|
+
const renamedTo = added.find((candidate) => {
|
|
105
|
+
if (consumedAdded.has(candidate))
|
|
106
|
+
return false;
|
|
107
|
+
const newRecord = current.procedures[candidate];
|
|
108
|
+
return (newRecord.kind === oldRecord.kind &&
|
|
109
|
+
typeSignature(resolveOutput(oldRecord.output, baseline), baseline) ===
|
|
110
|
+
typeSignature(resolveOutput(newRecord.output, current), current) &&
|
|
111
|
+
fieldsSignature(oldRecord.input === null ? [] : resolveInputFields(oldRecord.input, baseline)) ===
|
|
112
|
+
fieldsSignature(newRecord.input === null ? [] : resolveInputFields(newRecord.input, current)));
|
|
113
|
+
});
|
|
114
|
+
if (renamedTo) {
|
|
115
|
+
consumedAdded.add(renamedTo);
|
|
116
|
+
entries.push({
|
|
117
|
+
kind: 'procedure-renamed',
|
|
118
|
+
path,
|
|
119
|
+
detail: `renamed to ${renamedTo}`,
|
|
120
|
+
breaking: true,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
entries.push({ kind: 'procedure-removed', path, detail: 'removed', breaking: true });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for (const path of added) {
|
|
128
|
+
if (consumedAdded.has(path))
|
|
129
|
+
continue;
|
|
130
|
+
entries.push({ kind: 'procedure-added', path, detail: 'added', breaking: false });
|
|
131
|
+
}
|
|
132
|
+
for (const path of baselinePaths.filter((p) => currentPaths.includes(p))) {
|
|
133
|
+
const before = baseline.procedures[path];
|
|
134
|
+
const after = current.procedures[path];
|
|
135
|
+
if (before.kind !== after.kind) {
|
|
136
|
+
entries.push({ kind: 'procedure-kind-changed', path, detail: `${before.kind} -> ${after.kind}`, breaking: true });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const beforeInput = before.input === null ? null : resolveInputFields(before.input, baseline);
|
|
140
|
+
const afterInput = after.input === null ? null : resolveInputFields(after.input, current);
|
|
141
|
+
if ((beforeInput === null) !== (afterInput === null)) {
|
|
142
|
+
entries.push({
|
|
143
|
+
kind: 'input-type-changed',
|
|
144
|
+
path,
|
|
145
|
+
detail: 'input presence changed',
|
|
146
|
+
breaking: true,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else if (beforeInput && afterInput) {
|
|
150
|
+
diffInputFields(path, beforeInput, afterInput, entries, baseline, current);
|
|
151
|
+
}
|
|
152
|
+
const beforeOutput = resolveOutput(before.output, baseline);
|
|
153
|
+
const afterOutput = resolveOutput(after.output, current);
|
|
154
|
+
if (typeSignature(beforeOutput, baseline) !== typeSignature(afterOutput, current)) {
|
|
155
|
+
diffOutputFields(path, beforeOutput, afterOutput, entries, baseline, current);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return entries;
|
|
159
|
+
}
|
|
160
|
+
function resolveInputFields(input, ir) {
|
|
161
|
+
const def = resolveDef(input, ir);
|
|
162
|
+
if (def.kind !== 'object')
|
|
163
|
+
return [];
|
|
164
|
+
return def.fields;
|
|
165
|
+
}
|
|
166
|
+
function resolveOutput(output, ir) {
|
|
167
|
+
return resolveDef(output, ir);
|
|
168
|
+
}
|
|
169
|
+
function resolveDef(type, ir) {
|
|
170
|
+
if (typeof type === 'string') {
|
|
171
|
+
return { kind: 'primitive', name: type };
|
|
172
|
+
}
|
|
173
|
+
if ('$ref' in type) {
|
|
174
|
+
const def = ir.types[type.$ref];
|
|
175
|
+
if (!def)
|
|
176
|
+
throw new Error(`missing registry entry ${type.$ref}`);
|
|
177
|
+
return def;
|
|
178
|
+
}
|
|
179
|
+
return type;
|
|
180
|
+
}
|
|
181
|
+
function diffInputFields(path, before, after, entries, baseline, current) {
|
|
182
|
+
const beforeByName = new Map(before.map((f) => [f.name, f]));
|
|
183
|
+
const afterByName = new Map(after.map((f) => [f.name, f]));
|
|
184
|
+
for (const [name, beforeField] of beforeByName) {
|
|
185
|
+
const afterField = afterByName.get(name);
|
|
186
|
+
if (!afterField) {
|
|
187
|
+
entries.push({ kind: 'input-field-removed', path: `${path}#${name}`, detail: 'input field removed', breaking: false });
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
if (!beforeField.optional && afterField.optional) {
|
|
191
|
+
entries.push({ kind: 'input-field-made-required', path: `${path}#${name}`, detail: 'optional -> required', breaking: true });
|
|
192
|
+
}
|
|
193
|
+
if (typeSignature(beforeField.type, baseline) !== typeSignature(afterField.type, current)) {
|
|
194
|
+
entries.push({ kind: 'input-type-changed', path: `${path}#${name}`, detail: 'input field type changed', breaking: true });
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const [name, afterField] of afterByName) {
|
|
198
|
+
if (!beforeByName.has(name)) {
|
|
199
|
+
entries.push({
|
|
200
|
+
kind: afterField.optional ? 'input-field-added-optional' : 'input-field-added-required',
|
|
201
|
+
path: `${path}#${name}`,
|
|
202
|
+
detail: afterField.optional ? 'optional input field added' : 'required input field added',
|
|
203
|
+
breaking: !afterField.optional,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function diffOutputFields(path, before, after, entries, baseline, current) {
|
|
209
|
+
if (before.kind !== 'object' || after.kind !== 'object') {
|
|
210
|
+
entries.push({ kind: 'output-type-changed', path, detail: `output type changed (${before.kind} -> ${after.kind})`, breaking: true });
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const beforeByName = new Map(before.fields.map((f) => [f.name, f]));
|
|
214
|
+
const afterByName = new Map(after.fields.map((f) => [f.name, f]));
|
|
215
|
+
for (const [name, beforeField] of beforeByName) {
|
|
216
|
+
const afterField = afterByName.get(name);
|
|
217
|
+
if (!afterField) {
|
|
218
|
+
entries.push({ kind: 'output-field-removed', path: `${path}#${name}`, detail: 'output field removed', breaking: true });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (beforeField.nullable !== afterField.nullable) {
|
|
222
|
+
entries.push({ kind: 'output-nullable-changed', path: `${path}#${name}`, detail: 'output nullability changed', breaking: true });
|
|
223
|
+
}
|
|
224
|
+
if (typeSignature(beforeField.type, baseline) !== typeSignature(afterField.type, current)) {
|
|
225
|
+
entries.push({ kind: 'output-type-changed', path: `${path}#${name}`, detail: 'output field type changed', breaking: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
for (const name of afterByName.keys()) {
|
|
229
|
+
if (!beforeByName.has(name)) {
|
|
230
|
+
entries.push({ kind: 'output-field-added', path: `${path}#${name}`, detail: 'output field added', breaking: false });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface DoctorCheck {
|
|
2
|
+
name: string;
|
|
3
|
+
status: 'pass' | 'warn' | 'fail';
|
|
4
|
+
detail: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DoctorOptions {
|
|
7
|
+
tsconfigPath?: string;
|
|
8
|
+
entryFile?: string;
|
|
9
|
+
exportName?: string;
|
|
10
|
+
/** Path to a Dart package whose pubspec version should match the CLI's. */
|
|
11
|
+
dartPackagePath?: string;
|
|
12
|
+
}
|
|
13
|
+
/** Environment + workspace checklist. */
|
|
14
|
+
export declare function doctorChecks(options?: DoctorOptions): Promise<DoctorCheck[]>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { extractContract } from '@rpc-dart/trpc-adapter';
|
|
4
|
+
/** Environment + workspace checklist. */
|
|
5
|
+
export async function doctorChecks(options = {}) {
|
|
6
|
+
const checks = [];
|
|
7
|
+
const nodeMajor = Number(process.versions.node.split('.')[0]);
|
|
8
|
+
checks.push({
|
|
9
|
+
name: 'node-version',
|
|
10
|
+
status: nodeMajor >= 20 ? 'pass' : 'fail',
|
|
11
|
+
detail: `node ${process.versions.node} (requires >= 20)`,
|
|
12
|
+
});
|
|
13
|
+
if (options.tsconfigPath) {
|
|
14
|
+
checks.push({
|
|
15
|
+
name: 'tsconfig',
|
|
16
|
+
status: existsSync(options.tsconfigPath) ? 'pass' : 'fail',
|
|
17
|
+
detail: options.tsconfigPath,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
checks.push({ name: 'tsconfig', status: 'warn', detail: 'no tsconfig path provided' });
|
|
22
|
+
}
|
|
23
|
+
if (options.entryFile && options.exportName) {
|
|
24
|
+
try {
|
|
25
|
+
await extractContract({
|
|
26
|
+
tsconfigPath: options.tsconfigPath ?? '',
|
|
27
|
+
entryFile: options.entryFile,
|
|
28
|
+
exportName: options.exportName,
|
|
29
|
+
protocol: { kind: 'trpc', version: '11', transformer: 'superjson' },
|
|
30
|
+
});
|
|
31
|
+
checks.push({ name: 'router-export', status: 'pass', detail: `${options.exportName} extracted` });
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
checks.push({
|
|
35
|
+
name: 'router-export',
|
|
36
|
+
status: 'fail',
|
|
37
|
+
detail: error.message.slice(0, 200),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
checks.push({ name: 'router-export', status: 'warn', detail: 'no router configured yet (run rpc-dart init)' });
|
|
43
|
+
}
|
|
44
|
+
let dartBin = null;
|
|
45
|
+
for (const candidate of [process.env.DART_BIN, 'dart', `${process.env.HOME}/development/flutter/bin/dart`]) {
|
|
46
|
+
if (!candidate)
|
|
47
|
+
continue;
|
|
48
|
+
try {
|
|
49
|
+
execFileSync(candidate, ['--version'], { stdio: 'pipe' });
|
|
50
|
+
dartBin = candidate;
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
checks.push({
|
|
58
|
+
name: 'dart-sdk',
|
|
59
|
+
status: dartBin ? 'pass' : 'fail',
|
|
60
|
+
detail: dartBin ?? 'dart not found on PATH (set DART_BIN)',
|
|
61
|
+
});
|
|
62
|
+
try {
|
|
63
|
+
execFileSync('flutter', ['--version', '--machine'], { stdio: 'pipe' });
|
|
64
|
+
checks.push({ name: 'flutter-sdk', status: 'pass', detail: 'flutter available' });
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
checks.push({ name: 'flutter-sdk', status: 'warn', detail: 'flutter not available (optional for server-only use)' });
|
|
68
|
+
}
|
|
69
|
+
if (options.dartPackagePath) {
|
|
70
|
+
const pubspecPath = `${options.dartPackagePath}/pubspec.yaml`;
|
|
71
|
+
try {
|
|
72
|
+
const pubspec = readFileSync(pubspecPath, 'utf-8');
|
|
73
|
+
const dartVersion = /^version:\s*(.+)$/m.exec(pubspec)?.[1]?.trim();
|
|
74
|
+
const cliPackage = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf-8'));
|
|
75
|
+
const cliVersion = cliPackage.version ?? 'unknown';
|
|
76
|
+
checks.push({
|
|
77
|
+
name: 'version-alignment',
|
|
78
|
+
status: dartVersion === cliVersion ? 'pass' : 'warn',
|
|
79
|
+
detail: `rpc-dart ${cliVersion} vs runtime ${dartVersion ?? 'unknown'}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
checks.push({
|
|
84
|
+
name: 'version-alignment',
|
|
85
|
+
status: 'warn',
|
|
86
|
+
detail: `could not read pubspec: ${error.message.slice(0, 120)}`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
checks.push({ name: 'version-alignment', status: 'warn', detail: 'no dart package path provided' });
|
|
92
|
+
}
|
|
93
|
+
return checks;
|
|
94
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface GenerateOptions {
|
|
2
|
+
tsconfigPath: string;
|
|
3
|
+
entryFile: string;
|
|
4
|
+
exportName: string;
|
|
5
|
+
/** Generated root, e.g. <consumer>/lib/src/api */
|
|
6
|
+
outDir: string;
|
|
7
|
+
transformer?: 'superjson' | 'json';
|
|
8
|
+
force?: boolean;
|
|
9
|
+
policies?: {
|
|
10
|
+
unserializablePolicy?: 'error' | 'warn-dynamic' | 'skip-procedure';
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface GenerateResult {
|
|
14
|
+
files: string[];
|
|
15
|
+
hash: string;
|
|
16
|
+
wrote: boolean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Full pipeline: extract -> hash gate -> emit -> atomic writes -> manifest ->
|
|
20
|
+
* stale-file cleanup.
|
|
21
|
+
*/
|
|
22
|
+
export declare function generate(options: GenerateOptions): Promise<GenerateResult>;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { extractContract } from '@rpc-dart/trpc-adapter';
|
|
4
|
+
import { emit } from '@rpc-dart/dart-generator';
|
|
5
|
+
const MANIFEST_NAME = 'generated.rpc-dart.json';
|
|
6
|
+
/**
|
|
7
|
+
* Full pipeline: extract -> hash gate -> emit -> atomic writes -> manifest ->
|
|
8
|
+
* stale-file cleanup.
|
|
9
|
+
*/
|
|
10
|
+
export async function generate(options) {
|
|
11
|
+
const { ir, diagnostics } = await extractContract({
|
|
12
|
+
tsconfigPath: options.tsconfigPath,
|
|
13
|
+
entryFile: options.entryFile,
|
|
14
|
+
exportName: options.exportName,
|
|
15
|
+
protocol: { kind: 'trpc', version: '11', transformer: options.transformer ?? 'superjson' },
|
|
16
|
+
policies: options.policies,
|
|
17
|
+
});
|
|
18
|
+
const manifestPath = path.join(options.outDir, MANIFEST_NAME);
|
|
19
|
+
const previous = existsSync(manifestPath)
|
|
20
|
+
? JSON.parse(readFileSync(manifestPath, 'utf-8'))
|
|
21
|
+
: null;
|
|
22
|
+
if (previous && previous.irHash === ir.hash && !options.force) {
|
|
23
|
+
return {
|
|
24
|
+
files: previous.files.map((f) => f.path),
|
|
25
|
+
hash: ir.hash,
|
|
26
|
+
wrote: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const emitted = emit(ir);
|
|
30
|
+
// emit() lays out under lib/src/api/; the consumer's outDir IS that root.
|
|
31
|
+
// Manifest paths are normalized too so stale cleanup addresses real files.
|
|
32
|
+
const manifestRaw = JSON.parse(emitted.files['lib/src/api/generated.rpc-dart.json']);
|
|
33
|
+
manifestRaw.files = manifestRaw.files.map((f) => ({
|
|
34
|
+
path: f.path.replace(/^lib\/src\/api\//, ''),
|
|
35
|
+
contentSha256: f.contentSha256,
|
|
36
|
+
}));
|
|
37
|
+
manifestRaw.diagnostics = diagnostics;
|
|
38
|
+
manifestRaw.policies = options.policies ?? { unserializablePolicy: 'error' };
|
|
39
|
+
const files = {};
|
|
40
|
+
for (const [rel, content] of Object.entries(emitted.files)) {
|
|
41
|
+
const stripped = rel.replace(/^lib\/src\/api\//, '');
|
|
42
|
+
files[stripped] = rel === 'lib/src/api/generated.rpc-dart.json'
|
|
43
|
+
? `${JSON.stringify(manifestRaw, null, 2)}\n`
|
|
44
|
+
: content;
|
|
45
|
+
}
|
|
46
|
+
const written = [];
|
|
47
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
48
|
+
const target = path.join(options.outDir, rel);
|
|
49
|
+
mkdirSync(path.dirname(target), { recursive: true });
|
|
50
|
+
const tmp = `${target}.tmp`;
|
|
51
|
+
writeFileSync(tmp, content);
|
|
52
|
+
renameSync(tmp, target);
|
|
53
|
+
written.push(rel);
|
|
54
|
+
}
|
|
55
|
+
// Stale cleanup: committed files from the previous manifest that the new
|
|
56
|
+
// emission no longer contains (crash-safe: cleanup happens after a
|
|
57
|
+
// successful full write, manifest last).
|
|
58
|
+
if (previous) {
|
|
59
|
+
for (const prevFile of previous.files) {
|
|
60
|
+
if (!(prevFile.path in files)) {
|
|
61
|
+
const stalePath = path.join(options.outDir, prevFile.path);
|
|
62
|
+
if (existsSync(stalePath)) {
|
|
63
|
+
rmLocal(stalePath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { files: written, hash: ir.hash, wrote: true };
|
|
69
|
+
}
|
|
70
|
+
import { rmSync } from 'node:fs';
|
|
71
|
+
function rmLocal(target) {
|
|
72
|
+
rmSync(target);
|
|
73
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface InitOptions {
|
|
2
|
+
/** Additional Flutter target directories to consider. */
|
|
3
|
+
flutterDirs?: string[];
|
|
4
|
+
/** Skip detection and write explicit values (used by tests). */
|
|
5
|
+
entryFile?: string;
|
|
6
|
+
exportName?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface InitResult {
|
|
9
|
+
configPath: string;
|
|
10
|
+
config: {
|
|
11
|
+
entry: string;
|
|
12
|
+
exportName: string;
|
|
13
|
+
transformer: 'superjson' | 'json';
|
|
14
|
+
dart: {
|
|
15
|
+
outDir: string;
|
|
16
|
+
packagePath?: string;
|
|
17
|
+
};
|
|
18
|
+
policies: {
|
|
19
|
+
unserializablePolicy: 'error' | 'warn-dynamic' | 'skip-procedure';
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
detected: {
|
|
23
|
+
tsconfig: string | null;
|
|
24
|
+
routerCandidates: string[];
|
|
25
|
+
flutterDirs: string[];
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Detects workspace shape and scaffolds rpc-dart.config.ts. Idempotent-guarded. */
|
|
29
|
+
export declare function initWorkspace(workspaceDir: string, options?: InitOptions): Promise<InitResult>;
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const CONFIG_FILE = 'rpc-dart.config.ts';
|
|
4
|
+
/** Detects workspace shape and scaffolds rpc-dart.config.ts. Idempotent-guarded. */
|
|
5
|
+
export async function initWorkspace(workspaceDir, options = {}) {
|
|
6
|
+
const configPath = path.join(workspaceDir, CONFIG_FILE);
|
|
7
|
+
if (existsSync(configPath)) {
|
|
8
|
+
throw new Error(`${CONFIG_FILE} already exists at ${configPath} - refusing to overwrite`);
|
|
9
|
+
}
|
|
10
|
+
const detected = { tsconfig: null, routerCandidates: [], flutterDirs: [] };
|
|
11
|
+
const tsconfigPath = path.join(workspaceDir, 'tsconfig.json');
|
|
12
|
+
if (existsSync(tsconfigPath)) {
|
|
13
|
+
detected.tsconfig = 'tsconfig.json';
|
|
14
|
+
}
|
|
15
|
+
const routerCandidates = findRouterCandidates(workspaceDir);
|
|
16
|
+
detected.routerCandidates = routerCandidates;
|
|
17
|
+
const flutterDirs = findFlutterDirs(workspaceDir, options.flutterDirs ?? []);
|
|
18
|
+
detected.flutterDirs = flutterDirs;
|
|
19
|
+
const entryFile = options.entryFile ??
|
|
20
|
+
(routerCandidates.length > 0 ? path.relative(workspaceDir, routerCandidates[0]) : 'src/trpc/router.ts');
|
|
21
|
+
const entryText = existsSync(path.join(workspaceDir, entryFile))
|
|
22
|
+
? readFileSync(path.join(workspaceDir, entryFile), 'utf-8')
|
|
23
|
+
: '';
|
|
24
|
+
const exportName = options.exportName ??
|
|
25
|
+
(/export\s+(?:const|type)\s+(appRouter|router|trpcRouter)\b/.exec(entryText)?.[1] ??
|
|
26
|
+
/export\s+const\s+(\w+Router)\b/.exec(entryText)?.[1] ??
|
|
27
|
+
'appRouter');
|
|
28
|
+
const flutterTarget = flutterDirs[0];
|
|
29
|
+
const config = {
|
|
30
|
+
entry: entryFile,
|
|
31
|
+
exportName,
|
|
32
|
+
transformer: 'superjson',
|
|
33
|
+
dart: {
|
|
34
|
+
outDir: flutterTarget
|
|
35
|
+
? path.join(path.relative(workspaceDir, flutterTarget), 'lib/src/api')
|
|
36
|
+
: 'lib/src/api',
|
|
37
|
+
packagePath: flutterTarget ? path.relative(workspaceDir, flutterTarget) : undefined,
|
|
38
|
+
},
|
|
39
|
+
policies: { unserializablePolicy: 'error' },
|
|
40
|
+
};
|
|
41
|
+
writeFileSync(configPath, `import { defineConfig } from 'rpc-dart';
|
|
42
|
+
|
|
43
|
+
// Generated by rpc-dart init. Review the detected values below.
|
|
44
|
+
export default defineConfig({
|
|
45
|
+
entry: ${JSON.stringify(config.entry)},
|
|
46
|
+
exportName: ${JSON.stringify(config.exportName)},
|
|
47
|
+
transformer: ${JSON.stringify(config.transformer)},
|
|
48
|
+
dart: {
|
|
49
|
+
outDir: ${JSON.stringify(config.dart.outDir)},
|
|
50
|
+
packagePath: ${config.dart.packagePath ? JSON.stringify(config.dart.packagePath) : 'undefined'},
|
|
51
|
+
},
|
|
52
|
+
policies: {
|
|
53
|
+
unserializablePolicy: ${JSON.stringify(config.policies.unserializablePolicy)},
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
`);
|
|
57
|
+
return { configPath, config, detected };
|
|
58
|
+
}
|
|
59
|
+
function findRouterCandidates(workspaceDir) {
|
|
60
|
+
const candidates = [];
|
|
61
|
+
const scan = (dir, depth) => {
|
|
62
|
+
if (depth > 4 || candidates.length >= 5)
|
|
63
|
+
return;
|
|
64
|
+
let entries;
|
|
65
|
+
try {
|
|
66
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const entry of entries) {
|
|
72
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.'))
|
|
73
|
+
continue;
|
|
74
|
+
const full = path.join(dir, entry.name);
|
|
75
|
+
if (entry.isDirectory()) {
|
|
76
|
+
scan(full, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
else if (/(^|\/)(router|trpc|app)\.tsx?$/.test(entry.name) ||
|
|
79
|
+
entry.name.endsWith('Router.ts')) {
|
|
80
|
+
try {
|
|
81
|
+
const text = readFileSync(full, 'utf-8');
|
|
82
|
+
if (text.includes('initTRPC')) {
|
|
83
|
+
candidates.push(full);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
scan(workspaceDir, 0);
|
|
93
|
+
return candidates;
|
|
94
|
+
}
|
|
95
|
+
function findFlutterDirs(workspaceDir, extra) {
|
|
96
|
+
const found = [];
|
|
97
|
+
for (const dir of [...readdirSafe(workspaceDir).map((n) => path.join(workspaceDir, n)), ...extra]) {
|
|
98
|
+
if (existsSync(path.join(dir, 'pubspec.yaml'))) {
|
|
99
|
+
found.push(dir);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
function readdirSafe(dir) {
|
|
105
|
+
try {
|
|
106
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
107
|
+
.filter((e) => e.isDirectory() && e.name !== 'node_modules' && !e.name.startsWith('.'))
|
|
108
|
+
.map((e) => e.name);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type GenerateOptions } from './generate.js';
|
|
2
|
+
export interface WatchCycle {
|
|
3
|
+
changed: boolean;
|
|
4
|
+
durationMs: number;
|
|
5
|
+
hash: string;
|
|
6
|
+
written: number;
|
|
7
|
+
}
|
|
8
|
+
export interface WatchSessionOptions extends GenerateOptions {
|
|
9
|
+
debounceMs?: number;
|
|
10
|
+
onCycle?: (cycle: WatchCycle) => void;
|
|
11
|
+
onError?: (error: Error) => void;
|
|
12
|
+
}
|
|
13
|
+
export declare class WatchSession {
|
|
14
|
+
private readonly options;
|
|
15
|
+
private readonly watchers;
|
|
16
|
+
private timer;
|
|
17
|
+
private stopped;
|
|
18
|
+
private running;
|
|
19
|
+
private lastHash;
|
|
20
|
+
constructor(options: WatchSessionOptions);
|
|
21
|
+
start(): void;
|
|
22
|
+
stop(): void;
|
|
23
|
+
private schedule;
|
|
24
|
+
private cycle;
|
|
25
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { watch } from 'node:fs';
|
|
2
|
+
import { extractContract } from '@rpc-dart/trpc-adapter';
|
|
3
|
+
import { generate } from './generate.js';
|
|
4
|
+
export class WatchSession {
|
|
5
|
+
options;
|
|
6
|
+
watchers = [];
|
|
7
|
+
timer = null;
|
|
8
|
+
stopped = false;
|
|
9
|
+
running = false;
|
|
10
|
+
lastHash = null;
|
|
11
|
+
constructor(options) {
|
|
12
|
+
this.options = options;
|
|
13
|
+
}
|
|
14
|
+
start() {
|
|
15
|
+
this.stopped = false;
|
|
16
|
+
const watchDirs = new Set([
|
|
17
|
+
path.dirname(this.options.entryFile),
|
|
18
|
+
path.dirname(this.options.tsconfigPath),
|
|
19
|
+
]);
|
|
20
|
+
for (const dir of watchDirs) {
|
|
21
|
+
try {
|
|
22
|
+
const watcher = watch(dir, { recursive: true }, () => this.schedule());
|
|
23
|
+
this.watchers.push(watcher);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Directory may not exist yet; the initial cycle still runs.
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
void this.cycle();
|
|
30
|
+
}
|
|
31
|
+
stop() {
|
|
32
|
+
this.stopped = true;
|
|
33
|
+
if (this.timer) {
|
|
34
|
+
clearTimeout(this.timer);
|
|
35
|
+
this.timer = null;
|
|
36
|
+
}
|
|
37
|
+
for (const watcher of this.watchers) {
|
|
38
|
+
watcher.close();
|
|
39
|
+
}
|
|
40
|
+
this.watchers.length = 0;
|
|
41
|
+
}
|
|
42
|
+
schedule() {
|
|
43
|
+
if (this.stopped)
|
|
44
|
+
return;
|
|
45
|
+
if (this.timer)
|
|
46
|
+
clearTimeout(this.timer);
|
|
47
|
+
this.timer = setTimeout(() => {
|
|
48
|
+
this.timer = null;
|
|
49
|
+
void this.cycle();
|
|
50
|
+
}, this.options.debounceMs ?? 300);
|
|
51
|
+
}
|
|
52
|
+
async cycle() {
|
|
53
|
+
if (this.stopped || this.running)
|
|
54
|
+
return;
|
|
55
|
+
this.running = true;
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
try {
|
|
58
|
+
const { ir } = await extractContract({
|
|
59
|
+
tsconfigPath: this.options.tsconfigPath,
|
|
60
|
+
entryFile: this.options.entryFile,
|
|
61
|
+
exportName: this.options.exportName,
|
|
62
|
+
protocol: { kind: 'trpc', version: '11', transformer: this.options.transformer ?? 'superjson' },
|
|
63
|
+
policies: this.options.policies,
|
|
64
|
+
});
|
|
65
|
+
if (this.lastHash !== null && ir.hash === this.lastHash) {
|
|
66
|
+
this.options.onCycle?.({
|
|
67
|
+
changed: false,
|
|
68
|
+
durationMs: Date.now() - started,
|
|
69
|
+
hash: ir.hash,
|
|
70
|
+
written: 0,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const result = await generate(this.options);
|
|
75
|
+
this.lastHash = result.hash;
|
|
76
|
+
this.options.onCycle?.({
|
|
77
|
+
changed: result.wrote,
|
|
78
|
+
durationMs: Date.now() - started,
|
|
79
|
+
hash: result.hash,
|
|
80
|
+
written: result.files.length,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
this.options.onError?.(error);
|
|
85
|
+
}
|
|
86
|
+
finally {
|
|
87
|
+
this.running = false;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
import path from 'node:path';
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export interface RpcDartTarget {
|
|
2
|
+
name?: string;
|
|
3
|
+
/** Path to tsconfig.json, relative to the config file's directory. */
|
|
4
|
+
tsconfig?: string;
|
|
5
|
+
entry: string;
|
|
6
|
+
exportName: string;
|
|
7
|
+
transformer?: 'superjson' | 'json';
|
|
8
|
+
dart?: {
|
|
9
|
+
outDir: string;
|
|
10
|
+
packagePath?: string;
|
|
11
|
+
};
|
|
12
|
+
policies?: {
|
|
13
|
+
unserializablePolicy?: 'error' | 'warn-dynamic' | 'skip-procedure';
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export interface RpcDartConfig extends RpcDartTarget {
|
|
17
|
+
/** Multiple generation targets; each gets its own outDir. */
|
|
18
|
+
projects?: RpcDartTarget[];
|
|
19
|
+
}
|
|
20
|
+
export type RpcDartConfigFile = 'rpc-dart.config.json' | 'rpc-dart.config.ts';
|
|
21
|
+
export declare function defineConfig(config: RpcDartConfig): RpcDartConfig;
|
|
22
|
+
/** Raised when no usable configuration can be discovered. Exit code 2. */
|
|
23
|
+
export declare class ConfigNotFoundError extends Error {
|
|
24
|
+
readonly searchDir: string;
|
|
25
|
+
constructor(searchDir: string);
|
|
26
|
+
}
|
|
27
|
+
export declare function findConfigFile(searchDir: string, explicit?: string): string | null;
|
|
28
|
+
export declare function loadConfigFileAsync(configPath: string): Promise<RpcDartConfig>;
|
|
29
|
+
/**
|
|
30
|
+
* Resolves the concrete generation targets from CLI flags + config file.
|
|
31
|
+
* Explicit flags override config values. Supports multi-project configs.
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveTargets(searchDir: string, flags: {
|
|
34
|
+
config?: string;
|
|
35
|
+
tsconfig?: string;
|
|
36
|
+
entry?: string;
|
|
37
|
+
exportName?: string;
|
|
38
|
+
out?: string;
|
|
39
|
+
transformer?: string;
|
|
40
|
+
}): Promise<Array<RpcDartTarget & {
|
|
41
|
+
tsconfigPath: string;
|
|
42
|
+
}>>;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { createJiti } from 'jiti';
|
|
3
|
+
export function defineConfig(config) {
|
|
4
|
+
return config;
|
|
5
|
+
}
|
|
6
|
+
/** Raised when no usable configuration can be discovered. Exit code 2. */
|
|
7
|
+
export class ConfigNotFoundError extends Error {
|
|
8
|
+
searchDir;
|
|
9
|
+
constructor(searchDir) {
|
|
10
|
+
super(`no rpc-dart.config.json found in ${searchDir} (or any flags provided).\n` +
|
|
11
|
+
' Run `npx rpc-dart init` to create one, or pass --config / --tsconfig / --entry / --export / --out.');
|
|
12
|
+
this.searchDir = searchDir;
|
|
13
|
+
this.name = 'ConfigNotFoundError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function findConfigFile(searchDir, explicit) {
|
|
17
|
+
if (explicit) {
|
|
18
|
+
const resolved = path.resolve(searchDir, explicit);
|
|
19
|
+
return existsSync(resolved) ? resolved : null;
|
|
20
|
+
}
|
|
21
|
+
for (const name of ['rpc-dart.config.json', 'rpc-dart.config.ts']) {
|
|
22
|
+
const candidate = path.join(searchDir, name);
|
|
23
|
+
if (existsSync(candidate))
|
|
24
|
+
return candidate;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export async function loadConfigFileAsync(configPath) {
|
|
29
|
+
if (configPath.endsWith('.json')) {
|
|
30
|
+
return JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
31
|
+
}
|
|
32
|
+
const jiti = createJiti(configPath, { fsCache: false, moduleCache: false });
|
|
33
|
+
const mod = (await jiti.import(configPath));
|
|
34
|
+
if (!mod.default) {
|
|
35
|
+
throw new Error(`${configPath} must default-export a defineConfig() value`);
|
|
36
|
+
}
|
|
37
|
+
return mod.default;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolves the concrete generation targets from CLI flags + config file.
|
|
41
|
+
* Explicit flags override config values. Supports multi-project configs.
|
|
42
|
+
*/
|
|
43
|
+
export async function resolveTargets(searchDir, flags) {
|
|
44
|
+
const configPath = findConfigFile(searchDir, flags.config);
|
|
45
|
+
let config = null;
|
|
46
|
+
if (configPath) {
|
|
47
|
+
config = await loadConfigFileAsync(configPath);
|
|
48
|
+
}
|
|
49
|
+
// Flags-only mode when no config exists. Entry resolves against the
|
|
50
|
+
// tsconfig's directory (matching extraction semantics).
|
|
51
|
+
if (!config) {
|
|
52
|
+
if (flags.tsconfig && flags.entry && flags.exportName && flags.out) {
|
|
53
|
+
const tsconfigPath = path.resolve(searchDir, flags.tsconfig);
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
entry: path.resolve(path.dirname(tsconfigPath), flags.entry),
|
|
57
|
+
exportName: flags.exportName,
|
|
58
|
+
transformer: flags.transformer ?? 'superjson',
|
|
59
|
+
dart: { outDir: flags.out },
|
|
60
|
+
tsconfigPath,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
throw new ConfigNotFoundError(searchDir);
|
|
65
|
+
}
|
|
66
|
+
const configDir = path.dirname(configPath);
|
|
67
|
+
const projects = config.projects && config.projects.length > 0 ? config.projects : [config];
|
|
68
|
+
return projects.map((project) => ({
|
|
69
|
+
...project,
|
|
70
|
+
entry: path.resolve(configDir, flags.entry ?? project.entry),
|
|
71
|
+
exportName: flags.exportName ?? project.exportName,
|
|
72
|
+
transformer: flags.transformer ?? project.transformer,
|
|
73
|
+
dart: {
|
|
74
|
+
...project.dart,
|
|
75
|
+
outDir: flags.out ?? project.dart?.outDir ?? 'lib/src/api',
|
|
76
|
+
},
|
|
77
|
+
policies: project.policies,
|
|
78
|
+
tsconfigPath: path.resolve(path.dirname(configPath ?? searchDir), flags.tsconfig ?? project.tsconfig ?? 'tsconfig.json'),
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
import path from 'node:path';
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { generate, type GenerateOptions, type GenerateResult } from './commands/generate.js';
|
|
2
|
+
export { checkContract, type CheckResult } from './commands/check.js';
|
|
3
|
+
export { diffContract, diffIr, type DiffEntry, type DiffOptions, type DiffResult, } from './commands/diff.js';
|
|
4
|
+
export { cleanGenerated, type CleanResult } from './commands/clean.js';
|
|
5
|
+
export { doctorChecks, type DoctorCheck, type DoctorOptions } from './commands/doctor.js';
|
|
6
|
+
export { initWorkspace, type InitOptions, type InitResult } from './commands/init.js';
|
|
7
|
+
export { WatchSession, type WatchCycle, type WatchSessionOptions } from './commands/watch.js';
|
|
8
|
+
export { ConfigNotFoundError, defineConfig, findConfigFile, loadConfigFileAsync as loadConfig, resolveTargets, type RpcDartConfig, type RpcDartTarget, } from './config.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { generate } from './commands/generate.js';
|
|
2
|
+
export { checkContract } from './commands/check.js';
|
|
3
|
+
export { diffContract, diffIr, } from './commands/diff.js';
|
|
4
|
+
export { cleanGenerated } from './commands/clean.js';
|
|
5
|
+
export { doctorChecks } from './commands/doctor.js';
|
|
6
|
+
export { initWorkspace } from './commands/init.js';
|
|
7
|
+
export { WatchSession } from './commands/watch.js';
|
|
8
|
+
export { ConfigNotFoundError, defineConfig, findConfigFile, loadConfigFileAsync as loadConfig, resolveTargets, } from './config.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rpc-dart",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "TypeScript-to-Dart bindings generator for tRPC v11 backends.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"rpc-dart": "bin/rpc-dart.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"bin"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@rpc-dart/core": "0.1.2",
|
|
17
|
+
"@rpc-dart/trpc-adapter": "0.1.2",
|
|
18
|
+
"@rpc-dart/dart-generator": "0.1.2",
|
|
19
|
+
"jiti": "^2.6.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.9.0",
|
|
23
|
+
"vitest": "^3.2.0",
|
|
24
|
+
"@types/node": "^24.0.0"
|
|
25
|
+
},
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/AdityaJandu/rpc-dart.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/AdityaJandu/rpc-dart#readme",
|
|
32
|
+
"keywords": [
|
|
33
|
+
"trpc",
|
|
34
|
+
"dart",
|
|
35
|
+
"flutter",
|
|
36
|
+
"cli",
|
|
37
|
+
"codegen"
|
|
38
|
+
],
|
|
39
|
+
"scripts": {
|
|
40
|
+
"build": "tsc -p tsconfig.build.json",
|
|
41
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
42
|
+
"test": "vitest run --passWithNoTests"
|
|
43
|
+
}
|
|
6
44
|
}
|