@swimmesberger/elarion-jsonrpc-client-generator 0.2.3-preview.76.1 → 0.2.3-preview.77.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/dist/generate.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { jsonSchemaToTypeScript } from './json-schema-to-ts.js';
2
2
  import { jsonSchemaToZod } from './json-schema-to-zod.js';
3
- import { stripNullable } from './json-schema.js';
3
+ import { collectFilePaths, stripNullable } from './json-schema.js';
4
4
  import { generateRpcClientSource } from './rpc-client-source.js';
5
5
  import { generateSessionClientSource } from './session-client-source.js';
6
6
  import { generateStartAdapterSource } from './start-adapter-source.js';
@@ -65,6 +65,21 @@ export function generateRpcClientFiles(schema, options = {}) {
65
65
  schemasLines.push('export type RpcResultSchemas = typeof rpcResultSchemas');
66
66
  schemasLines.push('');
67
67
  const idempotentMethods = methods.filter((method) => schema.methods[method].idempotent === true);
68
+ // Where each method carries file payloads (x-elarion-file nodes), so the client can convert between
69
+ // native File values and the base64 wire envelope. Methods without files are omitted entirely.
70
+ const paramsFilePaths = {};
71
+ const resultFilePaths = {};
72
+ for (const method of methods) {
73
+ const definition = schema.methods[method];
74
+ const params = collectFilePaths(stripNullable(definition.params), createContext(definition.params, `methods.${method}.params`));
75
+ if (params.length > 0) {
76
+ paramsFilePaths[method] = params;
77
+ }
78
+ const result = collectFilePaths(stripNullable(definition.result), createContext(definition.result, `methods.${method}.result`));
79
+ if (result.length > 0) {
80
+ resultFilePaths[method] = result;
81
+ }
82
+ }
68
83
  const clientSource = generateRpcClientSource({
69
84
  generatedBy,
70
85
  sourceLabel,
@@ -72,6 +87,8 @@ export function generateRpcClientFiles(schema, options = {}) {
72
87
  schemasFileName,
73
88
  methods,
74
89
  idempotentMethods,
90
+ paramsFilePaths,
91
+ resultFilePaths,
75
92
  });
76
93
  // The client-capability snapshot client + OpenFeature provider (ADR-0030) is emitted only when the host exposes
77
94
  // the session operation, so a schema without it produces byte-identical output. The schema's optional
@@ -4,6 +4,11 @@ export function jsonSchemaToTypeScript(schema, ctx, indent = 0) {
4
4
  const pad = ' '.repeat(indent);
5
5
  const nullable = isNullable(resolved);
6
6
  const base = baseType(resolved);
7
+ // The Elarion file envelope surfaces as a native File; the generated client converts to/from the
8
+ // base64 wire shape at the call boundary, so callers never see the envelope.
9
+ if (resolved['x-elarion-file'] === true) {
10
+ return nullable ? 'File | null | undefined' : 'File';
11
+ }
7
12
  if (resolved.enum) {
8
13
  const values = resolved.enum
9
14
  .filter((value) => value !== null)
@@ -3,6 +3,11 @@ export function jsonSchemaToZod(schema, ctx, indent = 0) {
3
3
  const resolved = resolveSchema(schema, ctx);
4
4
  const nullable = isNullable(resolved);
5
5
  const base = baseType(resolved);
6
+ // Schemas validate the user-facing shape: a file field is a native File on both sides of a call (the
7
+ // client encodes params after validation and decodes results before validation).
8
+ if (resolved['x-elarion-file'] === true) {
9
+ return nullish('z.instanceof(File)', nullable);
10
+ }
6
11
  if (resolved.enum) {
7
12
  const values = resolved.enum.filter((value) => value !== null);
8
13
  const allStrings = values.every((value) => typeof value === 'string');
@@ -14,3 +14,10 @@ export declare function stripNullable(schema: JsonSchema): JsonSchema;
14
14
  export declare function resolveSchema(schema: JsonSchema, ctx: SchemaContext): JsonSchema;
15
15
  export declare function childContext(ctx: SchemaContext, segment: string): SchemaContext;
16
16
  export declare function formatPropertyName(key: string): string;
17
+ /**
18
+ * Collects the property paths of every `x-elarion-file` node in a params/result schema (`[]` when the
19
+ * schema itself is the file, `'*'` for array items), so the generated client knows where to convert
20
+ * between native `File` values and the base64 wire envelope. Deterministic: property order follows the
21
+ * schema document.
22
+ */
23
+ export declare function collectFilePaths(schema: JsonSchema, ctx: SchemaContext): string[][];
@@ -63,6 +63,32 @@ export function formatPropertyName(key) {
63
63
  }
64
64
  return JSON.stringify(key);
65
65
  }
66
+ /**
67
+ * Collects the property paths of every `x-elarion-file` node in a params/result schema (`[]` when the
68
+ * schema itself is the file, `'*'` for array items), so the generated client knows where to convert
69
+ * between native `File` values and the base64 wire envelope. Deterministic: property order follows the
70
+ * schema document.
71
+ */
72
+ export function collectFilePaths(schema, ctx) {
73
+ const paths = [];
74
+ visitFileNodes(schema, ctx, [], paths);
75
+ return paths;
76
+ }
77
+ function visitFileNodes(schema, ctx, prefix, paths) {
78
+ const resolved = resolveSchema(schema, ctx);
79
+ if (resolved['x-elarion-file'] === true) {
80
+ paths.push(prefix);
81
+ return;
82
+ }
83
+ if (resolved.items) {
84
+ visitFileNodes(stripNullable(resolved.items), childContext(ctx, 'items'), [...prefix, '*'], paths);
85
+ }
86
+ if (resolved.properties) {
87
+ for (const [key, property] of Object.entries(resolved.properties)) {
88
+ visitFileNodes(property, childContext(ctx, `properties.${key}`), [...prefix, key], paths);
89
+ }
90
+ }
91
+ }
66
92
  function assertSupportedComposition(schema, path) {
67
93
  if (schema.oneOf) {
68
94
  throw new UnsupportedJsonSchemaError(path, 'oneOf is not supported');
@@ -5,6 +5,9 @@ interface GenerateRpcClientSourceOptions {
5
5
  schemasFileName: string;
6
6
  methods: readonly string[];
7
7
  idempotentMethods: readonly string[];
8
+ /** Property paths of file payloads per method ('*' = array items, [] = the value itself); omitted methods carry none. */
9
+ paramsFilePaths: Readonly<Record<string, string[][]>>;
10
+ resultFilePaths: Readonly<Record<string, string[][]>>;
8
11
  }
9
12
  export declare function generateRpcClientSource(options: GenerateRpcClientSourceOptions): string;
10
13
  export declare function moduleSpecifier(fileName: string): string;
@@ -5,6 +5,9 @@ export function generateRpcClientSource(options) {
5
5
  const typesImport = moduleSpecifier(options.typesFileName);
6
6
  const schemasImport = moduleSpecifier(options.schemasFileName);
7
7
  const methodTree = buildMethodTree(options.methods);
8
+ // File conversion is emitted only when the schema actually carries file payloads, so file-free schemas
9
+ // produce byte-identical output.
10
+ const hasFiles = Object.keys(options.paramsFilePaths).length > 0 || Object.keys(options.resultFilePaths).length > 0;
8
11
  return [
9
12
  `// Auto-generated by ${options.generatedBy} — DO NOT EDIT`,
10
13
  `// Source: ${options.sourceLabel}`,
@@ -110,6 +113,16 @@ export function generateRpcClientSource(options) {
110
113
  ...generateMethodNameLines(options.methods),
111
114
  `const rpcIdempotentMethods: ReadonlySet<RpcMethod> = new Set(${JSON.stringify(options.idempotentMethods)})`,
112
115
  '',
116
+ ...(hasFiles
117
+ ? [
118
+ '// Where each method carries file payloads: property paths into params/results ("*" descends into',
119
+ '// array items; an empty path means the value itself is the file). The client converts between',
120
+ '// native File values and the base64 wire envelope at exactly these positions.',
121
+ `const rpcParamsFilePaths: Partial<Record<RpcMethod, readonly (readonly string[])[]>> = ${JSON.stringify(options.paramsFilePaths)}`,
122
+ `const rpcResultFilePaths: Partial<Record<RpcMethod, readonly (readonly string[])[]>> = ${JSON.stringify(options.resultFilePaths)}`,
123
+ '',
124
+ ]
125
+ : []),
113
126
  'type JsonRpcId = string | number',
114
127
  '',
115
128
  'interface JsonRpcRequestEnvelope<M extends RpcMethod = RpcMethod> {',
@@ -328,8 +341,11 @@ export function generateRpcClientSource(options) {
328
341
  ' requestOptions?: RpcRequestOptions',
329
342
  ' ): Promise<RpcResult<M>> {',
330
343
  ' assertValidParams(method, params)',
344
+ ...(hasFiles ? [' const wireParams = await encodeFileParams(method, params)'] : []),
331
345
  ' const idempotencyKey = resolveIdempotencyKey(method, requestOptions?.idempotencyKey)',
332
- ' const request = createRequest(method, withIdempotencyKey(params, idempotencyKey), createId())',
346
+ hasFiles
347
+ ? ' const request = createRequest(method, withIdempotencyKey(wireParams, idempotencyKey), createId())'
348
+ : ' const request = createRequest(method, withIdempotencyKey(params, idempotencyKey), createId())',
333
349
  ' const context: RpcRequestContext = { methods: [method], batch: false }',
334
350
  ' const span = options.instrumentation?.startSpan(context)',
335
351
  ' try {',
@@ -343,7 +359,9 @@ export function generateRpcClientSource(options) {
343
359
  ' throw toRpcError(response.error)',
344
360
  ' }',
345
361
  '',
346
- ' return parseResult(method, response.result)',
362
+ hasFiles
363
+ ? ' return parseResult(method, decodeFileResult(method, response.result))'
364
+ : ' return parseResult(method, response.result)',
347
365
  ' } catch (error) {',
348
366
  ' span?.setError(error)',
349
367
  ' throw error',
@@ -365,13 +383,29 @@ export function generateRpcClientSource(options) {
365
383
  ' assertValidParams(item.method, item.params)',
366
384
  ' }',
367
385
  '',
368
- ' const requests = items.map((item) =>',
369
- ' createRequest(',
370
- ' item.method,',
371
- ' withIdempotencyKey(item.params, resolveIdempotencyKey(item.method, item.idempotencyKey)),',
372
- ' createId()',
373
- ' )',
374
- ' )',
386
+ ...(hasFiles
387
+ ? [
388
+ ' const requests: JsonRpcRequestEnvelope[] = []',
389
+ ' for (const item of items) {',
390
+ ' requests.push(createRequest(',
391
+ ' item.method,',
392
+ ' withIdempotencyKey(',
393
+ ' await encodeFileParams(item.method, item.params),',
394
+ ' resolveIdempotencyKey(item.method, item.idempotencyKey)',
395
+ ' ),',
396
+ ' createId()',
397
+ ' ))',
398
+ ' }',
399
+ ]
400
+ : [
401
+ ' const requests = items.map((item) =>',
402
+ ' createRequest(',
403
+ ' item.method,',
404
+ ' withIdempotencyKey(item.params, resolveIdempotencyKey(item.method, item.idempotencyKey)),',
405
+ ' createId()',
406
+ ' )',
407
+ ' )',
408
+ ]),
375
409
  ' const context: RpcRequestContext = { methods: items.map((item) => item.method), batch: true }',
376
410
  ' const span = options.instrumentation?.startSpan(context)',
377
411
  ' try {',
@@ -406,7 +440,9 @@ export function generateRpcClientSource(options) {
406
440
  ' }',
407
441
  '',
408
442
  ' try {',
409
- ' return { ok: true, result: parseResult(method, response.result) }',
443
+ hasFiles
444
+ ? ' return { ok: true, result: parseResult(method, decodeFileResult(method, response.result)) }'
445
+ : ' return { ok: true, result: parseResult(method, response.result) }',
410
446
  ' } catch (error) {',
411
447
  " return { ok: false, error: new RpcError(-32603, 'Invalid RPC result.', error) }",
412
448
  ' }',
@@ -447,6 +483,7 @@ export function generateRpcClientSource(options) {
447
483
  ` return { ...base, _meta: { ...existingMeta, [${JSON.stringify(IDEMPOTENCY_META_KEY)}]: key } }`,
448
484
  '}',
449
485
  '',
486
+ ...(hasFiles ? generateFileConversionLines() : []),
450
487
  'function setApiPath(root: Record<string, unknown>, method: string, value: unknown): void {',
451
488
  " const segments = method.split('.')",
452
489
  ' let node = root',
@@ -641,3 +678,103 @@ function generateMethodNameLines(methods) {
641
678
  '',
642
679
  ];
643
680
  }
681
+ // The runtime file-conversion block, emitted only for schemas that carry file payloads: native File values
682
+ // convert to the Elarion base64 envelope ({ contentType, fileName?, data }) after params validation, and
683
+ // results convert back before validation — so schemas and callers only ever see File. Every API used is
684
+ // standard in browsers and Node 20+ (File, atob/btoa, Uint8Array).
685
+ function generateFileConversionLines() {
686
+ return [
687
+ 'interface RpcFileEnvelope {',
688
+ ' readonly contentType: string',
689
+ ' readonly fileName?: string',
690
+ ' readonly data: string',
691
+ '}',
692
+ '',
693
+ 'async function encodeFileParams(method: RpcMethod, params: unknown): Promise<unknown> {',
694
+ ' const paths = rpcParamsFilePaths[method]',
695
+ ' if (!paths) return params',
696
+ ' let value = params',
697
+ ' for (const path of paths) {',
698
+ ' value = await encodeFilesAtPath(value, path, 0)',
699
+ ' }',
700
+ ' return value',
701
+ '}',
702
+ '',
703
+ 'function decodeFileResult(method: RpcMethod, result: unknown): unknown {',
704
+ ' const paths = rpcResultFilePaths[method]',
705
+ ' if (!paths) return result',
706
+ ' let value = result',
707
+ ' for (const path of paths) {',
708
+ ' value = decodeEnvelopesAtPath(value, path, 0)',
709
+ ' }',
710
+ ' return value',
711
+ '}',
712
+ '',
713
+ 'async function encodeFilesAtPath(value: unknown, path: readonly string[], index: number): Promise<unknown> {',
714
+ ' if (index === path.length) {',
715
+ ' return value instanceof File ? fileToEnvelope(value) : value',
716
+ ' }',
717
+ '',
718
+ ' const segment = path[index]',
719
+ " if (segment === '*') {",
720
+ ' if (!Array.isArray(value)) return value',
721
+ ' return Promise.all(value.map((item) => encodeFilesAtPath(item, path, index + 1)))',
722
+ ' }',
723
+ '',
724
+ ' if (!isRecord(value) || value[segment] === undefined) return value',
725
+ ' return { ...value, [segment]: await encodeFilesAtPath(value[segment], path, index + 1) }',
726
+ '}',
727
+ '',
728
+ 'function decodeEnvelopesAtPath(value: unknown, path: readonly string[], index: number): unknown {',
729
+ ' if (index === path.length) {',
730
+ ' return isFileEnvelope(value) ? envelopeToFile(value) : value',
731
+ ' }',
732
+ '',
733
+ ' const segment = path[index]',
734
+ " if (segment === '*') {",
735
+ ' if (!Array.isArray(value)) return value',
736
+ ' return value.map((item) => decodeEnvelopesAtPath(item, path, index + 1))',
737
+ ' }',
738
+ '',
739
+ ' if (!isRecord(value) || value[segment] === undefined) return value',
740
+ ' return { ...value, [segment]: decodeEnvelopesAtPath(value[segment], path, index + 1) }',
741
+ '}',
742
+ '',
743
+ 'function isFileEnvelope(value: unknown): value is RpcFileEnvelope {',
744
+ " return isRecord(value) && typeof value['contentType'] === 'string' && typeof value['data'] === 'string'",
745
+ '}',
746
+ '',
747
+ 'async function fileToEnvelope(file: File): Promise<RpcFileEnvelope> {',
748
+ ' const bytes = new Uint8Array(await file.arrayBuffer())',
749
+ " const contentType = file.type || 'application/octet-stream'",
750
+ ' return file.name',
751
+ ' ? { contentType, fileName: file.name, data: bytesToBase64(bytes) }',
752
+ ' : { contentType, data: bytesToBase64(bytes) }',
753
+ '}',
754
+ '',
755
+ 'function envelopeToFile(envelope: RpcFileEnvelope): File {',
756
+ " return new File([base64ToBytes(envelope.data)], envelope.fileName ?? '', { type: envelope.contentType })",
757
+ '}',
758
+ '',
759
+ 'function bytesToBase64(bytes: Uint8Array): string {',
760
+ " let binary = ''",
761
+ ' const chunkSize = 0x8000',
762
+ ' for (let offset = 0; offset < bytes.length; offset += chunkSize) {',
763
+ ' binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize))',
764
+ ' }',
765
+ ' return btoa(binary)',
766
+ '}',
767
+ '',
768
+ // No return annotation: inference yields Uint8Array<ArrayBuffer> under TS 5.7+ (required by BlobPart)
769
+ // while staying valid for consumers on older TypeScript where Uint8Array is not generic.
770
+ 'function base64ToBytes(base64: string) {',
771
+ ' const binary = atob(base64)',
772
+ ' const bytes = new Uint8Array(binary.length)',
773
+ ' for (let index = 0; index < binary.length; index += 1) {',
774
+ ' bytes[index] = binary.charCodeAt(index)',
775
+ ' }',
776
+ ' return bytes',
777
+ '}',
778
+ '',
779
+ ];
780
+ }
package/dist/schema.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface JsonSchema {
10
10
  oneOf?: unknown[];
11
11
  anyOf?: unknown[];
12
12
  allOf?: unknown[];
13
+ /**
14
+ * Marks the Elarion binary-file envelope (`{ contentType, fileName?, data }`, base64 `data`), emitted by the
15
+ * server exporter for `ElarionFile` payloads. The generator maps it to a native `File`: params accept a
16
+ * `File`, results materialize one; the client converts to/from the wire envelope at the call boundary.
17
+ */
18
+ 'x-elarion-file'?: boolean;
13
19
  minLength?: number;
14
20
  maxLength?: number;
15
21
  pattern?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmesberger/elarion-jsonrpc-client-generator",
3
- "version": "0.2.3-preview.76.1",
3
+ "version": "0.2.3-preview.77.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",