@swimmesberger/elarion-jsonrpc-client-generator 0.2.3-preview.67.1 → 0.2.3-preview.69.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 CHANGED
@@ -7,6 +7,12 @@ npm install --save-dev @swimmesberger/elarion-jsonrpc-client-generator
7
7
  npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated
8
8
  ```
9
9
 
10
+ Add `--watch` for a tight dev loop — the generator regenerates whenever `rpc-schema.json` changes (and survives the partial/invalid states a build tool leaves the file in mid-write). Pair it with a server that re-exports the schema on save (e.g. `dotnet watch`) so an edit to a handler flows straight to the typed client:
11
+
12
+ ```bash
13
+ npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated --watch
14
+ ```
15
+
10
16
  The generated files are:
11
17
 
12
18
  | File | Purpose |
@@ -59,6 +65,39 @@ Result validation is enabled by default through `rpcResultSchemas`. Use `transfo
59
65
 
60
66
  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
67
 
68
+ ## Error handling
69
+
70
+ 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:
71
+
72
+ ```ts
73
+ import { RpcError, ElarionErrorCodes } from './generated/rpc-client'
74
+
75
+ try {
76
+ await rpc.clients.get({ id })
77
+ } catch (error) {
78
+ if (error instanceof RpcError && error.isNotFound) return renderNotFound()
79
+ throw error
80
+ }
81
+ ```
82
+
83
+ `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.
84
+
85
+ ## Framework adapters
86
+
87
+ 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):
88
+
89
+ ```bash
90
+ npx elarion-jsonrpc-client-generator --schema rpc-schema.json --out src/generated --framework tanstack-start
91
+ ```
92
+
93
+ 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.
94
+
95
+ ```ts
96
+ import { createStartRpcApi } from './generated/start-adapter'
97
+
98
+ export const rpc = createStartRpcApi({ url: '/rpc' })
99
+ ```
100
+
62
101
  ## Client-side tracing
63
102
 
64
103
  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
@@ -1,7 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { mkdirSync, readFileSync, watchFile, writeFileSync } from 'node:fs';
3
3
  import { basename, resolve } from 'node:path';
4
4
  import { generateRpcClientFiles } from './generate.js';
5
+ // Poll interval for --watch. Polling (rather than fs.watch) is deliberate: the schema is (re)written by a
6
+ // build tool, often via a temp-file + rename, which event-based watchers miss or double-fire; polling the
7
+ // exact path is robust to that and to delete/recreate, at the cost of up-to-one-interval latency.
8
+ const WATCH_INTERVAL_MS = 300;
9
+ const SUPPORTED_FRAMEWORKS = ['tanstack-start'];
5
10
  function parseArgs(argv) {
6
11
  const options = {
7
12
  schemaPath: 'rpc-schema.json',
@@ -14,6 +19,10 @@ function parseArgs(argv) {
14
19
  printHelp();
15
20
  process.exit(0);
16
21
  }
22
+ if (arg === '--watch') {
23
+ options.watch = true;
24
+ continue;
25
+ }
17
26
  if (!next) {
18
27
  throw new Error(`Missing value for ${arg}`);
19
28
  }
@@ -47,6 +56,19 @@ function parseArgs(argv) {
47
56
  index += 1;
48
57
  continue;
49
58
  }
59
+ if (arg === '--framework') {
60
+ if (!SUPPORTED_FRAMEWORKS.includes(next)) {
61
+ throw new Error(`Unsupported framework ${next}. Supported: ${SUPPORTED_FRAMEWORKS.join(', ')}`);
62
+ }
63
+ options.framework = next;
64
+ index += 1;
65
+ continue;
66
+ }
67
+ if (arg === '--framework-adapter') {
68
+ options.frameworkAdapterFileName = next;
69
+ index += 1;
70
+ continue;
71
+ }
50
72
  if (arg === '--source-label') {
51
73
  options.sourceLabel = next;
52
74
  index += 1;
@@ -67,31 +89,88 @@ Options:
67
89
  --client <file> Fetch client filename (default: rpc-client.ts)
68
90
  --session-client <file> Client-capability snapshot client + OpenFeature provider (default: session-client.ts;
69
91
  emitted only when the schema exposes the elarion.session operation)
92
+ --framework <name> Emit an opt-in framework adapter alongside the neutral core client.
93
+ Supported: tanstack-start (needs the @tanstack/react-start peer dependency)
94
+ --framework-adapter <file> Framework adapter filename (default: start-adapter.ts)
70
95
  --source-label <text> Source label written into generated file headers
96
+ --watch Regenerate whenever the schema file changes (Ctrl+C to stop)
71
97
  `);
72
98
  }
73
- function main() {
74
- const options = parseArgs(process.argv.slice(2));
99
+ // Reads the schema and writes the generated files once. Returns false (without throwing) on a missing or
100
+ // malformed schema so --watch can keep running across the transient states a build tool leaves the file in
101
+ // mid-write, and so a one-shot run exits non-zero with a clean message instead of a stack trace.
102
+ function generateOnce(options) {
75
103
  const schemaPath = resolve(process.cwd(), options.schemaPath);
76
104
  const outDir = resolve(process.cwd(), options.outDir);
77
- const raw = readFileSync(schemaPath, 'utf-8');
78
- const schema = JSON.parse(raw);
105
+ let raw;
106
+ try {
107
+ raw = readFileSync(schemaPath, 'utf-8');
108
+ }
109
+ catch (error) {
110
+ const code = error.code;
111
+ console.error(code === 'ENOENT'
112
+ ? `[jsonrpc-client-generator] Schema not found at ${schemaPath}`
113
+ : `[jsonrpc-client-generator] Cannot read ${schemaPath}: ${errorMessage(error)}`);
114
+ return false;
115
+ }
116
+ let schema;
117
+ try {
118
+ schema = JSON.parse(raw);
119
+ }
120
+ catch (error) {
121
+ console.error(`[jsonrpc-client-generator] Invalid JSON in ${schemaPath}: ${errorMessage(error)}`);
122
+ return false;
123
+ }
79
124
  const generated = generateRpcClientFiles(schema, {
80
125
  sourceLabel: options.sourceLabel ?? basename(schemaPath),
81
126
  typesFileName: options.typesFileName,
82
127
  schemasFileName: options.schemasFileName,
83
128
  clientFileName: options.clientFileName,
84
129
  sessionClientFileName: options.sessionClientFileName,
130
+ framework: options.framework,
131
+ frameworkAdapterFileName: options.frameworkAdapterFileName,
85
132
  });
86
133
  mkdirSync(outDir, { recursive: true });
87
134
  writeFileSync(resolve(outDir, generated.typesFileName), generated.typesSource, 'utf-8');
88
135
  writeFileSync(resolve(outDir, generated.schemasFileName), generated.schemasSource, 'utf-8');
89
136
  writeFileSync(resolve(outDir, generated.clientFileName), generated.clientSource, 'utf-8');
90
- let sessionNote = '';
137
+ const extraNotes = [];
91
138
  if (generated.sessionClientFileName !== undefined && generated.sessionClientSource !== undefined) {
92
139
  writeFileSync(resolve(outDir, generated.sessionClientFileName), generated.sessionClientSource, 'utf-8');
93
- sessionNote = ` (+ ${generated.sessionClientFileName})`;
140
+ extraNotes.push(generated.sessionClientFileName);
141
+ }
142
+ if (generated.frameworkAdapterFileName !== undefined && generated.frameworkAdapterSource !== undefined) {
143
+ writeFileSync(resolve(outDir, generated.frameworkAdapterFileName), generated.frameworkAdapterSource, 'utf-8');
144
+ extraNotes.push(generated.frameworkAdapterFileName);
145
+ }
146
+ const extraNote = extraNotes.length > 0 ? ` (+ ${extraNotes.join(', ')})` : '';
147
+ console.log(`[jsonrpc-client-generator] Generated ${generated.methodCount} RPC method types and schemas${extraNote} -> ${outDir}`);
148
+ return true;
149
+ }
150
+ function errorMessage(error) {
151
+ return error instanceof Error ? error.message : String(error);
152
+ }
153
+ function main() {
154
+ const options = parseArgs(process.argv.slice(2));
155
+ const ok = generateOnce(options);
156
+ if (!options.watch) {
157
+ if (!ok) {
158
+ process.exitCode = 1;
159
+ }
160
+ return;
94
161
  }
95
- console.log(`[jsonrpc-client-generator] Generated ${generated.methodCount} RPC method types and schemas${sessionNote} -> ${outDir}`);
162
+ const schemaPath = resolve(process.cwd(), options.schemaPath);
163
+ console.log(`[jsonrpc-client-generator] Watching ${schemaPath} for changes (Ctrl+C to stop)…`);
164
+ watchFile(schemaPath, { interval: WATCH_INTERVAL_MS }, (curr, prev) => {
165
+ // mtimeMs === 0 means the file does not currently exist (not yet written, or just deleted); wait for the
166
+ // next write rather than regenerating from a stale/absent file.
167
+ if (curr.mtimeMs === 0) {
168
+ return;
169
+ }
170
+ if (curr.mtimeMs === prev.mtimeMs && curr.size === prev.size) {
171
+ return;
172
+ }
173
+ generateOnce(options);
174
+ });
96
175
  }
97
176
  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) {
@@ -7,4 +7,5 @@ interface GenerateRpcClientSourceOptions {
7
7
  idempotentMethods: readonly string[];
8
8
  }
9
9
  export declare function generateRpcClientSource(options: GenerateRpcClientSourceOptions): string;
10
+ export declare function moduleSpecifier(fileName: string): string;
10
11
  export {};
@@ -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.67.1",
3
+ "version": "0.2.3-preview.69.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",