@swimmesberger/elarion-jsonrpc-client-generator 0.1.0-preview.9.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -2
- package/dist/cli.js +8 -0
- package/dist/generate.js +12 -0
- package/dist/rpc-client-source.d.ts +9 -0
- package/dist/rpc-client-source.js +507 -0
- package/dist/schema.d.ts +3 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @swimmesberger/elarion-jsonrpc-client-generator
|
|
2
2
|
|
|
3
|
-
Generate TypeScript RPC method contracts
|
|
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
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm install --save-dev @swimmesberger/elarion-jsonrpc-client-generator
|
|
@@ -13,5 +13,81 @@ The generated files are:
|
|
|
13
13
|
| --- | --- |
|
|
14
14
|
| `rpc-types.ts` | `RpcMethods` interface mapping method names to params/result types. |
|
|
15
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. |
|
|
16
17
|
|
|
17
|
-
The generated schema
|
|
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.
|
|
59
|
+
|
|
60
|
+
## Client-side tracing
|
|
61
|
+
|
|
62
|
+
The generated client never imports an OpenTelemetry SDK. Instead it exposes an optional `instrumentation` hook so client-side tracing stays a host decision and adds zero dependencies. The client calls `startSpan` once per request (and once per batch), reads the returned span's `headers` to inject trace context into the outgoing request, then calls `setError`/`end` as the request settles:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
interface RpcInstrumentation {
|
|
66
|
+
startSpan(context: { methods: readonly string[]; batch: boolean }): RpcClientSpan | undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface RpcClientSpan {
|
|
70
|
+
readonly headers?: HeadersInit // e.g. { traceparent } — merged in last, so it stays authoritative
|
|
71
|
+
setError(error: unknown): void
|
|
72
|
+
end(): void
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Minimal, dependency-free W3C context propagation (continues the server trace; ASP.NET Core reads `traceparent` automatically):
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
const rpc = createRpcApi({
|
|
80
|
+
url: '/rpc',
|
|
81
|
+
instrumentation: {
|
|
82
|
+
startSpan() {
|
|
83
|
+
const traceId = crypto.getRandomValues(new Uint8Array(16))
|
|
84
|
+
const spanId = crypto.getRandomValues(new Uint8Array(8))
|
|
85
|
+
const hex = (bytes: Uint8Array) => [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
86
|
+
const traceparent = `00-${hex(traceId)}-${hex(spanId)}-01`
|
|
87
|
+
return { headers: { traceparent }, setError() {}, end() {} }
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Hosts that already run `@opentelemetry/api` pass a small adapter that starts a real `CLIENT` span and injects context via the API's propagator — still no SDK in the generated client. Per-item application errors in a batch are returned as data (`{ ok: false, error }`), so the batch span ends without `setError`; only transport/protocol failures mark the span as errored.
|
package/dist/cli.js
CHANGED
|
@@ -37,6 +37,11 @@ function parseArgs(argv) {
|
|
|
37
37
|
index += 1;
|
|
38
38
|
continue;
|
|
39
39
|
}
|
|
40
|
+
if (arg === '--client') {
|
|
41
|
+
options.clientFileName = next;
|
|
42
|
+
index += 1;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
40
45
|
if (arg === '--source-label') {
|
|
41
46
|
options.sourceLabel = next;
|
|
42
47
|
index += 1;
|
|
@@ -54,6 +59,7 @@ Options:
|
|
|
54
59
|
--out <dir> Output directory (default: src/generated)
|
|
55
60
|
--types <file> TypeScript types filename (default: rpc-types.ts)
|
|
56
61
|
--schemas <file> Zod schemas filename (default: rpc-schemas.ts)
|
|
62
|
+
--client <file> Fetch client filename (default: rpc-client.ts)
|
|
57
63
|
--source-label <text> Source label written into generated file headers
|
|
58
64
|
`);
|
|
59
65
|
}
|
|
@@ -67,10 +73,12 @@ function main() {
|
|
|
67
73
|
sourceLabel: options.sourceLabel ?? basename(schemaPath),
|
|
68
74
|
typesFileName: options.typesFileName,
|
|
69
75
|
schemasFileName: options.schemasFileName,
|
|
76
|
+
clientFileName: options.clientFileName,
|
|
70
77
|
});
|
|
71
78
|
mkdirSync(outDir, { recursive: true });
|
|
72
79
|
writeFileSync(resolve(outDir, generated.typesFileName), generated.typesSource, 'utf-8');
|
|
73
80
|
writeFileSync(resolve(outDir, generated.schemasFileName), generated.schemasSource, 'utf-8');
|
|
81
|
+
writeFileSync(resolve(outDir, generated.clientFileName), generated.clientSource, 'utf-8');
|
|
74
82
|
console.log(`[jsonrpc-client-generator] Generated ${generated.methodCount} RPC method types and schemas -> ${outDir}`);
|
|
75
83
|
}
|
|
76
84
|
main();
|
package/dist/generate.js
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { jsonSchemaToTypeScript } from './json-schema-to-ts.js';
|
|
2
2
|
import { jsonSchemaToZod } from './json-schema-to-zod.js';
|
|
3
3
|
import { stripNullable } from './json-schema.js';
|
|
4
|
+
import { generateRpcClientSource } from './rpc-client-source.js';
|
|
4
5
|
const DEFAULT_GENERATED_BY = 'elarion-jsonrpc-client-generator';
|
|
5
6
|
const DEFAULT_SOURCE_LABEL = 'rpc-schema.json';
|
|
6
7
|
const DEFAULT_TYPES_FILE = 'rpc-types.ts';
|
|
7
8
|
const DEFAULT_SCHEMAS_FILE = 'rpc-schemas.ts';
|
|
9
|
+
const DEFAULT_CLIENT_FILE = 'rpc-client.ts';
|
|
8
10
|
export { UnsupportedJsonSchemaError } from './json-schema.js';
|
|
9
11
|
export function generateRpcClientFiles(schema, options = {}) {
|
|
10
12
|
const generatedBy = options.generatedBy ?? DEFAULT_GENERATED_BY;
|
|
11
13
|
const sourceLabel = options.sourceLabel ?? DEFAULT_SOURCE_LABEL;
|
|
12
14
|
const typesFileName = options.typesFileName ?? DEFAULT_TYPES_FILE;
|
|
13
15
|
const schemasFileName = options.schemasFileName ?? DEFAULT_SCHEMAS_FILE;
|
|
16
|
+
const clientFileName = options.clientFileName ?? DEFAULT_CLIENT_FILE;
|
|
14
17
|
const methods = Object.keys(schema.methods).sort();
|
|
15
18
|
const typesLines = [
|
|
16
19
|
`// Auto-generated by ${generatedBy} — DO NOT EDIT`,
|
|
@@ -46,12 +49,21 @@ export function generateRpcClientFiles(schema, options = {}) {
|
|
|
46
49
|
schemasLines.push('');
|
|
47
50
|
schemasLines.push('export type RpcResultSchemas = typeof rpcResultSchemas');
|
|
48
51
|
schemasLines.push('');
|
|
52
|
+
const clientSource = generateRpcClientSource({
|
|
53
|
+
generatedBy,
|
|
54
|
+
sourceLabel,
|
|
55
|
+
typesFileName,
|
|
56
|
+
schemasFileName,
|
|
57
|
+
methods,
|
|
58
|
+
});
|
|
49
59
|
return {
|
|
50
60
|
methodCount: methods.length,
|
|
51
61
|
typesFileName,
|
|
52
62
|
schemasFileName,
|
|
63
|
+
clientFileName,
|
|
53
64
|
typesSource: typesLines.join('\n'),
|
|
54
65
|
schemasSource: schemasLines.join('\n'),
|
|
66
|
+
clientSource,
|
|
55
67
|
};
|
|
56
68
|
}
|
|
57
69
|
function createContext(root, path) {
|
|
@@ -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,507 @@
|
|
|
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
|
+
'// Optional client-side tracing hook. Supply an adapter (for example over @opentelemetry/api, or a',
|
|
30
|
+
'// hand-rolled W3C `traceparent` generator) to start a span per request/batch, inject trace-context',
|
|
31
|
+
'// headers, and record the outcome. Zero-dependency: the generated client never imports a tracing SDK.',
|
|
32
|
+
'export interface RpcClientSpan {',
|
|
33
|
+
' // Headers injected into the outgoing request (e.g. `{ traceparent }`); read before the fetch.',
|
|
34
|
+
' readonly headers?: RpcHeadersInit',
|
|
35
|
+
' // Called with the thrown transport/protocol/RPC error before it propagates.',
|
|
36
|
+
' setError(error: unknown): void',
|
|
37
|
+
' // Always called once the request settles (success or failure).',
|
|
38
|
+
' end(): void',
|
|
39
|
+
'}',
|
|
40
|
+
'',
|
|
41
|
+
'export interface RpcInstrumentation {',
|
|
42
|
+
' startSpan(context: RpcRequestContext): RpcClientSpan | undefined',
|
|
43
|
+
'}',
|
|
44
|
+
'',
|
|
45
|
+
'export interface RpcClientOptions {',
|
|
46
|
+
' readonly url: string | URL',
|
|
47
|
+
' readonly fetch?: RpcFetch',
|
|
48
|
+
' readonly headers?: RpcHeaders',
|
|
49
|
+
' readonly idGenerator?: () => string | number',
|
|
50
|
+
' readonly validateResults?: boolean',
|
|
51
|
+
' readonly transformResult?: <M extends RpcMethod>(method: M, result: unknown) => unknown',
|
|
52
|
+
' readonly instrumentation?: RpcInstrumentation',
|
|
53
|
+
'}',
|
|
54
|
+
'',
|
|
55
|
+
'export interface RpcRequestOptions {',
|
|
56
|
+
' readonly signal?: AbortSignal',
|
|
57
|
+
' readonly headers?: RpcHeadersInit',
|
|
58
|
+
'}',
|
|
59
|
+
'',
|
|
60
|
+
'export interface BatchItem<M extends RpcMethod = RpcMethod> {',
|
|
61
|
+
' readonly method: M',
|
|
62
|
+
' readonly params: RpcParams<M>',
|
|
63
|
+
'}',
|
|
64
|
+
'',
|
|
65
|
+
'export type BatchItemResult<M extends RpcMethod = RpcMethod> =',
|
|
66
|
+
' | { readonly ok: true; readonly result: RpcResult<M> }',
|
|
67
|
+
' | { readonly ok: false; readonly error: RpcError }',
|
|
68
|
+
'',
|
|
69
|
+
'export type BatchResult<T extends readonly BatchItem[]> = {',
|
|
70
|
+
' readonly [K in keyof T]: T[K] extends BatchItem<infer M> ? BatchItemResult<M> : never',
|
|
71
|
+
'}',
|
|
72
|
+
'',
|
|
73
|
+
...generateApiTypeLines(methodTree),
|
|
74
|
+
'export interface RpcClient {',
|
|
75
|
+
' call<M extends RpcMethod>(',
|
|
76
|
+
' method: M,',
|
|
77
|
+
' params: RpcParams<M>,',
|
|
78
|
+
' options?: RpcRequestOptions',
|
|
79
|
+
' ): Promise<RpcResult<M>>',
|
|
80
|
+
'',
|
|
81
|
+
' batch<const T extends readonly BatchItem[]>(',
|
|
82
|
+
' items: T,',
|
|
83
|
+
' options?: RpcRequestOptions',
|
|
84
|
+
' ): Promise<BatchResult<T>>',
|
|
85
|
+
'}',
|
|
86
|
+
'',
|
|
87
|
+
...generateMethodNameLines(options.methods),
|
|
88
|
+
'type JsonRpcId = string | number',
|
|
89
|
+
'',
|
|
90
|
+
'interface JsonRpcRequestEnvelope<M extends RpcMethod = RpcMethod> {',
|
|
91
|
+
" readonly jsonrpc: '2.0'",
|
|
92
|
+
' readonly method: M',
|
|
93
|
+
' readonly params: RpcParams<M>',
|
|
94
|
+
' readonly id: JsonRpcId',
|
|
95
|
+
'}',
|
|
96
|
+
'',
|
|
97
|
+
'interface JsonRpcErrorObject {',
|
|
98
|
+
' readonly code: number',
|
|
99
|
+
' readonly message: string',
|
|
100
|
+
' readonly data?: unknown',
|
|
101
|
+
'}',
|
|
102
|
+
'',
|
|
103
|
+
'interface JsonRpcResponseEnvelope {',
|
|
104
|
+
' readonly id?: JsonRpcId | null',
|
|
105
|
+
' readonly result?: unknown',
|
|
106
|
+
' readonly error?: JsonRpcErrorObject',
|
|
107
|
+
'}',
|
|
108
|
+
'',
|
|
109
|
+
'export class RpcError extends Error {',
|
|
110
|
+
' constructor(',
|
|
111
|
+
' public readonly code: number,',
|
|
112
|
+
' message: string,',
|
|
113
|
+
' public readonly data?: unknown',
|
|
114
|
+
' ) {',
|
|
115
|
+
' super(message)',
|
|
116
|
+
" this.name = 'RpcError'",
|
|
117
|
+
' }',
|
|
118
|
+
'',
|
|
119
|
+
' get isParseError() {',
|
|
120
|
+
' return this.code === -32700',
|
|
121
|
+
' }',
|
|
122
|
+
'',
|
|
123
|
+
' get isInvalidRequest() {',
|
|
124
|
+
' return this.code === -32600',
|
|
125
|
+
' }',
|
|
126
|
+
'',
|
|
127
|
+
' get isMethodNotFound() {',
|
|
128
|
+
' return this.code === -32601',
|
|
129
|
+
' }',
|
|
130
|
+
'',
|
|
131
|
+
' get isInvalidParams() {',
|
|
132
|
+
' return this.code === -32602',
|
|
133
|
+
' }',
|
|
134
|
+
'',
|
|
135
|
+
' get isInternalError() {',
|
|
136
|
+
' return this.code === -32603',
|
|
137
|
+
' }',
|
|
138
|
+
'}',
|
|
139
|
+
'',
|
|
140
|
+
'export class RpcTransportError extends Error {',
|
|
141
|
+
' constructor(',
|
|
142
|
+
' public readonly status: number,',
|
|
143
|
+
' public readonly statusText: string,',
|
|
144
|
+
' public readonly body: string',
|
|
145
|
+
' ) {',
|
|
146
|
+
' super(`RPC transport error: ${status} ${statusText}`.trim())',
|
|
147
|
+
" this.name = 'RpcTransportError'",
|
|
148
|
+
' }',
|
|
149
|
+
'}',
|
|
150
|
+
'',
|
|
151
|
+
'export class RpcProtocolError extends Error {',
|
|
152
|
+
' constructor(message: string) {',
|
|
153
|
+
' super(message)',
|
|
154
|
+
" this.name = 'RpcProtocolError'",
|
|
155
|
+
' }',
|
|
156
|
+
'}',
|
|
157
|
+
'',
|
|
158
|
+
'export function createRpcApi(options: RpcClientOptions): RpcApi {',
|
|
159
|
+
' const client = createRpcClient(options)',
|
|
160
|
+
' const calls: Record<string, unknown> = {}',
|
|
161
|
+
' const requests: Record<string, unknown> = {}',
|
|
162
|
+
'',
|
|
163
|
+
' for (const method of rpcMethodNames) {',
|
|
164
|
+
' setApiPath(calls, method, (params: unknown, requestOptions?: RpcRequestOptions) =>',
|
|
165
|
+
' client.call(method, params as RpcParams<typeof method>, requestOptions))',
|
|
166
|
+
' setApiPath(requests, method, (params: unknown) => ({',
|
|
167
|
+
' method,',
|
|
168
|
+
' params: params as RpcParams<typeof method>,',
|
|
169
|
+
' }))',
|
|
170
|
+
' }',
|
|
171
|
+
'',
|
|
172
|
+
' return {',
|
|
173
|
+
' ...calls,',
|
|
174
|
+
' $client: client,',
|
|
175
|
+
' $batch: client.batch,',
|
|
176
|
+
' $request: requests,',
|
|
177
|
+
' } as unknown as RpcApi',
|
|
178
|
+
'}',
|
|
179
|
+
'',
|
|
180
|
+
'export function createRpcClient(options: RpcClientOptions): RpcClient {',
|
|
181
|
+
' const fetchImpl = options.fetch ?? globalThis.fetch',
|
|
182
|
+
' if (!fetchImpl) {',
|
|
183
|
+
" throw new Error('No fetch implementation is available. Pass fetch in createRpcClient options.')",
|
|
184
|
+
' }',
|
|
185
|
+
'',
|
|
186
|
+
' let nextId = 1',
|
|
187
|
+
' const createId = options.idGenerator ?? (() => globalThis.crypto?.randomUUID?.() ?? String(nextId++))',
|
|
188
|
+
' const validateResults = options.validateResults ?? true',
|
|
189
|
+
'',
|
|
190
|
+
' async function post(',
|
|
191
|
+
' payload: unknown,',
|
|
192
|
+
' context: RpcRequestContext,',
|
|
193
|
+
' requestOptions?: RpcRequestOptions,',
|
|
194
|
+
' span?: RpcClientSpan',
|
|
195
|
+
' ): Promise<unknown> {',
|
|
196
|
+
' const response = await fetchImpl(options.url, {',
|
|
197
|
+
" method: 'POST',",
|
|
198
|
+
' headers: await buildHeaders(options.headers, context, requestOptions?.headers, span?.headers),',
|
|
199
|
+
' body: JSON.stringify(payload),',
|
|
200
|
+
' signal: requestOptions?.signal,',
|
|
201
|
+
' })',
|
|
202
|
+
'',
|
|
203
|
+
' if (!response.ok) {',
|
|
204
|
+
' throw new RpcTransportError(response.status, response.statusText, await response.text())',
|
|
205
|
+
' }',
|
|
206
|
+
'',
|
|
207
|
+
' return response.json()',
|
|
208
|
+
' }',
|
|
209
|
+
'',
|
|
210
|
+
' function parseResult<M extends RpcMethod>(method: M, result: unknown): RpcResult<M> {',
|
|
211
|
+
' const transformed = options.transformResult?.(method, result) ?? result',
|
|
212
|
+
' if (!validateResults) {',
|
|
213
|
+
' return transformed as RpcResult<M>',
|
|
214
|
+
' }',
|
|
215
|
+
'',
|
|
216
|
+
' return rpcResultSchemas[method].parse(transformed) as RpcResult<M>',
|
|
217
|
+
' }',
|
|
218
|
+
'',
|
|
219
|
+
' return {',
|
|
220
|
+
' async call<M extends RpcMethod>(',
|
|
221
|
+
' method: M,',
|
|
222
|
+
' params: RpcParams<M>,',
|
|
223
|
+
' requestOptions?: RpcRequestOptions',
|
|
224
|
+
' ): Promise<RpcResult<M>> {',
|
|
225
|
+
' const request = createRequest(method, params, createId())',
|
|
226
|
+
' const context: RpcRequestContext = { methods: [method], batch: false }',
|
|
227
|
+
' const span = options.instrumentation?.startSpan(context)',
|
|
228
|
+
' try {',
|
|
229
|
+
' const raw = await post(request, context, requestOptions, span)',
|
|
230
|
+
' const response = parseResponseEnvelope(raw)',
|
|
231
|
+
' if (!idsEqual(response.id, request.id)) {',
|
|
232
|
+
" throw new RpcProtocolError('JSON-RPC response id does not match request id.')",
|
|
233
|
+
' }',
|
|
234
|
+
'',
|
|
235
|
+
' if (response.error) {',
|
|
236
|
+
' throw toRpcError(response.error)',
|
|
237
|
+
' }',
|
|
238
|
+
'',
|
|
239
|
+
' return parseResult(method, response.result)',
|
|
240
|
+
' } catch (error) {',
|
|
241
|
+
' span?.setError(error)',
|
|
242
|
+
' throw error',
|
|
243
|
+
' } finally {',
|
|
244
|
+
' span?.end()',
|
|
245
|
+
' }',
|
|
246
|
+
' },',
|
|
247
|
+
'',
|
|
248
|
+
' async batch<const T extends readonly BatchItem[]>(',
|
|
249
|
+
' items: T,',
|
|
250
|
+
' requestOptions?: RpcRequestOptions',
|
|
251
|
+
' ): Promise<BatchResult<T>> {',
|
|
252
|
+
' if (items.length === 0) {',
|
|
253
|
+
' return [] as unknown as BatchResult<T>',
|
|
254
|
+
' }',
|
|
255
|
+
'',
|
|
256
|
+
' const requests = items.map((item) => createRequest(item.method, item.params, createId()))',
|
|
257
|
+
' const context: RpcRequestContext = { methods: items.map((item) => item.method), batch: true }',
|
|
258
|
+
' const span = options.instrumentation?.startSpan(context)',
|
|
259
|
+
' try {',
|
|
260
|
+
' const raw = await post(requests, context, requestOptions, span)',
|
|
261
|
+
'',
|
|
262
|
+
' if (!Array.isArray(raw)) {',
|
|
263
|
+
" throw new RpcProtocolError('Expected JSON-RPC batch response array.')",
|
|
264
|
+
' }',
|
|
265
|
+
'',
|
|
266
|
+
' const responsesById = new Map<string, JsonRpcResponseEnvelope>()',
|
|
267
|
+
' for (const item of raw) {',
|
|
268
|
+
' const response = parseResponseEnvelope(item)',
|
|
269
|
+
' if (response.id === undefined || response.id === null) {',
|
|
270
|
+
" throw new RpcProtocolError('JSON-RPC batch response item is missing an id.')",
|
|
271
|
+
' }',
|
|
272
|
+
' const responseKey = idKey(response.id)',
|
|
273
|
+
' if (responsesById.has(responseKey)) {',
|
|
274
|
+
" throw new RpcProtocolError('JSON-RPC batch response contains a duplicate id.')",
|
|
275
|
+
' }',
|
|
276
|
+
' responsesById.set(responseKey, response)',
|
|
277
|
+
' }',
|
|
278
|
+
'',
|
|
279
|
+
' return requests.map((request, index) => {',
|
|
280
|
+
' const method = items[index].method',
|
|
281
|
+
' const response = responsesById.get(idKey(request.id))',
|
|
282
|
+
' if (!response) {',
|
|
283
|
+
" return { ok: false, error: new RpcError(-32603, 'Missing JSON-RPC batch response item.') }",
|
|
284
|
+
' }',
|
|
285
|
+
'',
|
|
286
|
+
' if (response.error) {',
|
|
287
|
+
' return { ok: false, error: toRpcError(response.error) }',
|
|
288
|
+
' }',
|
|
289
|
+
'',
|
|
290
|
+
' try {',
|
|
291
|
+
' return { ok: true, result: parseResult(method, response.result) }',
|
|
292
|
+
' } catch (error) {',
|
|
293
|
+
" return { ok: false, error: new RpcError(-32603, 'Invalid RPC result.', error) }",
|
|
294
|
+
' }',
|
|
295
|
+
' }) as BatchResult<T>',
|
|
296
|
+
' } catch (error) {',
|
|
297
|
+
' span?.setError(error)',
|
|
298
|
+
' throw error',
|
|
299
|
+
' } finally {',
|
|
300
|
+
' span?.end()',
|
|
301
|
+
' }',
|
|
302
|
+
' },',
|
|
303
|
+
' }',
|
|
304
|
+
'}',
|
|
305
|
+
'',
|
|
306
|
+
'function createRequest<M extends RpcMethod>(',
|
|
307
|
+
' method: M,',
|
|
308
|
+
' params: RpcParams<M>,',
|
|
309
|
+
' id: JsonRpcId',
|
|
310
|
+
'): JsonRpcRequestEnvelope<M> {',
|
|
311
|
+
" return { jsonrpc: '2.0', method, params, id }",
|
|
312
|
+
'}',
|
|
313
|
+
'',
|
|
314
|
+
'function setApiPath(root: Record<string, unknown>, method: string, value: unknown): void {',
|
|
315
|
+
" const segments = method.split('.')",
|
|
316
|
+
' let node = root',
|
|
317
|
+
'',
|
|
318
|
+
' for (let index = 0; index < segments.length - 1; index += 1) {',
|
|
319
|
+
' const segment = segments[index]',
|
|
320
|
+
' const existing = node[segment]',
|
|
321
|
+
' if (isObjectLike(existing)) {',
|
|
322
|
+
' node = existing',
|
|
323
|
+
' continue',
|
|
324
|
+
' }',
|
|
325
|
+
'',
|
|
326
|
+
' const next: Record<string, unknown> = {}',
|
|
327
|
+
' node[segment] = next',
|
|
328
|
+
' node = next',
|
|
329
|
+
' }',
|
|
330
|
+
'',
|
|
331
|
+
' node[segments[segments.length - 1] ?? method] = value',
|
|
332
|
+
'}',
|
|
333
|
+
'',
|
|
334
|
+
'async function buildHeaders(',
|
|
335
|
+
' baseHeaders: RpcHeaders | undefined,',
|
|
336
|
+
' context: RpcRequestContext,',
|
|
337
|
+
' requestHeaders: RpcHeadersInit | undefined,',
|
|
338
|
+
' instrumentationHeaders: RpcHeadersInit | undefined',
|
|
339
|
+
'): Promise<Headers> {',
|
|
340
|
+
' const headers = new Headers()',
|
|
341
|
+
" headers.set('Content-Type', 'application/json')",
|
|
342
|
+
'',
|
|
343
|
+
' if (baseHeaders) {',
|
|
344
|
+
' mergeHeaders(',
|
|
345
|
+
' headers,',
|
|
346
|
+
" typeof baseHeaders === 'function' ? await baseHeaders(context) : baseHeaders",
|
|
347
|
+
' )',
|
|
348
|
+
' }',
|
|
349
|
+
'',
|
|
350
|
+
' if (requestHeaders) {',
|
|
351
|
+
' mergeHeaders(headers, requestHeaders)',
|
|
352
|
+
' }',
|
|
353
|
+
'',
|
|
354
|
+
' // Trace-context headers are applied last so instrumentation stays authoritative over propagation.',
|
|
355
|
+
' if (instrumentationHeaders) {',
|
|
356
|
+
' mergeHeaders(headers, instrumentationHeaders)',
|
|
357
|
+
' }',
|
|
358
|
+
'',
|
|
359
|
+
' return headers',
|
|
360
|
+
'}',
|
|
361
|
+
'',
|
|
362
|
+
'function mergeHeaders(target: Headers, source: RpcHeadersInit): void {',
|
|
363
|
+
' if (source instanceof Headers) {',
|
|
364
|
+
' source.forEach((value, key) => {',
|
|
365
|
+
' target.set(key, value)',
|
|
366
|
+
' })',
|
|
367
|
+
' return',
|
|
368
|
+
' }',
|
|
369
|
+
'',
|
|
370
|
+
' if (Array.isArray(source)) {',
|
|
371
|
+
' for (const [key, value] of source) {',
|
|
372
|
+
' target.set(key, value)',
|
|
373
|
+
' }',
|
|
374
|
+
' return',
|
|
375
|
+
' }',
|
|
376
|
+
'',
|
|
377
|
+
' for (const [key, value] of Object.entries(source)) {',
|
|
378
|
+
' target.set(key, value)',
|
|
379
|
+
' }',
|
|
380
|
+
'}',
|
|
381
|
+
'',
|
|
382
|
+
'function idKey(id: JsonRpcId): string {',
|
|
383
|
+
' return `${typeof id}:${String(id)}`',
|
|
384
|
+
'}',
|
|
385
|
+
'',
|
|
386
|
+
'function idsEqual(actual: JsonRpcId | null | undefined, expected: JsonRpcId): boolean {',
|
|
387
|
+
' return actual === expected && typeof actual === typeof expected',
|
|
388
|
+
'}',
|
|
389
|
+
'',
|
|
390
|
+
'function parseResponseEnvelope(value: unknown): JsonRpcResponseEnvelope {',
|
|
391
|
+
' if (!isRecord(value)) {',
|
|
392
|
+
" throw new RpcProtocolError('Expected JSON-RPC response object.')",
|
|
393
|
+
' }',
|
|
394
|
+
'',
|
|
395
|
+
' const error = value.error',
|
|
396
|
+
' if (error !== undefined && !isJsonRpcErrorObject(error)) {',
|
|
397
|
+
" throw new RpcProtocolError('Invalid JSON-RPC error object.')",
|
|
398
|
+
' }',
|
|
399
|
+
'',
|
|
400
|
+
' const id = value.id',
|
|
401
|
+
' if (id !== undefined && id !== null && typeof id !== ' + "'string'" + ' && typeof id !== ' + "'number'" + ') {',
|
|
402
|
+
" throw new RpcProtocolError('Invalid JSON-RPC response id.')",
|
|
403
|
+
' }',
|
|
404
|
+
'',
|
|
405
|
+
' return { id, result: value.result, error }',
|
|
406
|
+
'}',
|
|
407
|
+
'',
|
|
408
|
+
'function isJsonRpcErrorObject(value: unknown): value is JsonRpcErrorObject {',
|
|
409
|
+
' return (',
|
|
410
|
+
' isRecord(value) &&',
|
|
411
|
+
" typeof value.code === 'number' &&",
|
|
412
|
+
" typeof value.message === 'string'",
|
|
413
|
+
' )',
|
|
414
|
+
'}',
|
|
415
|
+
'',
|
|
416
|
+
'function toRpcError(error: JsonRpcErrorObject): RpcError {',
|
|
417
|
+
' return new RpcError(error.code, error.message, error.data)',
|
|
418
|
+
'}',
|
|
419
|
+
'',
|
|
420
|
+
'function isRecord(value: unknown): value is Record<string, unknown> {',
|
|
421
|
+
" return typeof value === 'object' && value !== null && !Array.isArray(value)",
|
|
422
|
+
'}',
|
|
423
|
+
'',
|
|
424
|
+
'function isObjectLike(value: unknown): value is Record<string, unknown> {',
|
|
425
|
+
" return (typeof value === 'object' && value !== null) || typeof value === 'function'",
|
|
426
|
+
'}',
|
|
427
|
+
'',
|
|
428
|
+
].join('\n');
|
|
429
|
+
}
|
|
430
|
+
function moduleSpecifier(fileName) {
|
|
431
|
+
const withoutExtension = fileName.replace(/[.][cm]?tsx?$/, '');
|
|
432
|
+
const relativePath = withoutExtension.startsWith('.')
|
|
433
|
+
? withoutExtension
|
|
434
|
+
: `./${withoutExtension}`;
|
|
435
|
+
return `${relativePath}.js`;
|
|
436
|
+
}
|
|
437
|
+
function buildMethodTree(methods) {
|
|
438
|
+
const root = createMethodTreeNode();
|
|
439
|
+
for (const method of methods) {
|
|
440
|
+
const segments = method.split('.');
|
|
441
|
+
let node = root;
|
|
442
|
+
for (const segment of segments) {
|
|
443
|
+
let child = node.children.get(segment);
|
|
444
|
+
if (!child) {
|
|
445
|
+
child = createMethodTreeNode();
|
|
446
|
+
node.children.set(segment, child);
|
|
447
|
+
}
|
|
448
|
+
node = child;
|
|
449
|
+
}
|
|
450
|
+
node.method = method;
|
|
451
|
+
}
|
|
452
|
+
return root;
|
|
453
|
+
}
|
|
454
|
+
function createMethodTreeNode() {
|
|
455
|
+
return { children: new Map() };
|
|
456
|
+
}
|
|
457
|
+
function generateApiTypeLines(methodTree) {
|
|
458
|
+
return [
|
|
459
|
+
'export type RpcEndpoint<M extends RpcMethod> = (',
|
|
460
|
+
' params: RpcParams<M>,',
|
|
461
|
+
' options?: RpcRequestOptions',
|
|
462
|
+
') => Promise<RpcResult<M>>',
|
|
463
|
+
'',
|
|
464
|
+
'export type RpcBatchRequest<M extends RpcMethod> = (params: RpcParams<M>) => BatchItem<M>',
|
|
465
|
+
'',
|
|
466
|
+
'export interface RpcApi {',
|
|
467
|
+
' readonly $client: RpcClient',
|
|
468
|
+
" readonly $batch: RpcClient['batch']",
|
|
469
|
+
' readonly $request: RpcRequestApi',
|
|
470
|
+
...emitProperties(methodTree, 'RpcEndpoint', 1),
|
|
471
|
+
'}',
|
|
472
|
+
'',
|
|
473
|
+
'export interface RpcRequestApi {',
|
|
474
|
+
...emitProperties(methodTree, 'RpcBatchRequest', 1),
|
|
475
|
+
'}',
|
|
476
|
+
'',
|
|
477
|
+
];
|
|
478
|
+
}
|
|
479
|
+
function emitProperties(node, endpointType, indent) {
|
|
480
|
+
const pad = ' '.repeat(indent);
|
|
481
|
+
return Array.from(node.children.entries())
|
|
482
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
483
|
+
.map(([segment, child]) => {
|
|
484
|
+
return `${pad}readonly ${JSON.stringify(segment)}: ${emitNodeType(child, endpointType, indent)}`;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
function emitNodeType(node, endpointType, indent) {
|
|
488
|
+
const childLines = emitProperties(node, endpointType, indent + 1);
|
|
489
|
+
const childObject = childLines.length === 0
|
|
490
|
+
? '{}'
|
|
491
|
+
: `{\n${childLines.join('\n')}\n${' '.repeat(indent)}}`;
|
|
492
|
+
if (node.method && childLines.length > 0) {
|
|
493
|
+
return `${endpointType}<${JSON.stringify(node.method)}> & ${childObject}`;
|
|
494
|
+
}
|
|
495
|
+
if (node.method) {
|
|
496
|
+
return `${endpointType}<${JSON.stringify(node.method)}>`;
|
|
497
|
+
}
|
|
498
|
+
return childObject;
|
|
499
|
+
}
|
|
500
|
+
function generateMethodNameLines(methods) {
|
|
501
|
+
return [
|
|
502
|
+
'const rpcMethodNames = [',
|
|
503
|
+
...methods.map((method) => ` ${JSON.stringify(method)},`),
|
|
504
|
+
'] as const satisfies readonly RpcMethod[]',
|
|
505
|
+
'',
|
|
506
|
+
];
|
|
507
|
+
}
|
package/dist/schema.d.ts
CHANGED
|
@@ -23,11 +23,14 @@ export interface GenerateRpcClientOptions {
|
|
|
23
23
|
sourceLabel?: string;
|
|
24
24
|
typesFileName?: string;
|
|
25
25
|
schemasFileName?: string;
|
|
26
|
+
clientFileName?: string;
|
|
26
27
|
}
|
|
27
28
|
export interface GeneratedRpcClientFiles {
|
|
28
29
|
methodCount: number;
|
|
29
30
|
typesFileName: string;
|
|
30
31
|
schemasFileName: string;
|
|
32
|
+
clientFileName: string;
|
|
31
33
|
typesSource: string;
|
|
32
34
|
schemasSource: string;
|
|
35
|
+
clientSource: string;
|
|
33
36
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swimmesberger/elarion-jsonrpc-client-generator",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Generate TypeScript RPC contracts and
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Generate TypeScript RPC contracts, Zod schemas, and a fetch client from Elarion JSON-RPC schema exports.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|