@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 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
- let sessionNote = '';
109
+ const extraNotes = [];
91
110
  if (generated.sessionClientFileName !== undefined && generated.sessionClientSource !== undefined) {
92
111
  writeFileSync(resolve(outDir, generated.sessionClientFileName), generated.sessionClientSource, 'utf-8');
93
- sessionNote = ` (+ ${generated.sessionClientFileName})`;
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
- console.log(`[jsonrpc-client-generator] Generated ${generated.methodCount} RPC method types and schemas${sessionNote} -> ${outDir}`);
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) {
@@ -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.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",