@swimmesberger/elarion-jsonrpc-client-generator 0.2.21 → 0.2.22-preview.191.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
@@ -87,7 +87,9 @@ When the schema carries an `events` block (the host registers Elarion client-eve
87
87
  additionally emits `events-client.ts`: `createElarionEvents(...)` multiplexes topic-typed subscriptions over one
88
88
  `EventSource`, validates incoming payloads with the generated Zod schemas (`rpcEventPayloadSchemas`), and exposes
89
89
  `$client.onConnected(...)` — fired on connect and every reconnect — as the "you may have missed events, re-query"
90
- signal. Event-free schemas produce byte-identical output with no events file.
90
+ signal. The platform `EventSource` stops retrying when an answer is not a 200 event stream (a 401, a login page, a
91
+ 502 during a restart), so the client reopens such a connection itself, backing off from 1 s to 30 s
92
+ (`reconnectDelay`). Event-free schemas produce byte-identical output with no events file.
91
93
 
92
94
  ```ts
93
95
  import { createElarionEvents } from './generated/events-client'
@@ -23,6 +23,8 @@ export function generateEventsClientSource(options) {
23
23
  'export interface ElarionEventSourceLike {',
24
24
  ' addEventListener(type: string, listener: (event: { readonly data?: unknown }) => void): void',
25
25
  ' close(): void',
26
+ ' // 2 (CLOSED) once the source gave up for good; a source without it is never reconnected by the client.',
27
+ ' readonly readyState?: number',
26
28
  '}',
27
29
  '',
28
30
  'export type ElarionEventSourceFactory = (url: string) => ElarionEventSourceLike',
@@ -35,6 +37,9 @@ export function generateEventsClientSource(options) {
35
37
  ' readonly validateEvents?: boolean',
36
38
  ' // Called when an incoming event cannot be parsed or validated; the event is dropped either way.',
37
39
  ' readonly onEventError?: (topic: RpcEventTopic, error: unknown) => void',
40
+ ' // Milliseconds to wait before reopening a connection the platform EventSource gave up on, by attempt',
41
+ ' // (0-based, reset by every elarion.connected). Defaults to 1 s doubling up to 30 s.',
42
+ ' readonly reconnectDelay?: (attempt: number) => number',
38
43
  '}',
39
44
  '',
40
45
  'export interface ElarionEventSubscribeOptions {',
@@ -76,6 +81,8 @@ export function generateEventsClientSource(options) {
76
81
  '] as const satisfies readonly RpcEventTopic[]',
77
82
  '',
78
83
  "const CONNECTED_EVENT_TYPE = 'elarion.connected'",
84
+ '// EventSource.CLOSED — a literal, so the client needs no EventSource global to compare against.',
85
+ 'const EVENT_SOURCE_CLOSED = 2',
79
86
  '',
80
87
  'interface EventSubscriptionEntry {',
81
88
  ' readonly topic: RpcEventTopic',
@@ -102,11 +109,14 @@ export function generateEventsClientSource(options) {
102
109
  'export function createElarionEventsClient(options: ElarionEventsOptions): ElarionEventsClient {',
103
110
  ' const createSource = options.eventSource ?? defaultEventSourceFactory',
104
111
  ' const validateEvents = options.validateEvents ?? true',
112
+ ' const reconnectDelay = options.reconnectDelay ?? defaultReconnectDelay',
105
113
  '',
106
114
  ' const subscriptions = new Map<string, EventSubscriptionEntry>()',
107
115
  ' const connectedHandlers = new Set<() => void>()',
108
116
  ' let source: ElarionEventSourceLike | undefined',
109
117
  ' let reconnectScheduled = false',
118
+ ' let retryTimer: ReturnType<typeof setTimeout> | undefined',
119
+ ' let failedAttempts = 0',
110
120
  ' let closed = false',
111
121
  '',
112
122
  ' // Subscription changes coalesce onto a microtask, so subscribing N topics during setup opens one',
@@ -120,7 +130,25 @@ export function generateEventsClientSource(options) {
120
130
  ' })',
121
131
  ' }',
122
132
  '',
133
+ ' // The platform EventSource retries a dropped connection by itself, but fails it for good (readyState',
134
+ ' // CLOSED, no further retries) when the answer is not a 200 event stream: an expired session\'s 401 or',
135
+ ' // login page, a 502 while the server restarts. Without a retry here the client would stay silent until',
136
+ ' // the page reloads.',
137
+ ' function scheduleRetry(): void {',
138
+ ' if (retryTimer !== undefined || closed) return',
139
+ ' const delay = reconnectDelay(failedAttempts)',
140
+ ' failedAttempts += 1',
141
+ ' retryTimer = setTimeout(() => {',
142
+ ' retryTimer = undefined',
143
+ ' if (!closed) connect()',
144
+ ' }, delay)',
145
+ ' }',
146
+ '',
123
147
  ' function connect(): void {',
148
+ ' if (retryTimer !== undefined) {',
149
+ ' clearTimeout(retryTimer)',
150
+ ' retryTimer = undefined',
151
+ ' }',
124
152
  ' source?.close()',
125
153
  ' source = undefined',
126
154
  ' if (subscriptions.size === 0) return',
@@ -134,8 +162,12 @@ export function generateEventsClientSource(options) {
134
162
  ' next.addEventListener(topic, (event) => dispatch(topic, event))',
135
163
  ' }',
136
164
  ' next.addEventListener(CONNECTED_EVENT_TYPE, () => {',
165
+ ' failedAttempts = 0',
137
166
  ' for (const handler of [...connectedHandlers]) handler()',
138
167
  ' })',
168
+ " next.addEventListener('error', () => {",
169
+ ' if (source === next && next.readyState === EVENT_SOURCE_CLOSED) scheduleRetry()',
170
+ ' })',
139
171
  ' source = next',
140
172
  ' }',
141
173
  '',
@@ -208,6 +240,8 @@ export function generateEventsClientSource(options) {
208
240
  ' },',
209
241
  ' close(): void {',
210
242
  ' closed = true',
243
+ ' if (retryTimer !== undefined) clearTimeout(retryTimer)',
244
+ ' retryTimer = undefined',
211
245
  ' source?.close()',
212
246
  ' source = undefined',
213
247
  ' subscriptions.clear()',
@@ -216,6 +250,10 @@ export function generateEventsClientSource(options) {
216
250
  ' }',
217
251
  '}',
218
252
  '',
253
+ 'function defaultReconnectDelay(attempt: number): number {',
254
+ ' return Math.min(30_000, 1_000 * 2 ** attempt)',
255
+ '}',
256
+ '',
219
257
  'function defaultEventSourceFactory(url: string): ElarionEventSourceLike {',
220
258
  ' const ctor = (globalThis as { EventSource?: new (url: string) => ElarionEventSourceLike }).EventSource',
221
259
  ' if (!ctor) {',
package/dist/generate.js CHANGED
@@ -126,6 +126,24 @@ export function generateRpcClientFiles(schema, options = {}) {
126
126
  }
127
127
  }
128
128
  const idempotentMethods = methods.filter((method) => schema.methods[method].idempotent === true);
129
+ // Every method's client-visible facts, normalized so a consumer never has to tell "absent" from "false".
130
+ const methodInfo = {};
131
+ for (const method of methods) {
132
+ const definition = schema.methods[method];
133
+ const requires = definition.requires ?? {};
134
+ methodInfo[method] = {
135
+ readOnly: definition.readOnly === true,
136
+ idempotent: definition.idempotent === true,
137
+ requires: {
138
+ anonymous: requires.anonymous === true,
139
+ authenticated: requires.authenticated === true,
140
+ permissions: [...(requires.permissions ?? [])].sort(),
141
+ roles: [...(requires.roles ?? [])].sort(),
142
+ serverChecks: requires.serverChecks === true,
143
+ },
144
+ tags: Object.fromEntries(Object.entries(definition.tags ?? {}).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))),
145
+ };
146
+ }
129
147
  // Where each method carries file payloads (x-elarion-file nodes), so the client can convert between
130
148
  // native File values and the base64 wire envelope. Methods without files are omitted entirely.
131
149
  const paramsFilePaths = {};
@@ -148,6 +166,7 @@ export function generateRpcClientFiles(schema, options = {}) {
148
166
  schemasFileName,
149
167
  methods,
150
168
  idempotentMethods,
169
+ methodInfo,
151
170
  errorCodesByMethod,
152
171
  paramsFilePaths,
153
172
  resultFilePaths,
@@ -1,3 +1,16 @@
1
+ /** The normalized client-visible facts of one method, emitted verbatim into `rpcMethodInfo`. */
2
+ export interface RpcMethodInfoLiteral {
3
+ readOnly: boolean;
4
+ idempotent: boolean;
5
+ requires: {
6
+ anonymous: boolean;
7
+ authenticated: boolean;
8
+ permissions: string[];
9
+ roles: string[];
10
+ serverChecks: boolean;
11
+ };
12
+ tags: Record<string, string>;
13
+ }
1
14
  interface GenerateRpcClientSourceOptions {
2
15
  generatedBy: string;
3
16
  sourceLabel: string;
@@ -5,6 +18,8 @@ interface GenerateRpcClientSourceOptions {
5
18
  schemasFileName: string;
6
19
  methods: readonly string[];
7
20
  idempotentMethods: readonly string[];
21
+ /** Every method's normalized requirements, read-only flag and tags. */
22
+ methodInfo: Readonly<Record<string, RpcMethodInfoLiteral>>;
8
23
  /** The declared error codes per method (ADR-0080); methods without declared errors are omitted. */
9
24
  errorCodesByMethod: Readonly<Record<string, readonly string[]>>;
10
25
  /** Property paths of file payloads per method ('*' = array items, [] = the value itself); omitted methods carry none. */
@@ -120,6 +120,39 @@ export function generateRpcClientSource(options) {
120
120
  ...generateMethodNameLines(options.methods),
121
121
  `const rpcIdempotentMethods: ReadonlySet<RpcMethod> = new Set(${JSON.stringify(options.idempotentMethods)})`,
122
122
  '',
123
+ '/**',
124
+ ' * What a caller needs, as far as a client can tell: `anonymous` (any caller), or `authenticated` with every',
125
+ ' * permission and role listed. `serverChecks` marks requirements only the server evaluates (a policy, claim or',
126
+ ' * resource check), so an allowed-looking call may still be refused.',
127
+ ' */',
128
+ 'export interface RpcMethodRequirements {',
129
+ ' readonly anonymous: boolean',
130
+ ' readonly authenticated: boolean',
131
+ ' readonly permissions: readonly string[]',
132
+ ' readonly roles: readonly string[]',
133
+ ' readonly serverChecks: boolean',
134
+ '}',
135
+ '',
136
+ '/** What the schema says about a method before it is called. */',
137
+ 'export interface RpcMethodInfo {',
138
+ ' /** The request is a query: the operation only reads. */',
139
+ ' readonly readOnly: boolean',
140
+ ' /** The operation is [Idempotent]: the client attaches an idempotency key by default. */',
141
+ ' readonly idempotent: boolean',
142
+ ' readonly requires: RpcMethodRequirements',
143
+ ' /** The handler\'s [ClientTag] hints (for example a timeout) by key. */',
144
+ ' readonly tags: Readonly<Record<string, string>>',
145
+ '}',
146
+ '',
147
+ '/**',
148
+ ' * Every method\'s requirements, read-only flag and tags, from the server\'s handler declarations — the input of',
149
+ ' * UI gating (for example canCall from @swimmesberger/elarion-contributions). A UX projection, never security:',
150
+ ' * the server authorizes every call.',
151
+ ' */',
152
+ 'export const rpcMethodInfo: { readonly [M in RpcMethod]: RpcMethodInfo } = {',
153
+ ...options.methods.map((method) => ` ${JSON.stringify(method)}: ${JSON.stringify(options.methodInfo[method])},`),
154
+ '}',
155
+ '',
123
156
  `const rpcErrorCodes: Partial<Record<RpcMethod, readonly string[]>> = ${JSON.stringify(options.errorCodesByMethod)}`,
124
157
  '',
125
158
  '/** True when `error` is an RpcError carrying one of the error codes `method` declares. */',
@@ -180,6 +213,11 @@ export function generateRpcClientSource(options) {
180
213
  ' readonly data: TData',
181
214
  ' /** The numeric JSON-RPC error code (the coarse error kind, see ElarionErrorCodes). */',
182
215
  ' readonly rpcCode: number',
216
+ ' /**',
217
+ ' * The support reference of an unexpected server error (code "internal"): show it next to the message so a',
218
+ ' * user can quote it. Undefined for every other error.',
219
+ ' */',
220
+ ' readonly reference: string | undefined',
183
221
  '',
184
222
  ' constructor(rpcCode: number, message: string, wireData?: unknown) {',
185
223
  ' super(message)',
@@ -188,6 +226,7 @@ export function generateRpcClientSource(options) {
188
226
  ' const envelope = readErrorData(wireData)',
189
227
  ' this.code = (envelope?.code ?? defaultErrorCode(rpcCode)) as TCode',
190
228
  ' this.data = envelope?.data as TData',
229
+ ' this.reference = envelope?.reference',
191
230
  ' }',
192
231
  '',
193
232
  ' get isParseError() {',
@@ -677,10 +716,11 @@ export function generateRpcClientSource(options) {
677
716
  ' return rpcError',
678
717
  '}',
679
718
  '',
680
- '// The wire shape of error.data is always { code, data? } (ADR-0080).',
681
- 'function readErrorData(value: unknown): { code: string; data?: unknown } | undefined {',
719
+ '// The wire shape of error.data is always { code, data?, reference? } (ADR-0080).',
720
+ 'function readErrorData(value: unknown): { code: string; data?: unknown; reference?: string | undefined } | undefined {',
682
721
  ' if (!isRecord(value) || typeof value.code !== ' + "'string'" + ') return undefined',
683
- ' return { code: value.code, data: value.data }',
722
+ " const reference = typeof value.reference === 'string' ? value.reference : undefined",
723
+ ' return { code: value.code, data: value.data, reference }',
684
724
  '}',
685
725
  '',
686
726
  '// Fallback for a server that sends no error.data: the default code of the numeric kind.',
package/dist/schema.d.ts CHANGED
@@ -47,6 +47,24 @@ export interface RpcMethodSchema {
47
47
  /** The declared error contract, keyed by stable error code; omitted when the method declares none. */
48
48
  errors?: Record<string, RpcErrorSchema>;
49
49
  idempotent?: boolean;
50
+ /** What a caller needs, as far as a client can tell; omitted when the handler declares nothing. */
51
+ requires?: RpcMethodRequirementsSchema;
52
+ /** Set when the request is an IQuery: the operation only reads. */
53
+ readOnly?: boolean;
54
+ /** The handler's [ClientTag] hints by key; omitted when it declares none. */
55
+ tags?: Record<string, string>;
56
+ }
57
+ /**
58
+ * The per-method `requires` block of the server exporter: `anonymous` alone, or the requirements a client can
59
+ * evaluate against its session (`authenticated`, `permissions`, `roles`, all of them) plus `serverChecks` for
60
+ * requirements only the server can evaluate (a policy, claim or resource check).
61
+ */
62
+ export interface RpcMethodRequirementsSchema {
63
+ anonymous?: boolean;
64
+ authenticated?: boolean;
65
+ permissions?: string[];
66
+ roles?: string[];
67
+ serverChecks?: boolean;
50
68
  }
51
69
  /** One structured permission from the schema's capability vocabulary ({resource}.{verb} plus its parts). */
52
70
  export interface RpcCapabilityPermission {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swimmesberger/elarion-jsonrpc-client-generator",
3
- "version": "0.2.21",
3
+ "version": "0.2.22-preview.191.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",