@swimmesberger/elarion-jsonrpc-client-generator 0.1.0-preview.10.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 +58 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +84 -0
- package/dist/generate.d.ts +4 -0
- package/dist/generate.js +75 -0
- package/dist/json-schema-to-ts.d.ts +3 -0
- package/dist/json-schema-to-ts.js +38 -0
- package/dist/json-schema-to-zod.d.ts +3 -0
- package/dist/json-schema-to-zod.js +50 -0
- package/dist/json-schema.d.ts +16 -0
- package/dist/json-schema.js +96 -0
- package/dist/rpc-client-source.d.ts +9 -0
- package/dist/rpc-client-source.js +465 -0
- package/dist/schema.d.ts +36 -0
- package/dist/schema.js +1 -0
- package/package.json +47 -0
package/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# @swimmesberger/elarion-jsonrpc-client-generator
|
|
2
|
+
|
|
3
|
+
Generate TypeScript RPC method contracts, Zod result schemas, and a portable fetch-based JSON-RPC client from an Elarion `rpc-schema.json` export.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install --save-dev @swimmesberger/elarion-jsonrpc-client-generator
|
|
7
|
+
npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
The generated files are:
|
|
11
|
+
|
|
12
|
+
| File | Purpose |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `rpc-types.ts` | `RpcMethods` interface mapping method names to params/result types. |
|
|
15
|
+
| `rpc-schemas.ts` | `rpcResultSchemas` Zod map for runtime result validation. |
|
|
16
|
+
| `rpc-client.ts` | Browser/Node.js fetch client with typed single calls, batching, headers, `AbortSignal`, JSON-RPC errors, and Zod-backed result validation. |
|
|
17
|
+
|
|
18
|
+
The generated schema and client files import `zod`, so consuming applications should install `zod` as a runtime dependency.
|
|
19
|
+
|
|
20
|
+
## Generated API client
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { createRpcApi } from './generated/rpc-client'
|
|
24
|
+
|
|
25
|
+
const rpc = createRpcApi({
|
|
26
|
+
url: '/rpc',
|
|
27
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
const abort = new AbortController()
|
|
31
|
+
const client = await rpc.clients.get({ id: clientId }, { signal: abort.signal })
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The generated API mirrors dotted JSON-RPC method names as nested properties, so `clients.get` becomes `rpc.clients.get(...)`. The file also exports the lower-level `createRpcClient(...)` generic transport for advanced cases.
|
|
35
|
+
|
|
36
|
+
The API client uses `globalThis.fetch` in browsers and modern Node.js. Pass `fetch` explicitly for tests, older Node.js runtimes, server-function forwarding, or framework-specific transport wrappers:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
const rpc = createRpcApi({
|
|
40
|
+
url: process.env.API_INTERNAL_URL + '/rpc',
|
|
41
|
+
fetch,
|
|
42
|
+
headers: async ({ batch, methods }) => ({
|
|
43
|
+
'X-RPC-Batch': String(batch),
|
|
44
|
+
'X-RPC-Methods': methods.join(','),
|
|
45
|
+
}),
|
|
46
|
+
})
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Batching uses generated request builders, so params and results stay tied to each RPC method. Batch results preserve input order even when the server returns JSON-RPC responses out of order. Each item returns either `{ ok: true, result }` or `{ ok: false, error }`, so one method failure does not reject the whole batch:
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
const [clientResult, projectsResult] = await rpc.$batch([
|
|
53
|
+
rpc.$request.clients.get({ id: clientId }),
|
|
54
|
+
rpc.$request.projects.list({ clientId }),
|
|
55
|
+
] as const)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Result validation is enabled by default through `rpcResultSchemas`. Use `transformResult` for app-specific normalization before validation, or set `validateResults: false` when another layer validates responses.
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { generateRpcClientFiles } from './generate.js';
|
|
5
|
+
function parseArgs(argv) {
|
|
6
|
+
const options = {
|
|
7
|
+
schemaPath: 'rpc-schema.json',
|
|
8
|
+
outDir: 'src/generated',
|
|
9
|
+
};
|
|
10
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
11
|
+
const arg = argv[index];
|
|
12
|
+
const next = argv[index + 1];
|
|
13
|
+
if (arg === '--help' || arg === '-h') {
|
|
14
|
+
printHelp();
|
|
15
|
+
process.exit(0);
|
|
16
|
+
}
|
|
17
|
+
if (!next) {
|
|
18
|
+
throw new Error(`Missing value for ${arg}`);
|
|
19
|
+
}
|
|
20
|
+
if (arg === '--schema') {
|
|
21
|
+
options.schemaPath = next;
|
|
22
|
+
index += 1;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (arg === '--out') {
|
|
26
|
+
options.outDir = next;
|
|
27
|
+
index += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (arg === '--types') {
|
|
31
|
+
options.typesFileName = next;
|
|
32
|
+
index += 1;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (arg === '--schemas') {
|
|
36
|
+
options.schemasFileName = next;
|
|
37
|
+
index += 1;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (arg === '--client') {
|
|
41
|
+
options.clientFileName = next;
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (arg === '--source-label') {
|
|
46
|
+
options.sourceLabel = next;
|
|
47
|
+
index += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Unknown argument ${arg}`);
|
|
51
|
+
}
|
|
52
|
+
return options;
|
|
53
|
+
}
|
|
54
|
+
function printHelp() {
|
|
55
|
+
console.log(`Usage: elarion-jsonrpc-client-generator [options]
|
|
56
|
+
|
|
57
|
+
Options:
|
|
58
|
+
--schema <path> Path to rpc-schema.json (default: rpc-schema.json)
|
|
59
|
+
--out <dir> Output directory (default: src/generated)
|
|
60
|
+
--types <file> TypeScript types filename (default: rpc-types.ts)
|
|
61
|
+
--schemas <file> Zod schemas filename (default: rpc-schemas.ts)
|
|
62
|
+
--client <file> Fetch client filename (default: rpc-client.ts)
|
|
63
|
+
--source-label <text> Source label written into generated file headers
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
function main() {
|
|
67
|
+
const options = parseArgs(process.argv.slice(2));
|
|
68
|
+
const schemaPath = resolve(process.cwd(), options.schemaPath);
|
|
69
|
+
const outDir = resolve(process.cwd(), options.outDir);
|
|
70
|
+
const raw = readFileSync(schemaPath, 'utf-8');
|
|
71
|
+
const schema = JSON.parse(raw);
|
|
72
|
+
const generated = generateRpcClientFiles(schema, {
|
|
73
|
+
sourceLabel: options.sourceLabel ?? basename(schemaPath),
|
|
74
|
+
typesFileName: options.typesFileName,
|
|
75
|
+
schemasFileName: options.schemasFileName,
|
|
76
|
+
clientFileName: options.clientFileName,
|
|
77
|
+
});
|
|
78
|
+
mkdirSync(outDir, { recursive: true });
|
|
79
|
+
writeFileSync(resolve(outDir, generated.typesFileName), generated.typesSource, 'utf-8');
|
|
80
|
+
writeFileSync(resolve(outDir, generated.schemasFileName), generated.schemasSource, 'utf-8');
|
|
81
|
+
writeFileSync(resolve(outDir, generated.clientFileName), generated.clientSource, 'utf-8');
|
|
82
|
+
console.log(`[jsonrpc-client-generator] Generated ${generated.methodCount} RPC method types and schemas -> ${outDir}`);
|
|
83
|
+
}
|
|
84
|
+
main();
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GeneratedRpcClientFiles, GenerateRpcClientOptions, RpcSchema } from './schema.js';
|
|
2
|
+
export { UnsupportedJsonSchemaError } from './json-schema.js';
|
|
3
|
+
export type { GeneratedRpcClientFiles, GenerateRpcClientOptions, JsonSchema, RpcSchema } from './schema.js';
|
|
4
|
+
export declare function generateRpcClientFiles(schema: RpcSchema, options?: GenerateRpcClientOptions): GeneratedRpcClientFiles;
|
package/dist/generate.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { jsonSchemaToTypeScript } from './json-schema-to-ts.js';
|
|
2
|
+
import { jsonSchemaToZod } from './json-schema-to-zod.js';
|
|
3
|
+
import { stripNullable } from './json-schema.js';
|
|
4
|
+
import { generateRpcClientSource } from './rpc-client-source.js';
|
|
5
|
+
const DEFAULT_GENERATED_BY = 'elarion-jsonrpc-client-generator';
|
|
6
|
+
const DEFAULT_SOURCE_LABEL = 'rpc-schema.json';
|
|
7
|
+
const DEFAULT_TYPES_FILE = 'rpc-types.ts';
|
|
8
|
+
const DEFAULT_SCHEMAS_FILE = 'rpc-schemas.ts';
|
|
9
|
+
const DEFAULT_CLIENT_FILE = 'rpc-client.ts';
|
|
10
|
+
export { UnsupportedJsonSchemaError } from './json-schema.js';
|
|
11
|
+
export function generateRpcClientFiles(schema, options = {}) {
|
|
12
|
+
const generatedBy = options.generatedBy ?? DEFAULT_GENERATED_BY;
|
|
13
|
+
const sourceLabel = options.sourceLabel ?? DEFAULT_SOURCE_LABEL;
|
|
14
|
+
const typesFileName = options.typesFileName ?? DEFAULT_TYPES_FILE;
|
|
15
|
+
const schemasFileName = options.schemasFileName ?? DEFAULT_SCHEMAS_FILE;
|
|
16
|
+
const clientFileName = options.clientFileName ?? DEFAULT_CLIENT_FILE;
|
|
17
|
+
const methods = Object.keys(schema.methods).sort();
|
|
18
|
+
const typesLines = [
|
|
19
|
+
`// Auto-generated by ${generatedBy} — DO NOT EDIT`,
|
|
20
|
+
`// Source: ${sourceLabel}`,
|
|
21
|
+
'',
|
|
22
|
+
'export interface RpcMethods {',
|
|
23
|
+
];
|
|
24
|
+
for (const method of methods) {
|
|
25
|
+
const definition = schema.methods[method];
|
|
26
|
+
const paramsType = jsonSchemaToTypeScript(stripNullable(definition.params), createContext(definition.params, `methods.${method}.params`), 1);
|
|
27
|
+
const resultType = jsonSchemaToTypeScript(stripNullable(definition.result), createContext(definition.result, `methods.${method}.result`), 1);
|
|
28
|
+
typesLines.push(` ${JSON.stringify(method)}: {`);
|
|
29
|
+
typesLines.push(` params: ${paramsType}`);
|
|
30
|
+
typesLines.push(` result: ${resultType}`);
|
|
31
|
+
typesLines.push(' }');
|
|
32
|
+
}
|
|
33
|
+
typesLines.push('}');
|
|
34
|
+
typesLines.push('');
|
|
35
|
+
const schemasLines = [
|
|
36
|
+
`// Auto-generated by ${generatedBy} — DO NOT EDIT`,
|
|
37
|
+
`// Source: ${sourceLabel}`,
|
|
38
|
+
'',
|
|
39
|
+
"import { z } from 'zod'",
|
|
40
|
+
'',
|
|
41
|
+
'export const rpcResultSchemas = {',
|
|
42
|
+
];
|
|
43
|
+
for (const method of methods) {
|
|
44
|
+
const definition = schema.methods[method];
|
|
45
|
+
const zodSchema = jsonSchemaToZod(stripNullable(definition.result), createContext(definition.result, `methods.${method}.result`), 1);
|
|
46
|
+
schemasLines.push(` ${JSON.stringify(method)}: ${zodSchema},`);
|
|
47
|
+
}
|
|
48
|
+
schemasLines.push('} as const');
|
|
49
|
+
schemasLines.push('');
|
|
50
|
+
schemasLines.push('export type RpcResultSchemas = typeof rpcResultSchemas');
|
|
51
|
+
schemasLines.push('');
|
|
52
|
+
const clientSource = generateRpcClientSource({
|
|
53
|
+
generatedBy,
|
|
54
|
+
sourceLabel,
|
|
55
|
+
typesFileName,
|
|
56
|
+
schemasFileName,
|
|
57
|
+
methods,
|
|
58
|
+
});
|
|
59
|
+
return {
|
|
60
|
+
methodCount: methods.length,
|
|
61
|
+
typesFileName,
|
|
62
|
+
schemasFileName,
|
|
63
|
+
clientFileName,
|
|
64
|
+
typesSource: typesLines.join('\n'),
|
|
65
|
+
schemasSource: schemasLines.join('\n'),
|
|
66
|
+
clientSource,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function createContext(root, path) {
|
|
70
|
+
return {
|
|
71
|
+
root,
|
|
72
|
+
path,
|
|
73
|
+
resolvingRefs: new Set(),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { baseType, childContext, formatPropertyName, isNullable, resolveSchema, stripNullable, } from './json-schema.js';
|
|
2
|
+
export function jsonSchemaToTypeScript(schema, ctx, indent = 0) {
|
|
3
|
+
const resolved = resolveSchema(schema, ctx);
|
|
4
|
+
const pad = ' '.repeat(indent);
|
|
5
|
+
const nullable = isNullable(resolved);
|
|
6
|
+
const base = baseType(resolved);
|
|
7
|
+
if (resolved.enum) {
|
|
8
|
+
const values = resolved.enum
|
|
9
|
+
.filter((value) => value !== null)
|
|
10
|
+
.map((value) => JSON.stringify(value));
|
|
11
|
+
const union = values.length > 0 ? values.join(' | ') : 'never';
|
|
12
|
+
return nullable ? `(${union}) | null | undefined` : union;
|
|
13
|
+
}
|
|
14
|
+
if (base === 'string') {
|
|
15
|
+
return nullable ? 'string | null | undefined' : 'string';
|
|
16
|
+
}
|
|
17
|
+
if (base === 'number' || base === 'integer') {
|
|
18
|
+
return nullable ? 'number | null | undefined' : 'number';
|
|
19
|
+
}
|
|
20
|
+
if (base === 'boolean') {
|
|
21
|
+
return nullable ? 'boolean | null | undefined' : 'boolean';
|
|
22
|
+
}
|
|
23
|
+
if (base === 'array' && resolved.items) {
|
|
24
|
+
const itemType = jsonSchemaToTypeScript(stripNullable(resolved.items), childContext(ctx, 'items'), indent);
|
|
25
|
+
return nullable ? `(${itemType})[] | null | undefined` : `${itemType}[]`;
|
|
26
|
+
}
|
|
27
|
+
if (base === 'object' && resolved.properties) {
|
|
28
|
+
const required = new Set(resolved.required ?? []);
|
|
29
|
+
const lines = Object.entries(resolved.properties).map(([key, property]) => {
|
|
30
|
+
const optional = required.has(key) ? '' : '?';
|
|
31
|
+
const propertyType = jsonSchemaToTypeScript(property, childContext(ctx, `properties.${key}`), indent + 1);
|
|
32
|
+
return `${pad} ${formatPropertyName(key)}${optional}: ${propertyType}`;
|
|
33
|
+
});
|
|
34
|
+
const objectType = `{\n${lines.join('\n')}\n${pad}}`;
|
|
35
|
+
return nullable ? `${objectType} | null | undefined` : objectType;
|
|
36
|
+
}
|
|
37
|
+
return 'unknown';
|
|
38
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { baseType, childContext, formatPropertyName, isNullable, resolveSchema, stripNullable, } from './json-schema.js';
|
|
2
|
+
export function jsonSchemaToZod(schema, ctx, indent = 0) {
|
|
3
|
+
const resolved = resolveSchema(schema, ctx);
|
|
4
|
+
const nullable = isNullable(resolved);
|
|
5
|
+
const base = baseType(resolved);
|
|
6
|
+
if (resolved.enum) {
|
|
7
|
+
const values = resolved.enum.filter((value) => value !== null);
|
|
8
|
+
const allStrings = values.every((value) => typeof value === 'string');
|
|
9
|
+
let zodExpression;
|
|
10
|
+
if (allStrings && values.length > 0) {
|
|
11
|
+
zodExpression = `z.enum([${values.map((value) => JSON.stringify(value)).join(', ')}])`;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
const literals = values.map((value) => `z.literal(${JSON.stringify(value)})`);
|
|
15
|
+
zodExpression = literals.length === 1
|
|
16
|
+
? literals[0]
|
|
17
|
+
: `z.union([${literals.join(', ')}])`;
|
|
18
|
+
}
|
|
19
|
+
return nullish(zodExpression, nullable);
|
|
20
|
+
}
|
|
21
|
+
if (base === 'string') {
|
|
22
|
+
return nullish('z.string()', nullable);
|
|
23
|
+
}
|
|
24
|
+
if (base === 'number' || base === 'integer') {
|
|
25
|
+
return nullish('z.number()', nullable);
|
|
26
|
+
}
|
|
27
|
+
if (base === 'boolean') {
|
|
28
|
+
return nullish('z.boolean()', nullable);
|
|
29
|
+
}
|
|
30
|
+
if (base === 'array' && resolved.items) {
|
|
31
|
+
const itemSchema = jsonSchemaToZod(stripNullable(resolved.items), childContext(ctx, 'items'), indent);
|
|
32
|
+
return nullish(`z.array(${itemSchema})`, nullable);
|
|
33
|
+
}
|
|
34
|
+
if (base === 'object' && resolved.properties) {
|
|
35
|
+
const required = new Set(resolved.required ?? []);
|
|
36
|
+
const pad = ' '.repeat(indent + 1);
|
|
37
|
+
const fields = Object.entries(resolved.properties).map(([key, property]) => {
|
|
38
|
+
let fieldSchema = jsonSchemaToZod(property, childContext(ctx, `properties.${key}`), indent + 1);
|
|
39
|
+
if (!required.has(key)) {
|
|
40
|
+
fieldSchema += '.optional()';
|
|
41
|
+
}
|
|
42
|
+
return `${pad}${formatPropertyName(key)}: ${fieldSchema},`;
|
|
43
|
+
});
|
|
44
|
+
return nullish(`z.object({\n${fields.join('\n')}\n${' '.repeat(indent)}})`, nullable);
|
|
45
|
+
}
|
|
46
|
+
return 'z.unknown()';
|
|
47
|
+
}
|
|
48
|
+
function nullish(expression, nullable) {
|
|
49
|
+
return nullable ? `${expression}.nullish()` : expression;
|
|
50
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { JsonSchema } from './schema.js';
|
|
2
|
+
export declare class UnsupportedJsonSchemaError extends Error {
|
|
3
|
+
readonly schemaPath: string;
|
|
4
|
+
constructor(schemaPath: string, message: string);
|
|
5
|
+
}
|
|
6
|
+
export interface SchemaContext {
|
|
7
|
+
root: JsonSchema;
|
|
8
|
+
path: string;
|
|
9
|
+
resolvingRefs: ReadonlySet<string>;
|
|
10
|
+
}
|
|
11
|
+
export declare function isNullable(schema: JsonSchema): boolean;
|
|
12
|
+
export declare function baseType(schema: JsonSchema): string | undefined;
|
|
13
|
+
export declare function stripNullable(schema: JsonSchema): JsonSchema;
|
|
14
|
+
export declare function resolveSchema(schema: JsonSchema, ctx: SchemaContext): JsonSchema;
|
|
15
|
+
export declare function childContext(ctx: SchemaContext, segment: string): SchemaContext;
|
|
16
|
+
export declare function formatPropertyName(key: string): string;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export class UnsupportedJsonSchemaError extends Error {
|
|
2
|
+
schemaPath;
|
|
3
|
+
constructor(schemaPath, message) {
|
|
4
|
+
super(`${schemaPath}: ${message}`);
|
|
5
|
+
this.schemaPath = schemaPath;
|
|
6
|
+
this.name = 'UnsupportedJsonSchemaError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function isNullable(schema) {
|
|
10
|
+
if (Array.isArray(schema.type)) {
|
|
11
|
+
return schema.type.includes('null');
|
|
12
|
+
}
|
|
13
|
+
if (schema.enum) {
|
|
14
|
+
return schema.enum.includes(null);
|
|
15
|
+
}
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
export function baseType(schema) {
|
|
19
|
+
if (Array.isArray(schema.type)) {
|
|
20
|
+
return schema.type.find((type) => type !== 'null');
|
|
21
|
+
}
|
|
22
|
+
return schema.type;
|
|
23
|
+
}
|
|
24
|
+
export function stripNullable(schema) {
|
|
25
|
+
const copy = { ...schema };
|
|
26
|
+
if (Array.isArray(copy.type)) {
|
|
27
|
+
const nonNull = copy.type.filter((type) => type !== 'null');
|
|
28
|
+
copy.type = nonNull.length === 1 ? nonNull[0] : nonNull;
|
|
29
|
+
}
|
|
30
|
+
if (copy.enum) {
|
|
31
|
+
copy.enum = copy.enum.filter((value) => value !== null);
|
|
32
|
+
}
|
|
33
|
+
return copy;
|
|
34
|
+
}
|
|
35
|
+
export function resolveSchema(schema, ctx) {
|
|
36
|
+
assertSupportedComposition(schema, ctx.path);
|
|
37
|
+
if (!schema.$ref) {
|
|
38
|
+
return schema;
|
|
39
|
+
}
|
|
40
|
+
if (!schema.$ref.startsWith('#/')) {
|
|
41
|
+
throw new UnsupportedJsonSchemaError(ctx.path, `only local JSON Pointer $ref values are supported (${schema.$ref})`);
|
|
42
|
+
}
|
|
43
|
+
if (ctx.resolvingRefs.has(schema.$ref)) {
|
|
44
|
+
throw new UnsupportedJsonSchemaError(ctx.path, `cyclic $ref detected (${schema.$ref})`);
|
|
45
|
+
}
|
|
46
|
+
const target = resolveJsonPointer(ctx.root, schema.$ref, ctx.path);
|
|
47
|
+
return resolveSchema(target, {
|
|
48
|
+
root: ctx.root,
|
|
49
|
+
path: `${ctx.path}${schema.$ref}`,
|
|
50
|
+
resolvingRefs: new Set([...ctx.resolvingRefs, schema.$ref]),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
export function childContext(ctx, segment) {
|
|
54
|
+
return {
|
|
55
|
+
root: ctx.root,
|
|
56
|
+
path: `${ctx.path}.${segment}`,
|
|
57
|
+
resolvingRefs: ctx.resolvingRefs,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
export function formatPropertyName(key) {
|
|
61
|
+
if (/^[A-Za-z_$][\w$]*$/.test(key)) {
|
|
62
|
+
return key;
|
|
63
|
+
}
|
|
64
|
+
return JSON.stringify(key);
|
|
65
|
+
}
|
|
66
|
+
function assertSupportedComposition(schema, path) {
|
|
67
|
+
if (schema.oneOf) {
|
|
68
|
+
throw new UnsupportedJsonSchemaError(path, 'oneOf is not supported');
|
|
69
|
+
}
|
|
70
|
+
if (schema.anyOf) {
|
|
71
|
+
throw new UnsupportedJsonSchemaError(path, 'anyOf is not supported');
|
|
72
|
+
}
|
|
73
|
+
if (schema.allOf) {
|
|
74
|
+
throw new UnsupportedJsonSchemaError(path, 'allOf is not supported');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function resolveJsonPointer(root, ref, path) {
|
|
78
|
+
const segments = ref
|
|
79
|
+
.slice(2)
|
|
80
|
+
.split('/')
|
|
81
|
+
.map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
82
|
+
let current = root;
|
|
83
|
+
for (const segment of segments) {
|
|
84
|
+
if (!isObjectRecord(current) || !(segment in current)) {
|
|
85
|
+
throw new UnsupportedJsonSchemaError(path, `could not resolve $ref ${ref}`);
|
|
86
|
+
}
|
|
87
|
+
current = current[segment];
|
|
88
|
+
}
|
|
89
|
+
if (!isObjectRecord(current)) {
|
|
90
|
+
throw new UnsupportedJsonSchemaError(path, `$ref ${ref} does not point to a schema object`);
|
|
91
|
+
}
|
|
92
|
+
return current;
|
|
93
|
+
}
|
|
94
|
+
function isObjectRecord(value) {
|
|
95
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface GenerateRpcClientSourceOptions {
|
|
2
|
+
generatedBy: string;
|
|
3
|
+
sourceLabel: string;
|
|
4
|
+
typesFileName: string;
|
|
5
|
+
schemasFileName: string;
|
|
6
|
+
methods: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
export declare function generateRpcClientSource(options: GenerateRpcClientSourceOptions): string;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
export function generateRpcClientSource(options) {
|
|
2
|
+
const typesImport = moduleSpecifier(options.typesFileName);
|
|
3
|
+
const schemasImport = moduleSpecifier(options.schemasFileName);
|
|
4
|
+
const methodTree = buildMethodTree(options.methods);
|
|
5
|
+
return [
|
|
6
|
+
`// Auto-generated by ${options.generatedBy} — DO NOT EDIT`,
|
|
7
|
+
`// Source: ${options.sourceLabel}`,
|
|
8
|
+
'',
|
|
9
|
+
`import type { RpcMethods } from '${typesImport}'`,
|
|
10
|
+
`import { rpcResultSchemas } from '${schemasImport}'`,
|
|
11
|
+
'',
|
|
12
|
+
'export type RpcMethod = keyof RpcMethods & string',
|
|
13
|
+
"export type RpcParams<M extends RpcMethod> = RpcMethods[M]['params']",
|
|
14
|
+
"export type RpcResult<M extends RpcMethod> = RpcMethods[M]['result']",
|
|
15
|
+
'',
|
|
16
|
+
'export type RpcFetchInput = string | URL | Request',
|
|
17
|
+
'export type RpcHeadersInit = Headers | Record<string, string> | readonly (readonly [string, string])[]',
|
|
18
|
+
'export type RpcFetch = (input: RpcFetchInput, init?: RequestInit) => Promise<Response>',
|
|
19
|
+
'',
|
|
20
|
+
'export interface RpcRequestContext {',
|
|
21
|
+
' readonly methods: readonly RpcMethod[]',
|
|
22
|
+
' readonly batch: boolean',
|
|
23
|
+
'}',
|
|
24
|
+
'',
|
|
25
|
+
'export type RpcHeaders =',
|
|
26
|
+
' | RpcHeadersInit',
|
|
27
|
+
' | ((context: RpcRequestContext) => RpcHeadersInit | Promise<RpcHeadersInit>)',
|
|
28
|
+
'',
|
|
29
|
+
'export interface RpcClientOptions {',
|
|
30
|
+
' readonly url: string | URL',
|
|
31
|
+
' readonly fetch?: RpcFetch',
|
|
32
|
+
' readonly headers?: RpcHeaders',
|
|
33
|
+
' readonly idGenerator?: () => string | number',
|
|
34
|
+
' readonly validateResults?: boolean',
|
|
35
|
+
' readonly transformResult?: <M extends RpcMethod>(method: M, result: unknown) => unknown',
|
|
36
|
+
'}',
|
|
37
|
+
'',
|
|
38
|
+
'export interface RpcRequestOptions {',
|
|
39
|
+
' readonly signal?: AbortSignal',
|
|
40
|
+
' readonly headers?: RpcHeadersInit',
|
|
41
|
+
'}',
|
|
42
|
+
'',
|
|
43
|
+
'export interface BatchItem<M extends RpcMethod = RpcMethod> {',
|
|
44
|
+
' readonly method: M',
|
|
45
|
+
' readonly params: RpcParams<M>',
|
|
46
|
+
'}',
|
|
47
|
+
'',
|
|
48
|
+
'export type BatchItemResult<M extends RpcMethod = RpcMethod> =',
|
|
49
|
+
' | { readonly ok: true; readonly result: RpcResult<M> }',
|
|
50
|
+
' | { readonly ok: false; readonly error: RpcError }',
|
|
51
|
+
'',
|
|
52
|
+
'export type BatchResult<T extends readonly BatchItem[]> = {',
|
|
53
|
+
' readonly [K in keyof T]: T[K] extends BatchItem<infer M> ? BatchItemResult<M> : never',
|
|
54
|
+
'}',
|
|
55
|
+
'',
|
|
56
|
+
...generateApiTypeLines(methodTree),
|
|
57
|
+
'export interface RpcClient {',
|
|
58
|
+
' call<M extends RpcMethod>(',
|
|
59
|
+
' method: M,',
|
|
60
|
+
' params: RpcParams<M>,',
|
|
61
|
+
' options?: RpcRequestOptions',
|
|
62
|
+
' ): Promise<RpcResult<M>>',
|
|
63
|
+
'',
|
|
64
|
+
' batch<const T extends readonly BatchItem[]>(',
|
|
65
|
+
' items: T,',
|
|
66
|
+
' options?: RpcRequestOptions',
|
|
67
|
+
' ): Promise<BatchResult<T>>',
|
|
68
|
+
'}',
|
|
69
|
+
'',
|
|
70
|
+
...generateMethodNameLines(options.methods),
|
|
71
|
+
'type JsonRpcId = string | number',
|
|
72
|
+
'',
|
|
73
|
+
'interface JsonRpcRequestEnvelope<M extends RpcMethod = RpcMethod> {',
|
|
74
|
+
" readonly jsonrpc: '2.0'",
|
|
75
|
+
' readonly method: M',
|
|
76
|
+
' readonly params: RpcParams<M>',
|
|
77
|
+
' readonly id: JsonRpcId',
|
|
78
|
+
'}',
|
|
79
|
+
'',
|
|
80
|
+
'interface JsonRpcErrorObject {',
|
|
81
|
+
' readonly code: number',
|
|
82
|
+
' readonly message: string',
|
|
83
|
+
' readonly data?: unknown',
|
|
84
|
+
'}',
|
|
85
|
+
'',
|
|
86
|
+
'interface JsonRpcResponseEnvelope {',
|
|
87
|
+
' readonly id?: JsonRpcId | null',
|
|
88
|
+
' readonly result?: unknown',
|
|
89
|
+
' readonly error?: JsonRpcErrorObject',
|
|
90
|
+
'}',
|
|
91
|
+
'',
|
|
92
|
+
'export class RpcError extends Error {',
|
|
93
|
+
' constructor(',
|
|
94
|
+
' public readonly code: number,',
|
|
95
|
+
' message: string,',
|
|
96
|
+
' public readonly data?: unknown',
|
|
97
|
+
' ) {',
|
|
98
|
+
' super(message)',
|
|
99
|
+
" this.name = 'RpcError'",
|
|
100
|
+
' }',
|
|
101
|
+
'',
|
|
102
|
+
' get isParseError() {',
|
|
103
|
+
' return this.code === -32700',
|
|
104
|
+
' }',
|
|
105
|
+
'',
|
|
106
|
+
' get isInvalidRequest() {',
|
|
107
|
+
' return this.code === -32600',
|
|
108
|
+
' }',
|
|
109
|
+
'',
|
|
110
|
+
' get isMethodNotFound() {',
|
|
111
|
+
' return this.code === -32601',
|
|
112
|
+
' }',
|
|
113
|
+
'',
|
|
114
|
+
' get isInvalidParams() {',
|
|
115
|
+
' return this.code === -32602',
|
|
116
|
+
' }',
|
|
117
|
+
'',
|
|
118
|
+
' get isInternalError() {',
|
|
119
|
+
' return this.code === -32603',
|
|
120
|
+
' }',
|
|
121
|
+
'}',
|
|
122
|
+
'',
|
|
123
|
+
'export class RpcTransportError extends Error {',
|
|
124
|
+
' constructor(',
|
|
125
|
+
' public readonly status: number,',
|
|
126
|
+
' public readonly statusText: string,',
|
|
127
|
+
' public readonly body: string',
|
|
128
|
+
' ) {',
|
|
129
|
+
' super(`RPC transport error: ${status} ${statusText}`.trim())',
|
|
130
|
+
" this.name = 'RpcTransportError'",
|
|
131
|
+
' }',
|
|
132
|
+
'}',
|
|
133
|
+
'',
|
|
134
|
+
'export class RpcProtocolError extends Error {',
|
|
135
|
+
' constructor(message: string) {',
|
|
136
|
+
' super(message)',
|
|
137
|
+
" this.name = 'RpcProtocolError'",
|
|
138
|
+
' }',
|
|
139
|
+
'}',
|
|
140
|
+
'',
|
|
141
|
+
'export function createRpcApi(options: RpcClientOptions): RpcApi {',
|
|
142
|
+
' const client = createRpcClient(options)',
|
|
143
|
+
' const calls: Record<string, unknown> = {}',
|
|
144
|
+
' const requests: Record<string, unknown> = {}',
|
|
145
|
+
'',
|
|
146
|
+
' for (const method of rpcMethodNames) {',
|
|
147
|
+
' setApiPath(calls, method, (params: unknown, requestOptions?: RpcRequestOptions) =>',
|
|
148
|
+
' client.call(method, params as RpcParams<typeof method>, requestOptions))',
|
|
149
|
+
' setApiPath(requests, method, (params: unknown) => ({',
|
|
150
|
+
' method,',
|
|
151
|
+
' params: params as RpcParams<typeof method>,',
|
|
152
|
+
' }))',
|
|
153
|
+
' }',
|
|
154
|
+
'',
|
|
155
|
+
' return {',
|
|
156
|
+
' ...calls,',
|
|
157
|
+
' $client: client,',
|
|
158
|
+
' $batch: client.batch,',
|
|
159
|
+
' $request: requests,',
|
|
160
|
+
' } as unknown as RpcApi',
|
|
161
|
+
'}',
|
|
162
|
+
'',
|
|
163
|
+
'export function createRpcClient(options: RpcClientOptions): RpcClient {',
|
|
164
|
+
' const fetchImpl = options.fetch ?? globalThis.fetch',
|
|
165
|
+
' if (!fetchImpl) {',
|
|
166
|
+
" throw new Error('No fetch implementation is available. Pass fetch in createRpcClient options.')",
|
|
167
|
+
' }',
|
|
168
|
+
'',
|
|
169
|
+
' let nextId = 1',
|
|
170
|
+
' const createId = options.idGenerator ?? (() => globalThis.crypto?.randomUUID?.() ?? String(nextId++))',
|
|
171
|
+
' const validateResults = options.validateResults ?? true',
|
|
172
|
+
'',
|
|
173
|
+
' async function post(payload: unknown, context: RpcRequestContext, requestOptions?: RpcRequestOptions): Promise<unknown> {',
|
|
174
|
+
' const response = await fetchImpl(options.url, {',
|
|
175
|
+
" method: 'POST',",
|
|
176
|
+
' headers: await buildHeaders(options.headers, context, requestOptions?.headers),',
|
|
177
|
+
' body: JSON.stringify(payload),',
|
|
178
|
+
' signal: requestOptions?.signal,',
|
|
179
|
+
' })',
|
|
180
|
+
'',
|
|
181
|
+
' if (!response.ok) {',
|
|
182
|
+
' throw new RpcTransportError(response.status, response.statusText, await response.text())',
|
|
183
|
+
' }',
|
|
184
|
+
'',
|
|
185
|
+
' return response.json()',
|
|
186
|
+
' }',
|
|
187
|
+
'',
|
|
188
|
+
' function parseResult<M extends RpcMethod>(method: M, result: unknown): RpcResult<M> {',
|
|
189
|
+
' const transformed = options.transformResult?.(method, result) ?? result',
|
|
190
|
+
' if (!validateResults) {',
|
|
191
|
+
' return transformed as RpcResult<M>',
|
|
192
|
+
' }',
|
|
193
|
+
'',
|
|
194
|
+
' return rpcResultSchemas[method].parse(transformed) as RpcResult<M>',
|
|
195
|
+
' }',
|
|
196
|
+
'',
|
|
197
|
+
' return {',
|
|
198
|
+
' async call<M extends RpcMethod>(',
|
|
199
|
+
' method: M,',
|
|
200
|
+
' params: RpcParams<M>,',
|
|
201
|
+
' requestOptions?: RpcRequestOptions',
|
|
202
|
+
' ): Promise<RpcResult<M>> {',
|
|
203
|
+
' const request = createRequest(method, params, createId())',
|
|
204
|
+
' const raw = await post(request, { methods: [method], batch: false }, requestOptions)',
|
|
205
|
+
' const response = parseResponseEnvelope(raw)',
|
|
206
|
+
' if (!idsEqual(response.id, request.id)) {',
|
|
207
|
+
" throw new RpcProtocolError('JSON-RPC response id does not match request id.')",
|
|
208
|
+
' }',
|
|
209
|
+
'',
|
|
210
|
+
' if (response.error) {',
|
|
211
|
+
' throw toRpcError(response.error)',
|
|
212
|
+
' }',
|
|
213
|
+
'',
|
|
214
|
+
' return parseResult(method, response.result)',
|
|
215
|
+
' },',
|
|
216
|
+
'',
|
|
217
|
+
' async batch<const T extends readonly BatchItem[]>(',
|
|
218
|
+
' items: T,',
|
|
219
|
+
' requestOptions?: RpcRequestOptions',
|
|
220
|
+
' ): Promise<BatchResult<T>> {',
|
|
221
|
+
' if (items.length === 0) {',
|
|
222
|
+
' return [] as unknown as BatchResult<T>',
|
|
223
|
+
' }',
|
|
224
|
+
'',
|
|
225
|
+
' const requests = items.map((item) => createRequest(item.method, item.params, createId()))',
|
|
226
|
+
' const raw = await post(',
|
|
227
|
+
' requests,',
|
|
228
|
+
' { methods: items.map((item) => item.method), batch: true },',
|
|
229
|
+
' requestOptions',
|
|
230
|
+
' )',
|
|
231
|
+
'',
|
|
232
|
+
' if (!Array.isArray(raw)) {',
|
|
233
|
+
" throw new RpcProtocolError('Expected JSON-RPC batch response array.')",
|
|
234
|
+
' }',
|
|
235
|
+
'',
|
|
236
|
+
' const responsesById = new Map<string, JsonRpcResponseEnvelope>()',
|
|
237
|
+
' for (const item of raw) {',
|
|
238
|
+
' const response = parseResponseEnvelope(item)',
|
|
239
|
+
' if (response.id === undefined || response.id === null) {',
|
|
240
|
+
" throw new RpcProtocolError('JSON-RPC batch response item is missing an id.')",
|
|
241
|
+
' }',
|
|
242
|
+
' const responseKey = idKey(response.id)',
|
|
243
|
+
' if (responsesById.has(responseKey)) {',
|
|
244
|
+
" throw new RpcProtocolError('JSON-RPC batch response contains a duplicate id.')",
|
|
245
|
+
' }',
|
|
246
|
+
' responsesById.set(responseKey, response)',
|
|
247
|
+
' }',
|
|
248
|
+
'',
|
|
249
|
+
' return requests.map((request, index) => {',
|
|
250
|
+
' const method = items[index].method',
|
|
251
|
+
' const response = responsesById.get(idKey(request.id))',
|
|
252
|
+
' if (!response) {',
|
|
253
|
+
" return { ok: false, error: new RpcError(-32603, 'Missing JSON-RPC batch response item.') }",
|
|
254
|
+
' }',
|
|
255
|
+
'',
|
|
256
|
+
' if (response.error) {',
|
|
257
|
+
' return { ok: false, error: toRpcError(response.error) }',
|
|
258
|
+
' }',
|
|
259
|
+
'',
|
|
260
|
+
' try {',
|
|
261
|
+
' return { ok: true, result: parseResult(method, response.result) }',
|
|
262
|
+
' } catch (error) {',
|
|
263
|
+
" return { ok: false, error: new RpcError(-32603, 'Invalid RPC result.', error) }",
|
|
264
|
+
' }',
|
|
265
|
+
' }) as BatchResult<T>',
|
|
266
|
+
' },',
|
|
267
|
+
' }',
|
|
268
|
+
'}',
|
|
269
|
+
'',
|
|
270
|
+
'function createRequest<M extends RpcMethod>(',
|
|
271
|
+
' method: M,',
|
|
272
|
+
' params: RpcParams<M>,',
|
|
273
|
+
' id: JsonRpcId',
|
|
274
|
+
'): JsonRpcRequestEnvelope<M> {',
|
|
275
|
+
" return { jsonrpc: '2.0', method, params, id }",
|
|
276
|
+
'}',
|
|
277
|
+
'',
|
|
278
|
+
'function setApiPath(root: Record<string, unknown>, method: string, value: unknown): void {',
|
|
279
|
+
" const segments = method.split('.')",
|
|
280
|
+
' let node = root',
|
|
281
|
+
'',
|
|
282
|
+
' for (let index = 0; index < segments.length - 1; index += 1) {',
|
|
283
|
+
' const segment = segments[index]',
|
|
284
|
+
' const existing = node[segment]',
|
|
285
|
+
' if (isObjectLike(existing)) {',
|
|
286
|
+
' node = existing',
|
|
287
|
+
' continue',
|
|
288
|
+
' }',
|
|
289
|
+
'',
|
|
290
|
+
' const next: Record<string, unknown> = {}',
|
|
291
|
+
' node[segment] = next',
|
|
292
|
+
' node = next',
|
|
293
|
+
' }',
|
|
294
|
+
'',
|
|
295
|
+
' node[segments[segments.length - 1] ?? method] = value',
|
|
296
|
+
'}',
|
|
297
|
+
'',
|
|
298
|
+
'async function buildHeaders(',
|
|
299
|
+
' baseHeaders: RpcHeaders | undefined,',
|
|
300
|
+
' context: RpcRequestContext,',
|
|
301
|
+
' requestHeaders: RpcHeadersInit | undefined',
|
|
302
|
+
'): Promise<Headers> {',
|
|
303
|
+
' const headers = new Headers()',
|
|
304
|
+
" headers.set('Content-Type', 'application/json')",
|
|
305
|
+
'',
|
|
306
|
+
' if (baseHeaders) {',
|
|
307
|
+
' mergeHeaders(',
|
|
308
|
+
' headers,',
|
|
309
|
+
" typeof baseHeaders === 'function' ? await baseHeaders(context) : baseHeaders",
|
|
310
|
+
' )',
|
|
311
|
+
' }',
|
|
312
|
+
'',
|
|
313
|
+
' if (requestHeaders) {',
|
|
314
|
+
' mergeHeaders(headers, requestHeaders)',
|
|
315
|
+
' }',
|
|
316
|
+
'',
|
|
317
|
+
' return headers',
|
|
318
|
+
'}',
|
|
319
|
+
'',
|
|
320
|
+
'function mergeHeaders(target: Headers, source: RpcHeadersInit): void {',
|
|
321
|
+
' if (source instanceof Headers) {',
|
|
322
|
+
' source.forEach((value, key) => {',
|
|
323
|
+
' target.set(key, value)',
|
|
324
|
+
' })',
|
|
325
|
+
' return',
|
|
326
|
+
' }',
|
|
327
|
+
'',
|
|
328
|
+
' if (Array.isArray(source)) {',
|
|
329
|
+
' for (const [key, value] of source) {',
|
|
330
|
+
' target.set(key, value)',
|
|
331
|
+
' }',
|
|
332
|
+
' return',
|
|
333
|
+
' }',
|
|
334
|
+
'',
|
|
335
|
+
' for (const [key, value] of Object.entries(source)) {',
|
|
336
|
+
' target.set(key, value)',
|
|
337
|
+
' }',
|
|
338
|
+
'}',
|
|
339
|
+
'',
|
|
340
|
+
'function idKey(id: JsonRpcId): string {',
|
|
341
|
+
' return `${typeof id}:${String(id)}`',
|
|
342
|
+
'}',
|
|
343
|
+
'',
|
|
344
|
+
'function idsEqual(actual: JsonRpcId | null | undefined, expected: JsonRpcId): boolean {',
|
|
345
|
+
' return actual === expected && typeof actual === typeof expected',
|
|
346
|
+
'}',
|
|
347
|
+
'',
|
|
348
|
+
'function parseResponseEnvelope(value: unknown): JsonRpcResponseEnvelope {',
|
|
349
|
+
' if (!isRecord(value)) {',
|
|
350
|
+
" throw new RpcProtocolError('Expected JSON-RPC response object.')",
|
|
351
|
+
' }',
|
|
352
|
+
'',
|
|
353
|
+
' const error = value.error',
|
|
354
|
+
' if (error !== undefined && !isJsonRpcErrorObject(error)) {',
|
|
355
|
+
" throw new RpcProtocolError('Invalid JSON-RPC error object.')",
|
|
356
|
+
' }',
|
|
357
|
+
'',
|
|
358
|
+
' const id = value.id',
|
|
359
|
+
' if (id !== undefined && id !== null && typeof id !== ' + "'string'" + ' && typeof id !== ' + "'number'" + ') {',
|
|
360
|
+
" throw new RpcProtocolError('Invalid JSON-RPC response id.')",
|
|
361
|
+
' }',
|
|
362
|
+
'',
|
|
363
|
+
' return { id, result: value.result, error }',
|
|
364
|
+
'}',
|
|
365
|
+
'',
|
|
366
|
+
'function isJsonRpcErrorObject(value: unknown): value is JsonRpcErrorObject {',
|
|
367
|
+
' return (',
|
|
368
|
+
' isRecord(value) &&',
|
|
369
|
+
" typeof value.code === 'number' &&",
|
|
370
|
+
" typeof value.message === 'string'",
|
|
371
|
+
' )',
|
|
372
|
+
'}',
|
|
373
|
+
'',
|
|
374
|
+
'function toRpcError(error: JsonRpcErrorObject): RpcError {',
|
|
375
|
+
' return new RpcError(error.code, error.message, error.data)',
|
|
376
|
+
'}',
|
|
377
|
+
'',
|
|
378
|
+
'function isRecord(value: unknown): value is Record<string, unknown> {',
|
|
379
|
+
" return typeof value === 'object' && value !== null && !Array.isArray(value)",
|
|
380
|
+
'}',
|
|
381
|
+
'',
|
|
382
|
+
'function isObjectLike(value: unknown): value is Record<string, unknown> {',
|
|
383
|
+
" return (typeof value === 'object' && value !== null) || typeof value === 'function'",
|
|
384
|
+
'}',
|
|
385
|
+
'',
|
|
386
|
+
].join('\n');
|
|
387
|
+
}
|
|
388
|
+
function moduleSpecifier(fileName) {
|
|
389
|
+
const withoutExtension = fileName.replace(/[.][cm]?tsx?$/, '');
|
|
390
|
+
const relativePath = withoutExtension.startsWith('.')
|
|
391
|
+
? withoutExtension
|
|
392
|
+
: `./${withoutExtension}`;
|
|
393
|
+
return `${relativePath}.js`;
|
|
394
|
+
}
|
|
395
|
+
function buildMethodTree(methods) {
|
|
396
|
+
const root = createMethodTreeNode();
|
|
397
|
+
for (const method of methods) {
|
|
398
|
+
const segments = method.split('.');
|
|
399
|
+
let node = root;
|
|
400
|
+
for (const segment of segments) {
|
|
401
|
+
let child = node.children.get(segment);
|
|
402
|
+
if (!child) {
|
|
403
|
+
child = createMethodTreeNode();
|
|
404
|
+
node.children.set(segment, child);
|
|
405
|
+
}
|
|
406
|
+
node = child;
|
|
407
|
+
}
|
|
408
|
+
node.method = method;
|
|
409
|
+
}
|
|
410
|
+
return root;
|
|
411
|
+
}
|
|
412
|
+
function createMethodTreeNode() {
|
|
413
|
+
return { children: new Map() };
|
|
414
|
+
}
|
|
415
|
+
function generateApiTypeLines(methodTree) {
|
|
416
|
+
return [
|
|
417
|
+
'export type RpcEndpoint<M extends RpcMethod> = (',
|
|
418
|
+
' params: RpcParams<M>,',
|
|
419
|
+
' options?: RpcRequestOptions',
|
|
420
|
+
') => Promise<RpcResult<M>>',
|
|
421
|
+
'',
|
|
422
|
+
'export type RpcBatchRequest<M extends RpcMethod> = (params: RpcParams<M>) => BatchItem<M>',
|
|
423
|
+
'',
|
|
424
|
+
'export interface RpcApi {',
|
|
425
|
+
' readonly $client: RpcClient',
|
|
426
|
+
" readonly $batch: RpcClient['batch']",
|
|
427
|
+
' readonly $request: RpcRequestApi',
|
|
428
|
+
...emitProperties(methodTree, 'RpcEndpoint', 1),
|
|
429
|
+
'}',
|
|
430
|
+
'',
|
|
431
|
+
'export interface RpcRequestApi {',
|
|
432
|
+
...emitProperties(methodTree, 'RpcBatchRequest', 1),
|
|
433
|
+
'}',
|
|
434
|
+
'',
|
|
435
|
+
];
|
|
436
|
+
}
|
|
437
|
+
function emitProperties(node, endpointType, indent) {
|
|
438
|
+
const pad = ' '.repeat(indent);
|
|
439
|
+
return Array.from(node.children.entries())
|
|
440
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
441
|
+
.map(([segment, child]) => {
|
|
442
|
+
return `${pad}readonly ${JSON.stringify(segment)}: ${emitNodeType(child, endpointType, indent)}`;
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
function emitNodeType(node, endpointType, indent) {
|
|
446
|
+
const childLines = emitProperties(node, endpointType, indent + 1);
|
|
447
|
+
const childObject = childLines.length === 0
|
|
448
|
+
? '{}'
|
|
449
|
+
: `{\n${childLines.join('\n')}\n${' '.repeat(indent)}}`;
|
|
450
|
+
if (node.method && childLines.length > 0) {
|
|
451
|
+
return `${endpointType}<${JSON.stringify(node.method)}> & ${childObject}`;
|
|
452
|
+
}
|
|
453
|
+
if (node.method) {
|
|
454
|
+
return `${endpointType}<${JSON.stringify(node.method)}>`;
|
|
455
|
+
}
|
|
456
|
+
return childObject;
|
|
457
|
+
}
|
|
458
|
+
function generateMethodNameLines(methods) {
|
|
459
|
+
return [
|
|
460
|
+
'const rpcMethodNames = [',
|
|
461
|
+
...methods.map((method) => ` ${JSON.stringify(method)},`),
|
|
462
|
+
'] as const satisfies readonly RpcMethod[]',
|
|
463
|
+
'',
|
|
464
|
+
];
|
|
465
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface JsonSchema {
|
|
2
|
+
$ref?: string;
|
|
3
|
+
type?: string | string[];
|
|
4
|
+
properties?: Record<string, JsonSchema>;
|
|
5
|
+
required?: string[];
|
|
6
|
+
items?: JsonSchema;
|
|
7
|
+
enum?: Array<string | number | boolean | null>;
|
|
8
|
+
format?: string;
|
|
9
|
+
default?: unknown;
|
|
10
|
+
oneOf?: unknown[];
|
|
11
|
+
anyOf?: unknown[];
|
|
12
|
+
allOf?: unknown[];
|
|
13
|
+
}
|
|
14
|
+
export interface RpcMethodSchema {
|
|
15
|
+
params: JsonSchema;
|
|
16
|
+
result: JsonSchema;
|
|
17
|
+
}
|
|
18
|
+
export interface RpcSchema {
|
|
19
|
+
methods: Record<string, RpcMethodSchema>;
|
|
20
|
+
}
|
|
21
|
+
export interface GenerateRpcClientOptions {
|
|
22
|
+
generatedBy?: string;
|
|
23
|
+
sourceLabel?: string;
|
|
24
|
+
typesFileName?: string;
|
|
25
|
+
schemasFileName?: string;
|
|
26
|
+
clientFileName?: string;
|
|
27
|
+
}
|
|
28
|
+
export interface GeneratedRpcClientFiles {
|
|
29
|
+
methodCount: number;
|
|
30
|
+
typesFileName: string;
|
|
31
|
+
schemasFileName: string;
|
|
32
|
+
clientFileName: string;
|
|
33
|
+
typesSource: string;
|
|
34
|
+
schemasSource: string;
|
|
35
|
+
clientSource: string;
|
|
36
|
+
}
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@swimmesberger/elarion-jsonrpc-client-generator",
|
|
3
|
+
"version": "0.1.0-preview.10.1",
|
|
4
|
+
"description": "Generate TypeScript RPC contracts, Zod schemas, and a fetch client from Elarion JSON-RPC schema exports.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"elarion-jsonrpc-client-generator": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/generate.d.ts",
|
|
13
|
+
"import": "./dist/generate.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
23
|
+
"test": "vitest run",
|
|
24
|
+
"prepack": "npm run build"
|
|
25
|
+
},
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/swimmesberger/Elarion.git",
|
|
29
|
+
"directory": "src/elarion-jsonrpc-client-generator"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/swimmesberger/Elarion/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/swimmesberger/Elarion#readme",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public",
|
|
37
|
+
"registry": "https://registry.npmjs.org/"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20.11"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^25.3.3",
|
|
44
|
+
"typescript": "^5.9.3",
|
|
45
|
+
"vitest": "^4.0.18"
|
|
46
|
+
}
|
|
47
|
+
}
|