@swimmesberger/elarion-jsonrpc-client-generator 0.2.3-preview.67.1 → 0.2.3-preview.68.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 +33 -0
- package/dist/cli.js +27 -3
- package/dist/generate.js +10 -0
- package/dist/rpc-client-source.d.ts +1 -0
- package/dist/rpc-client-source.js +36 -1
- package/dist/schema.d.ts +14 -0
- package/dist/start-adapter-source.d.ts +12 -0
- package/dist/start-adapter-source.js +73 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -59,6 +59,39 @@ Result validation is enabled by default through `rpcResultSchemas`. Use `transfo
|
|
|
59
59
|
|
|
60
60
|
Params validation is also enabled by default through `rpcParamsSchemas`: single calls and batch items are checked against the exported schema's constraints (lengths, ranges, patterns, formats) before the request is sent, and a failure throws `RpcParamsValidationError` locally — naming the method, with the underlying Zod error as `cause` — so tier-1 contract violations never reach the wire. Set `validateParams: false` to opt out.
|
|
61
61
|
|
|
62
|
+
## Error handling
|
|
63
|
+
|
|
64
|
+
A JSON-RPC error from the server is thrown as a typed `RpcError` (`code`, `message`, optional `data`). Elarion maps its `AppError` kinds onto the JSON-RPC server-reserved range, so the generated `RpcError` exposes a getter per kind — branch on the kind directly instead of re-wrapping:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { RpcError, ElarionErrorCodes } from './generated/rpc-client'
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
await rpc.clients.get({ id })
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error instanceof RpcError && error.isNotFound) return renderNotFound()
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`isNotFound` (`-32001`), `isConflict` (`-32002`), `isForbidden` (`-32003`), `isBusinessRule` (`-32004`), and `isUnauthorized` (`-32005`) cover the Elarion application kinds; `isInvalidParams` (`-32602`) and `isInternalError` (`-32603`) cover `Validation`/`Internal`; and the standard `isParseError`/`isInvalidRequest`/`isMethodNotFound` getters stay available. The application codes are also exported as `ElarionErrorCodes` for `switch` statements.
|
|
78
|
+
|
|
79
|
+
## Framework adapters
|
|
80
|
+
|
|
81
|
+
The core client stays framework-neutral, but you can opt into a framework adapter emitted as a separate file. Pass `--framework tanstack-start` to also emit `start-adapter.ts` (needs the `@tanstack/react-start` peer dependency):
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated --framework tanstack-start
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It exports `createStartRpcApi` — a `createRpcApi` with request-scoped cookie forwarding pre-wired for SSR — and `forwardRequestCookie`, the isomorphic headers function on its own. The core client never imports the adapter, so consumers that don't opt in stay framework-neutral and their output is byte-identical.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
import { createStartRpcApi } from './generated/start-adapter'
|
|
91
|
+
|
|
92
|
+
export const rpc = createStartRpcApi({ url: '/rpc' })
|
|
93
|
+
```
|
|
94
|
+
|
|
62
95
|
## Client-side tracing
|
|
63
96
|
|
|
64
97
|
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:
|
package/dist/cli.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { basename, resolve } from 'node:path';
|
|
4
4
|
import { generateRpcClientFiles } from './generate.js';
|
|
5
|
+
const SUPPORTED_FRAMEWORKS = ['tanstack-start'];
|
|
5
6
|
function parseArgs(argv) {
|
|
6
7
|
const options = {
|
|
7
8
|
schemaPath: 'rpc-schema.json',
|
|
@@ -47,6 +48,19 @@ function parseArgs(argv) {
|
|
|
47
48
|
index += 1;
|
|
48
49
|
continue;
|
|
49
50
|
}
|
|
51
|
+
if (arg === '--framework') {
|
|
52
|
+
if (!SUPPORTED_FRAMEWORKS.includes(next)) {
|
|
53
|
+
throw new Error(`Unsupported framework ${next}. Supported: ${SUPPORTED_FRAMEWORKS.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
options.framework = next;
|
|
56
|
+
index += 1;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === '--framework-adapter') {
|
|
60
|
+
options.frameworkAdapterFileName = next;
|
|
61
|
+
index += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
50
64
|
if (arg === '--source-label') {
|
|
51
65
|
options.sourceLabel = next;
|
|
52
66
|
index += 1;
|
|
@@ -67,6 +81,9 @@ Options:
|
|
|
67
81
|
--client <file> Fetch client filename (default: rpc-client.ts)
|
|
68
82
|
--session-client <file> Client-capability snapshot client + OpenFeature provider (default: session-client.ts;
|
|
69
83
|
emitted only when the schema exposes the elarion.session operation)
|
|
84
|
+
--framework <name> Emit an opt-in framework adapter alongside the neutral core client.
|
|
85
|
+
Supported: tanstack-start (needs the @tanstack/react-start peer dependency)
|
|
86
|
+
--framework-adapter <file> Framework adapter filename (default: start-adapter.ts)
|
|
70
87
|
--source-label <text> Source label written into generated file headers
|
|
71
88
|
`);
|
|
72
89
|
}
|
|
@@ -82,16 +99,23 @@ function main() {
|
|
|
82
99
|
schemasFileName: options.schemasFileName,
|
|
83
100
|
clientFileName: options.clientFileName,
|
|
84
101
|
sessionClientFileName: options.sessionClientFileName,
|
|
102
|
+
framework: options.framework,
|
|
103
|
+
frameworkAdapterFileName: options.frameworkAdapterFileName,
|
|
85
104
|
});
|
|
86
105
|
mkdirSync(outDir, { recursive: true });
|
|
87
106
|
writeFileSync(resolve(outDir, generated.typesFileName), generated.typesSource, 'utf-8');
|
|
88
107
|
writeFileSync(resolve(outDir, generated.schemasFileName), generated.schemasSource, 'utf-8');
|
|
89
108
|
writeFileSync(resolve(outDir, generated.clientFileName), generated.clientSource, 'utf-8');
|
|
90
|
-
|
|
109
|
+
const extraNotes = [];
|
|
91
110
|
if (generated.sessionClientFileName !== undefined && generated.sessionClientSource !== undefined) {
|
|
92
111
|
writeFileSync(resolve(outDir, generated.sessionClientFileName), generated.sessionClientSource, 'utf-8');
|
|
93
|
-
|
|
112
|
+
extraNotes.push(generated.sessionClientFileName);
|
|
113
|
+
}
|
|
114
|
+
if (generated.frameworkAdapterFileName !== undefined && generated.frameworkAdapterSource !== undefined) {
|
|
115
|
+
writeFileSync(resolve(outDir, generated.frameworkAdapterFileName), generated.frameworkAdapterSource, 'utf-8');
|
|
116
|
+
extraNotes.push(generated.frameworkAdapterFileName);
|
|
94
117
|
}
|
|
95
|
-
|
|
118
|
+
const extraNote = extraNotes.length > 0 ? ` (+ ${extraNotes.join(', ')})` : '';
|
|
119
|
+
console.log(`[jsonrpc-client-generator] Generated ${generated.methodCount} RPC method types and schemas${extraNote} -> ${outDir}`);
|
|
96
120
|
}
|
|
97
121
|
main();
|
package/dist/generate.js
CHANGED
|
@@ -3,6 +3,7 @@ import { jsonSchemaToZod } from './json-schema-to-zod.js';
|
|
|
3
3
|
import { stripNullable } from './json-schema.js';
|
|
4
4
|
import { generateRpcClientSource } from './rpc-client-source.js';
|
|
5
5
|
import { generateSessionClientSource } from './session-client-source.js';
|
|
6
|
+
import { generateStartAdapterSource } from './start-adapter-source.js';
|
|
6
7
|
const DEFAULT_GENERATED_BY = 'elarion-jsonrpc-client-generator';
|
|
7
8
|
const DEFAULT_SOURCE_LABEL = 'rpc-schema.json';
|
|
8
9
|
const DEFAULT_TYPES_FILE = 'rpc-types.ts';
|
|
@@ -10,6 +11,7 @@ const DEFAULT_SCHEMAS_FILE = 'rpc-schemas.ts';
|
|
|
10
11
|
const DEFAULT_CLIENT_FILE = 'rpc-client.ts';
|
|
11
12
|
const DEFAULT_SESSION_CLIENT_FILE = 'session-client.ts';
|
|
12
13
|
const DEFAULT_SESSION_OPERATION = 'elarion.session';
|
|
14
|
+
const DEFAULT_START_ADAPTER_FILE = 'start-adapter.ts';
|
|
13
15
|
export { UnsupportedJsonSchemaError } from './json-schema.js';
|
|
14
16
|
export function generateRpcClientFiles(schema, options = {}) {
|
|
15
17
|
const generatedBy = options.generatedBy ?? DEFAULT_GENERATED_BY;
|
|
@@ -85,6 +87,12 @@ export function generateRpcClientFiles(schema, options = {}) {
|
|
|
85
87
|
capabilities: schema.capabilities,
|
|
86
88
|
})
|
|
87
89
|
: undefined;
|
|
90
|
+
// The framework adapter (ADR-boundary: kept opt-in so the core client stays framework-neutral) is emitted only
|
|
91
|
+
// when a framework is requested, so an unopted schema produces byte-identical output.
|
|
92
|
+
const frameworkAdapterFileName = options.frameworkAdapterFileName ?? DEFAULT_START_ADAPTER_FILE;
|
|
93
|
+
const frameworkAdapterSource = options.framework === 'tanstack-start'
|
|
94
|
+
? generateStartAdapterSource({ generatedBy, sourceLabel, clientFileName })
|
|
95
|
+
: undefined;
|
|
88
96
|
return {
|
|
89
97
|
methodCount: methods.length,
|
|
90
98
|
typesFileName,
|
|
@@ -95,6 +103,8 @@ export function generateRpcClientFiles(schema, options = {}) {
|
|
|
95
103
|
clientSource,
|
|
96
104
|
sessionClientFileName: sessionClientSource === undefined ? undefined : sessionClientFileName,
|
|
97
105
|
sessionClientSource,
|
|
106
|
+
frameworkAdapterFileName: frameworkAdapterSource === undefined ? undefined : frameworkAdapterFileName,
|
|
107
|
+
frameworkAdapterSource,
|
|
98
108
|
};
|
|
99
109
|
}
|
|
100
110
|
function createContext(root, path) {
|
|
@@ -131,6 +131,19 @@ export function generateRpcClientSource(options) {
|
|
|
131
131
|
' readonly error?: JsonRpcErrorObject',
|
|
132
132
|
'}',
|
|
133
133
|
'',
|
|
134
|
+
'// Elarion maps its transport-agnostic AppError kinds onto the JSON-RPC server-reserved range',
|
|
135
|
+
'// (-32000..-32099) via the framework default AppErrorMapper, so a frontend can branch on the kind',
|
|
136
|
+
'// directly instead of re-wrapping RpcError. These codes are a wire contract mirrored from the server',
|
|
137
|
+
"// (src/Elarion.JsonRpc/AppErrorMapper.cs) — keep both in sync. Validation reuses the standard -32602",
|
|
138
|
+
'// (isInvalidParams) and Internal reuses -32603 (isInternalError), so they are not repeated here.',
|
|
139
|
+
'export const ElarionErrorCodes = {',
|
|
140
|
+
' notFound: -32001,',
|
|
141
|
+
' conflict: -32002,',
|
|
142
|
+
' forbidden: -32003,',
|
|
143
|
+
' businessRule: -32004,',
|
|
144
|
+
' unauthorized: -32005,',
|
|
145
|
+
'} as const',
|
|
146
|
+
'',
|
|
134
147
|
'export class RpcError extends Error {',
|
|
135
148
|
' readonly code: number',
|
|
136
149
|
' readonly data?: unknown',
|
|
@@ -161,6 +174,28 @@ export function generateRpcClientSource(options) {
|
|
|
161
174
|
' get isInternalError() {',
|
|
162
175
|
' return this.code === -32603',
|
|
163
176
|
' }',
|
|
177
|
+
'',
|
|
178
|
+
' // Elarion application-error kinds (see ElarionErrorCodes). isInvalidParams also covers Validation;',
|
|
179
|
+
' // isInternalError also covers Internal.',
|
|
180
|
+
' get isNotFound() {',
|
|
181
|
+
' return this.code === ElarionErrorCodes.notFound',
|
|
182
|
+
' }',
|
|
183
|
+
'',
|
|
184
|
+
' get isConflict() {',
|
|
185
|
+
' return this.code === ElarionErrorCodes.conflict',
|
|
186
|
+
' }',
|
|
187
|
+
'',
|
|
188
|
+
' get isForbidden() {',
|
|
189
|
+
' return this.code === ElarionErrorCodes.forbidden',
|
|
190
|
+
' }',
|
|
191
|
+
'',
|
|
192
|
+
' get isBusinessRule() {',
|
|
193
|
+
' return this.code === ElarionErrorCodes.businessRule',
|
|
194
|
+
' }',
|
|
195
|
+
'',
|
|
196
|
+
' get isUnauthorized() {',
|
|
197
|
+
' return this.code === ElarionErrorCodes.unauthorized',
|
|
198
|
+
' }',
|
|
164
199
|
'}',
|
|
165
200
|
'',
|
|
166
201
|
'export class RpcTransportError extends Error {',
|
|
@@ -528,7 +563,7 @@ export function generateRpcClientSource(options) {
|
|
|
528
563
|
'',
|
|
529
564
|
].join('\n');
|
|
530
565
|
}
|
|
531
|
-
function moduleSpecifier(fileName) {
|
|
566
|
+
export function moduleSpecifier(fileName) {
|
|
532
567
|
const withoutExtension = fileName.replace(/[.][cm]?tsx?$/, '');
|
|
533
568
|
const relativePath = withoutExtension.startsWith('.')
|
|
534
569
|
? withoutExtension
|
package/dist/schema.d.ts
CHANGED
|
@@ -57,6 +57,14 @@ export interface GenerateRpcClientOptions {
|
|
|
57
57
|
sessionClientFileName?: string;
|
|
58
58
|
/** The operation name the client-capability snapshot is served under (default `elarion.session`). */
|
|
59
59
|
sessionOperationName?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Emit an opt-in framework adapter alongside the neutral core client. `tanstack-start` produces
|
|
62
|
+
* `start-adapter.ts` (request-scoped cookie forwarding via `@tanstack/react-start`). Omitted → no adapter,
|
|
63
|
+
* so output stays byte-identical for consumers that do not opt in.
|
|
64
|
+
*/
|
|
65
|
+
framework?: 'tanstack-start';
|
|
66
|
+
/** Output filename for the framework adapter (default `start-adapter.ts`). */
|
|
67
|
+
frameworkAdapterFileName?: string;
|
|
60
68
|
}
|
|
61
69
|
export interface GeneratedRpcClientFiles {
|
|
62
70
|
methodCount: number;
|
|
@@ -72,4 +80,10 @@ export interface GeneratedRpcClientFiles {
|
|
|
72
80
|
*/
|
|
73
81
|
sessionClientFileName?: string;
|
|
74
82
|
sessionClientSource?: string;
|
|
83
|
+
/**
|
|
84
|
+
* The opt-in framework adapter. Present only when `framework` is requested (e.g. `tanstack-start`); otherwise
|
|
85
|
+
* both fields are `undefined`.
|
|
86
|
+
*/
|
|
87
|
+
frameworkAdapterFileName?: string;
|
|
88
|
+
frameworkAdapterSource?: string;
|
|
75
89
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface GenerateStartAdapterSourceOptions {
|
|
2
|
+
generatedBy: string;
|
|
3
|
+
sourceLabel: string;
|
|
4
|
+
/** The core client filename the adapter imports from (default `rpc-client.ts`). */
|
|
5
|
+
clientFileName: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Emits the opt-in TanStack Start adapter (`start-adapter.ts`), produced only with `--framework tanstack-start`.
|
|
9
|
+
* It imports the framework-specific pieces (`@tanstack/react-start`) so the core generated client never does —
|
|
10
|
+
* a non-Start consumer neither installs nor imports this file, keeping the client's framework-neutral boundary.
|
|
11
|
+
*/
|
|
12
|
+
export declare function generateStartAdapterSource(options: GenerateStartAdapterSourceOptions): string;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { moduleSpecifier } from './rpc-client-source.js';
|
|
2
|
+
/**
|
|
3
|
+
* Emits the opt-in TanStack Start adapter (`start-adapter.ts`), produced only with `--framework tanstack-start`.
|
|
4
|
+
* It imports the framework-specific pieces (`@tanstack/react-start`) so the core generated client never does —
|
|
5
|
+
* a non-Start consumer neither installs nor imports this file, keeping the client's framework-neutral boundary.
|
|
6
|
+
*/
|
|
7
|
+
export function generateStartAdapterSource(options) {
|
|
8
|
+
const clientImport = moduleSpecifier(options.clientFileName);
|
|
9
|
+
return [
|
|
10
|
+
`// Auto-generated by ${options.generatedBy} — DO NOT EDIT`,
|
|
11
|
+
`// Source: ${options.sourceLabel}`,
|
|
12
|
+
'//',
|
|
13
|
+
'// Opt-in TanStack Start adapter (emitted only with `--framework tanstack-start`). Requires the',
|
|
14
|
+
'// `@tanstack/react-start` peer dependency. The core generated client never imports this module, so a',
|
|
15
|
+
'// consumer that does not opt in stays framework-neutral.',
|
|
16
|
+
"import { createIsomorphicFn } from '@tanstack/react-start'",
|
|
17
|
+
"import { getRequestHeader } from '@tanstack/react-start/server'",
|
|
18
|
+
`import { createRpcApi, type RpcApi, type RpcClientOptions, type RpcHeadersInit } from '${clientImport}'`,
|
|
19
|
+
'',
|
|
20
|
+
"// Forwards the incoming request's `cookie` during SSR so the RPC call runs as the signed-in user, and",
|
|
21
|
+
'// returns nothing in the browser. The read is isolated inside `createIsomorphicFn().server(...)` so the',
|
|
22
|
+
'// TanStack Start compiler strips both the read and the `@tanstack/react-start/server` import from the',
|
|
23
|
+
'// client bundle — a plain import (even one behind a `typeof window` guard) would fail `vite build` with',
|
|
24
|
+
'// `[import-protection] Import denied`.',
|
|
25
|
+
'export const forwardRequestCookie = createIsomorphicFn()',
|
|
26
|
+
' .server((): RpcHeadersInit => {',
|
|
27
|
+
" const cookie = getRequestHeader('cookie')",
|
|
28
|
+
' return cookie ? { cookie } : {}',
|
|
29
|
+
' })',
|
|
30
|
+
' .client((): RpcHeadersInit => ({}))',
|
|
31
|
+
'',
|
|
32
|
+
'// Turnkey `createRpcApi` for a TanStack Start app: request-scoped cookie forwarding is pre-wired, and any',
|
|
33
|
+
'// `headers` you pass are layered on top (yours win on a conflict). Everything else in RpcClientOptions',
|
|
34
|
+
'// (url, fetch, idempotency, instrumentation, …) passes through unchanged.',
|
|
35
|
+
'export function createStartRpcApi(options: RpcClientOptions): RpcApi {',
|
|
36
|
+
' const provided = options.headers',
|
|
37
|
+
' return createRpcApi({',
|
|
38
|
+
' ...options,',
|
|
39
|
+
' headers: async (context) => {',
|
|
40
|
+
' const cookie = await forwardRequestCookie()',
|
|
41
|
+
" const extra = typeof provided === 'function' ? await provided(context) : provided",
|
|
42
|
+
' return { ...toHeaderRecord(cookie), ...toHeaderRecord(extra) }',
|
|
43
|
+
' },',
|
|
44
|
+
' })',
|
|
45
|
+
'}',
|
|
46
|
+
'',
|
|
47
|
+
'// Normalizes any RpcHeadersInit (Headers, record, or entry tuples) to a plain record so the two sources',
|
|
48
|
+
'// can be merged with object spread.',
|
|
49
|
+
'function toHeaderRecord(init: RpcHeadersInit | undefined): Record<string, string> {',
|
|
50
|
+
' const record: Record<string, string> = {}',
|
|
51
|
+
' if (!init) {',
|
|
52
|
+
' return record',
|
|
53
|
+
' }',
|
|
54
|
+
' if (init instanceof Headers) {',
|
|
55
|
+
' init.forEach((value, key) => {',
|
|
56
|
+
' record[key] = value',
|
|
57
|
+
' })',
|
|
58
|
+
' return record',
|
|
59
|
+
' }',
|
|
60
|
+
' if (Array.isArray(init)) {',
|
|
61
|
+
' for (const [key, value] of init) {',
|
|
62
|
+
' record[key] = value',
|
|
63
|
+
' }',
|
|
64
|
+
' return record',
|
|
65
|
+
' }',
|
|
66
|
+
' for (const [key, value] of Object.entries(init)) {',
|
|
67
|
+
' record[key] = value',
|
|
68
|
+
' }',
|
|
69
|
+
' return record',
|
|
70
|
+
'}',
|
|
71
|
+
'',
|
|
72
|
+
].join('\n');
|
|
73
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swimmesberger/elarion-jsonrpc-client-generator",
|
|
3
|
-
"version": "0.2.3-preview.
|
|
3
|
+
"version": "0.2.3-preview.68.1",
|
|
4
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",
|