@swimmesberger/elarion-jsonrpc-client-generator 0.2.3-preview.83.1 → 0.2.3-preview.85.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 +12 -0
- package/dist/cli.js +12 -0
- package/dist/events-client-source.d.ts +15 -0
- package/dist/events-client-source.js +314 -0
- package/dist/generate.js +30 -0
- package/dist/schema.d.ts +17 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -65,6 +65,18 @@ Result validation is enabled by default through `rpcResultSchemas`. Use `transfo
|
|
|
65
65
|
|
|
66
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.
|
|
67
67
|
|
|
68
|
+
## Client-event subscriptions
|
|
69
|
+
|
|
70
|
+
When the schema carries an `events` block (the host registers Elarion client-event topics, ADR-0043), the generator additionally emits `events-client.ts`: `createElarionEvents(...)` multiplexes topic-typed subscriptions over one `EventSource`, validates incoming payloads with the generated Zod schemas (`rpcEventPayloadSchemas`), and exposes `$client.onConnected(...)` — fired on connect and every reconnect — as the "you may have missed events, re-query" signal. Event-free schemas produce byte-identical output with no events file.
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { createElarionEvents } from './generated/events-client'
|
|
74
|
+
|
|
75
|
+
const events = createElarionEvents({ url: '/events' })
|
|
76
|
+
events.invoicing.invoiceChanged.subscribe((evt) => refetch(evt.invoiceId))
|
|
77
|
+
events.$client.onConnected(() => refetchAll())
|
|
78
|
+
```
|
|
79
|
+
|
|
68
80
|
## Error handling
|
|
69
81
|
|
|
70
82
|
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:
|
package/dist/cli.js
CHANGED
|
@@ -56,6 +56,11 @@ function parseArgs(argv) {
|
|
|
56
56
|
index += 1;
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
|
+
if (arg === '--events-client') {
|
|
60
|
+
options.eventsClientFileName = next;
|
|
61
|
+
index += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
59
64
|
if (arg === '--framework') {
|
|
60
65
|
if (!SUPPORTED_FRAMEWORKS.includes(next)) {
|
|
61
66
|
throw new Error(`Unsupported framework ${next}. Supported: ${SUPPORTED_FRAMEWORKS.join(', ')}`);
|
|
@@ -89,6 +94,8 @@ Options:
|
|
|
89
94
|
--client <file> Fetch client filename (default: rpc-client.ts)
|
|
90
95
|
--session-client <file> Client-capability snapshot client + OpenFeature provider (default: session-client.ts;
|
|
91
96
|
emitted only when the schema exposes the elarion.session operation)
|
|
97
|
+
--events-client <file> Typed client-event subscription client (default: events-client.ts;
|
|
98
|
+
emitted only when the schema declares an events block)
|
|
92
99
|
--framework <name> Emit an opt-in framework adapter alongside the neutral core client.
|
|
93
100
|
Supported: tanstack-start (needs the @tanstack/react-start peer dependency)
|
|
94
101
|
--framework-adapter <file> Framework adapter filename (default: start-adapter.ts)
|
|
@@ -127,6 +134,7 @@ function generateOnce(options) {
|
|
|
127
134
|
schemasFileName: options.schemasFileName,
|
|
128
135
|
clientFileName: options.clientFileName,
|
|
129
136
|
sessionClientFileName: options.sessionClientFileName,
|
|
137
|
+
eventsClientFileName: options.eventsClientFileName,
|
|
130
138
|
framework: options.framework,
|
|
131
139
|
frameworkAdapterFileName: options.frameworkAdapterFileName,
|
|
132
140
|
});
|
|
@@ -139,6 +147,10 @@ function generateOnce(options) {
|
|
|
139
147
|
writeFileSync(resolve(outDir, generated.sessionClientFileName), generated.sessionClientSource, 'utf-8');
|
|
140
148
|
extraNotes.push(generated.sessionClientFileName);
|
|
141
149
|
}
|
|
150
|
+
if (generated.eventsClientFileName !== undefined && generated.eventsClientSource !== undefined) {
|
|
151
|
+
writeFileSync(resolve(outDir, generated.eventsClientFileName), generated.eventsClientSource, 'utf-8');
|
|
152
|
+
extraNotes.push(generated.eventsClientFileName);
|
|
153
|
+
}
|
|
142
154
|
if (generated.frameworkAdapterFileName !== undefined && generated.frameworkAdapterSource !== undefined) {
|
|
143
155
|
writeFileSync(resolve(outDir, generated.frameworkAdapterFileName), generated.frameworkAdapterSource, 'utf-8');
|
|
144
156
|
extraNotes.push(generated.frameworkAdapterFileName);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
interface GenerateEventsClientSourceOptions {
|
|
2
|
+
generatedBy: string;
|
|
3
|
+
sourceLabel: string;
|
|
4
|
+
schemasFileName: string;
|
|
5
|
+
/** Sorted topic names from the schema's `events` block. */
|
|
6
|
+
topics: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Generates the typed client-event subscription client (ADR-0043) from the schema's `events` block: topic
|
|
10
|
+
* literal types, `z.infer` payload types over the Zod payload schemas (emitted into the schemas file), and
|
|
11
|
+
* an `EventSource` wrapper that multiplexes the authorized subscriptions over one connection. Emitted only
|
|
12
|
+
* when the schema declares events, so event-free schemas produce byte-identical output.
|
|
13
|
+
*/
|
|
14
|
+
export declare function generateEventsClientSource(options: GenerateEventsClientSourceOptions): string;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import { moduleSpecifier } from './rpc-client-source.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generates the typed client-event subscription client (ADR-0043) from the schema's `events` block: topic
|
|
4
|
+
* literal types, `z.infer` payload types over the Zod payload schemas (emitted into the schemas file), and
|
|
5
|
+
* an `EventSource` wrapper that multiplexes the authorized subscriptions over one connection. Emitted only
|
|
6
|
+
* when the schema declares events, so event-free schemas produce byte-identical output.
|
|
7
|
+
*/
|
|
8
|
+
export function generateEventsClientSource(options) {
|
|
9
|
+
const schemasImport = moduleSpecifier(options.schemasFileName);
|
|
10
|
+
const topicTree = buildTopicTree(options.topics);
|
|
11
|
+
return [
|
|
12
|
+
`// Auto-generated by ${options.generatedBy} — DO NOT EDIT`,
|
|
13
|
+
`// Source: ${options.sourceLabel}`,
|
|
14
|
+
'',
|
|
15
|
+
"import type { z } from 'zod'",
|
|
16
|
+
`import { rpcEventPayloadSchemas } from '${schemasImport}'`,
|
|
17
|
+
'',
|
|
18
|
+
'export type RpcEventTopic = keyof typeof rpcEventPayloadSchemas & string',
|
|
19
|
+
'export type RpcEventPayload<T extends RpcEventTopic> = z.infer<(typeof rpcEventPayloadSchemas)[T]>',
|
|
20
|
+
'',
|
|
21
|
+
'// The structural surface of the platform EventSource this client needs — injectable for tests,',
|
|
22
|
+
'// polyfills, or a withCredentials/auth wrapper.',
|
|
23
|
+
'export interface ElarionEventSourceLike {',
|
|
24
|
+
' addEventListener(type: string, listener: (event: { readonly data?: unknown }) => void): void',
|
|
25
|
+
' close(): void',
|
|
26
|
+
'}',
|
|
27
|
+
'',
|
|
28
|
+
'export type ElarionEventSourceFactory = (url: string) => ElarionEventSourceLike',
|
|
29
|
+
'',
|
|
30
|
+
'export interface ElarionEventsOptions {',
|
|
31
|
+
' readonly url: string | URL',
|
|
32
|
+
' // Factory over the platform EventSource (defaults to globalThis.EventSource).',
|
|
33
|
+
' readonly eventSource?: ElarionEventSourceFactory',
|
|
34
|
+
' // Validate incoming payloads against the generated Zod payload schemas. Defaults to true.',
|
|
35
|
+
' readonly validateEvents?: boolean',
|
|
36
|
+
' // Called when an incoming event cannot be parsed or validated; the event is dropped either way.',
|
|
37
|
+
' readonly onEventError?: (topic: RpcEventTopic, error: unknown) => void',
|
|
38
|
+
'}',
|
|
39
|
+
'',
|
|
40
|
+
'export interface ElarionEventSubscribeOptions {',
|
|
41
|
+
' // Subscribe the topic within an application-defined resource scope (e.g. "customer:42"); the server',
|
|
42
|
+
' // authorizes it fail-closed. Omitted, the subscription observes the global scope plus your own user scope.',
|
|
43
|
+
' readonly resource?: string',
|
|
44
|
+
'}',
|
|
45
|
+
'',
|
|
46
|
+
'export type ElarionEventHandler<T extends RpcEventTopic> = (payload: RpcEventPayload<T>) => void',
|
|
47
|
+
'export type ElarionUnsubscribe = () => void',
|
|
48
|
+
'',
|
|
49
|
+
'// Client events are at-most-once hints: payloads carry ids/refs, and the client converges by re-running',
|
|
50
|
+
'// queries. Treat every onConnected callback (initial connect and every automatic reconnect) as "you may',
|
|
51
|
+
'// have missed events" and re-query — with TanStack Query, invalidate.',
|
|
52
|
+
'export interface ElarionEventsClient {',
|
|
53
|
+
' subscribe<T extends RpcEventTopic>(topic: T, handler: ElarionEventHandler<T>): ElarionUnsubscribe',
|
|
54
|
+
' subscribe<T extends RpcEventTopic>(',
|
|
55
|
+
' topic: T,',
|
|
56
|
+
' options: ElarionEventSubscribeOptions,',
|
|
57
|
+
' handler: ElarionEventHandler<T>',
|
|
58
|
+
' ): ElarionUnsubscribe',
|
|
59
|
+
' // Fires on the server\'s elarion.connected control event — once per (re)connect.',
|
|
60
|
+
' onConnected(handler: () => void): ElarionUnsubscribe',
|
|
61
|
+
' close(): void',
|
|
62
|
+
'}',
|
|
63
|
+
'',
|
|
64
|
+
'export interface ElarionEventTopicApi<T extends RpcEventTopic> {',
|
|
65
|
+
' subscribe(handler: ElarionEventHandler<T>): ElarionUnsubscribe',
|
|
66
|
+
' subscribe(options: ElarionEventSubscribeOptions, handler: ElarionEventHandler<T>): ElarionUnsubscribe',
|
|
67
|
+
'}',
|
|
68
|
+
'',
|
|
69
|
+
'export interface ElarionEventsApi {',
|
|
70
|
+
' readonly $client: ElarionEventsClient',
|
|
71
|
+
...emitProperties(topicTree, 1),
|
|
72
|
+
'}',
|
|
73
|
+
'',
|
|
74
|
+
'const rpcEventTopicNames = [',
|
|
75
|
+
...options.topics.map((topic) => ` ${JSON.stringify(topic)},`),
|
|
76
|
+
'] as const satisfies readonly RpcEventTopic[]',
|
|
77
|
+
'',
|
|
78
|
+
"const CONNECTED_EVENT_TYPE = 'elarion.connected'",
|
|
79
|
+
'',
|
|
80
|
+
'interface EventSubscriptionEntry {',
|
|
81
|
+
' readonly topic: RpcEventTopic',
|
|
82
|
+
' readonly resource?: string',
|
|
83
|
+
' readonly handlers: Set<(payload: unknown) => void>',
|
|
84
|
+
'}',
|
|
85
|
+
'',
|
|
86
|
+
'export function createElarionEvents(options: ElarionEventsOptions): ElarionEventsApi {',
|
|
87
|
+
' const client = createElarionEventsClient(options)',
|
|
88
|
+
' const topics: Record<string, unknown> = {}',
|
|
89
|
+
'',
|
|
90
|
+
' for (const topic of rpcEventTopicNames) {',
|
|
91
|
+
' setTopicPath(topics, topic, {',
|
|
92
|
+
' subscribe: (',
|
|
93
|
+
' optionsOrHandler: ElarionEventSubscribeOptions | ElarionEventHandler<typeof topic>,',
|
|
94
|
+
' maybeHandler?: ElarionEventHandler<typeof topic>',
|
|
95
|
+
' ) => client.subscribe(topic, optionsOrHandler as ElarionEventSubscribeOptions, maybeHandler as ElarionEventHandler<typeof topic>),',
|
|
96
|
+
' })',
|
|
97
|
+
' }',
|
|
98
|
+
'',
|
|
99
|
+
' return { ...topics, $client: client } as unknown as ElarionEventsApi',
|
|
100
|
+
'}',
|
|
101
|
+
'',
|
|
102
|
+
'export function createElarionEventsClient(options: ElarionEventsOptions): ElarionEventsClient {',
|
|
103
|
+
' const createSource = options.eventSource ?? defaultEventSourceFactory',
|
|
104
|
+
' const validateEvents = options.validateEvents ?? true',
|
|
105
|
+
'',
|
|
106
|
+
' const subscriptions = new Map<string, EventSubscriptionEntry>()',
|
|
107
|
+
' const connectedHandlers = new Set<() => void>()',
|
|
108
|
+
' let source: ElarionEventSourceLike | undefined',
|
|
109
|
+
' let reconnectScheduled = false',
|
|
110
|
+
' let closed = false',
|
|
111
|
+
'',
|
|
112
|
+
' // Subscription changes coalesce onto a microtask, so subscribing N topics during setup opens one',
|
|
113
|
+
' // connection, not N. EventSource cannot change its URL, so a changed subscription set reconnects.',
|
|
114
|
+
' function scheduleReconnect(): void {',
|
|
115
|
+
' if (reconnectScheduled || closed) return',
|
|
116
|
+
' reconnectScheduled = true',
|
|
117
|
+
' queueMicrotask(() => {',
|
|
118
|
+
' reconnectScheduled = false',
|
|
119
|
+
' if (!closed) connect()',
|
|
120
|
+
' })',
|
|
121
|
+
' }',
|
|
122
|
+
'',
|
|
123
|
+
' function connect(): void {',
|
|
124
|
+
' source?.close()',
|
|
125
|
+
' source = undefined',
|
|
126
|
+
' if (subscriptions.size === 0) return',
|
|
127
|
+
'',
|
|
128
|
+
' const requested = Array.from(subscriptions.values(), (entry) =>',
|
|
129
|
+
' entry.resource === undefined ? { topic: entry.topic } : { topic: entry.topic, resource: entry.resource }',
|
|
130
|
+
' ).sort(compareSubscriptions)',
|
|
131
|
+
'',
|
|
132
|
+
' const next = createSource(appendSubscriptions(String(options.url), requested))',
|
|
133
|
+
' for (const topic of new Set(Array.from(subscriptions.values(), (entry) => entry.topic))) {',
|
|
134
|
+
' next.addEventListener(topic, (event) => dispatch(topic, event))',
|
|
135
|
+
' }',
|
|
136
|
+
' next.addEventListener(CONNECTED_EVENT_TYPE, () => {',
|
|
137
|
+
' for (const handler of [...connectedHandlers]) handler()',
|
|
138
|
+
' })',
|
|
139
|
+
' source = next',
|
|
140
|
+
' }',
|
|
141
|
+
'',
|
|
142
|
+
' function dispatch(topic: RpcEventTopic, event: { readonly data?: unknown }): void {',
|
|
143
|
+
' let payload: unknown',
|
|
144
|
+
' try {',
|
|
145
|
+
' payload = JSON.parse(String(event.data))',
|
|
146
|
+
' if (validateEvents) {',
|
|
147
|
+
' payload = rpcEventPayloadSchemas[topic].parse(payload)',
|
|
148
|
+
' }',
|
|
149
|
+
' } catch (error) {',
|
|
150
|
+
' options.onEventError?.(topic, error)',
|
|
151
|
+
' return',
|
|
152
|
+
' }',
|
|
153
|
+
'',
|
|
154
|
+
' // Handlers match by topic only: the server already filters by the connection\'s subscribed scopes,',
|
|
155
|
+
' // but the wire frame carries no scope — handlers on the same topic see every matching event.',
|
|
156
|
+
' for (const entry of subscriptions.values()) {',
|
|
157
|
+
' if (entry.topic !== topic) continue',
|
|
158
|
+
' for (const handler of [...entry.handlers]) handler(payload)',
|
|
159
|
+
' }',
|
|
160
|
+
' }',
|
|
161
|
+
'',
|
|
162
|
+
' function subscribe<T extends RpcEventTopic>(',
|
|
163
|
+
' topic: T,',
|
|
164
|
+
' optionsOrHandler: ElarionEventSubscribeOptions | ElarionEventHandler<T>,',
|
|
165
|
+
' maybeHandler?: ElarionEventHandler<T>',
|
|
166
|
+
' ): ElarionUnsubscribe {',
|
|
167
|
+
" const subscribeOptions = typeof optionsOrHandler === 'function' ? undefined : optionsOrHandler",
|
|
168
|
+
" const handler = typeof optionsOrHandler === 'function' ? optionsOrHandler : maybeHandler",
|
|
169
|
+
" if (typeof handler !== 'function') {",
|
|
170
|
+
" throw new Error('subscribe requires a handler function.')",
|
|
171
|
+
' }',
|
|
172
|
+
' if (closed) {',
|
|
173
|
+
" throw new Error('The events client is closed.')",
|
|
174
|
+
' }',
|
|
175
|
+
'',
|
|
176
|
+
' const key = subscriptionKey(topic, subscribeOptions?.resource)',
|
|
177
|
+
' let entry = subscriptions.get(key)',
|
|
178
|
+
' if (entry === undefined) {',
|
|
179
|
+
' entry = { topic, resource: subscribeOptions?.resource, handlers: new Set() }',
|
|
180
|
+
' subscriptions.set(key, entry)',
|
|
181
|
+
' scheduleReconnect()',
|
|
182
|
+
' }',
|
|
183
|
+
'',
|
|
184
|
+
' // A fresh wrapper per call, so subscribing the same function twice yields two live subscriptions.',
|
|
185
|
+
' const wrapped = (payload: unknown) => handler(payload as RpcEventPayload<T>)',
|
|
186
|
+
' entry.handlers.add(wrapped)',
|
|
187
|
+
' const current = entry',
|
|
188
|
+
'',
|
|
189
|
+
' let active = true',
|
|
190
|
+
' return () => {',
|
|
191
|
+
' if (!active) return',
|
|
192
|
+
' active = false',
|
|
193
|
+
' current.handlers.delete(wrapped)',
|
|
194
|
+
' if (current.handlers.size === 0 && subscriptions.get(key) === current) {',
|
|
195
|
+
' subscriptions.delete(key)',
|
|
196
|
+
' scheduleReconnect()',
|
|
197
|
+
' }',
|
|
198
|
+
' }',
|
|
199
|
+
' }',
|
|
200
|
+
'',
|
|
201
|
+
' return {',
|
|
202
|
+
" subscribe: subscribe as ElarionEventsClient['subscribe'],",
|
|
203
|
+
' onConnected(handler: () => void): ElarionUnsubscribe {',
|
|
204
|
+
' connectedHandlers.add(handler)',
|
|
205
|
+
' return () => {',
|
|
206
|
+
' connectedHandlers.delete(handler)',
|
|
207
|
+
' }',
|
|
208
|
+
' },',
|
|
209
|
+
' close(): void {',
|
|
210
|
+
' closed = true',
|
|
211
|
+
' source?.close()',
|
|
212
|
+
' source = undefined',
|
|
213
|
+
' subscriptions.clear()',
|
|
214
|
+
' connectedHandlers.clear()',
|
|
215
|
+
' },',
|
|
216
|
+
' }',
|
|
217
|
+
'}',
|
|
218
|
+
'',
|
|
219
|
+
'function defaultEventSourceFactory(url: string): ElarionEventSourceLike {',
|
|
220
|
+
' const ctor = (globalThis as { EventSource?: new (url: string) => ElarionEventSourceLike }).EventSource',
|
|
221
|
+
' if (!ctor) {',
|
|
222
|
+
" throw new Error('No EventSource implementation is available. Pass eventSource in createElarionEvents options.')",
|
|
223
|
+
' }',
|
|
224
|
+
' return new ctor(url)',
|
|
225
|
+
'}',
|
|
226
|
+
'',
|
|
227
|
+
'// The subscription set travels as one URL-encoded JSON query parameter (EventSource cannot POST), in a',
|
|
228
|
+
'// deterministic order so equal sets produce equal URLs.',
|
|
229
|
+
'function appendSubscriptions(',
|
|
230
|
+
' url: string,',
|
|
231
|
+
' requested: readonly { readonly topic: string; readonly resource?: string }[]',
|
|
232
|
+
'): string {',
|
|
233
|
+
" const separator = url.includes('?') ? '&' : '?'",
|
|
234
|
+
' return `${url}${separator}subscriptions=${encodeURIComponent(JSON.stringify(requested))}`',
|
|
235
|
+
'}',
|
|
236
|
+
'',
|
|
237
|
+
'function compareSubscriptions(',
|
|
238
|
+
' left: { readonly topic: string; readonly resource?: string },',
|
|
239
|
+
' right: { readonly topic: string; readonly resource?: string }',
|
|
240
|
+
'): number {',
|
|
241
|
+
' if (left.topic !== right.topic) {',
|
|
242
|
+
' return left.topic < right.topic ? -1 : 1',
|
|
243
|
+
' }',
|
|
244
|
+
" const leftResource = left.resource ?? ''",
|
|
245
|
+
" const rightResource = right.resource ?? ''",
|
|
246
|
+
' if (leftResource === rightResource) return 0',
|
|
247
|
+
' return leftResource < rightResource ? -1 : 1',
|
|
248
|
+
'}',
|
|
249
|
+
'',
|
|
250
|
+
'function subscriptionKey(topic: string, resource: string | undefined): string {',
|
|
251
|
+
' return resource === undefined ? topic : `${topic}\\u0000${resource}`',
|
|
252
|
+
'}',
|
|
253
|
+
'',
|
|
254
|
+
'function setTopicPath(root: Record<string, unknown>, topic: string, value: unknown): void {',
|
|
255
|
+
" const segments = topic.split('.')",
|
|
256
|
+
' let node = root',
|
|
257
|
+
'',
|
|
258
|
+
' for (let index = 0; index < segments.length - 1; index += 1) {',
|
|
259
|
+
' const segment = segments[index]',
|
|
260
|
+
' const existing = node[segment]',
|
|
261
|
+
" if (typeof existing === 'object' && existing !== null) {",
|
|
262
|
+
' node = existing as Record<string, unknown>',
|
|
263
|
+
' continue',
|
|
264
|
+
' }',
|
|
265
|
+
'',
|
|
266
|
+
' const next: Record<string, unknown> = {}',
|
|
267
|
+
' node[segment] = next',
|
|
268
|
+
' node = next',
|
|
269
|
+
' }',
|
|
270
|
+
'',
|
|
271
|
+
' node[segments[segments.length - 1] ?? topic] = value',
|
|
272
|
+
'}',
|
|
273
|
+
'',
|
|
274
|
+
].join('\n');
|
|
275
|
+
}
|
|
276
|
+
function buildTopicTree(topics) {
|
|
277
|
+
const root = createTopicTreeNode();
|
|
278
|
+
for (const topic of topics) {
|
|
279
|
+
const segments = topic.split('.');
|
|
280
|
+
let node = root;
|
|
281
|
+
for (const segment of segments) {
|
|
282
|
+
let child = node.children.get(segment);
|
|
283
|
+
if (!child) {
|
|
284
|
+
child = createTopicTreeNode();
|
|
285
|
+
node.children.set(segment, child);
|
|
286
|
+
}
|
|
287
|
+
node = child;
|
|
288
|
+
}
|
|
289
|
+
node.topic = topic;
|
|
290
|
+
}
|
|
291
|
+
return root;
|
|
292
|
+
}
|
|
293
|
+
function createTopicTreeNode() {
|
|
294
|
+
return { children: new Map() };
|
|
295
|
+
}
|
|
296
|
+
function emitProperties(node, indent) {
|
|
297
|
+
const pad = ' '.repeat(indent);
|
|
298
|
+
return Array.from(node.children.entries())
|
|
299
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
300
|
+
.map(([segment, child]) => `${pad}readonly ${JSON.stringify(segment)}: ${emitNodeType(child, indent)}`);
|
|
301
|
+
}
|
|
302
|
+
function emitNodeType(node, indent) {
|
|
303
|
+
const childLines = emitProperties(node, indent + 1);
|
|
304
|
+
const childObject = childLines.length === 0
|
|
305
|
+
? '{}'
|
|
306
|
+
: `{\n${childLines.join('\n')}\n${' '.repeat(indent)}}`;
|
|
307
|
+
if (node.topic && childLines.length > 0) {
|
|
308
|
+
return `ElarionEventTopicApi<${JSON.stringify(node.topic)}> & ${childObject}`;
|
|
309
|
+
}
|
|
310
|
+
if (node.topic) {
|
|
311
|
+
return `ElarionEventTopicApi<${JSON.stringify(node.topic)}>`;
|
|
312
|
+
}
|
|
313
|
+
return childObject;
|
|
314
|
+
}
|
package/dist/generate.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { generateEventsClientSource } from './events-client-source.js';
|
|
1
2
|
import { jsonSchemaToTypeScript } from './json-schema-to-ts.js';
|
|
2
3
|
import { jsonSchemaToZod } from './json-schema-to-zod.js';
|
|
3
4
|
import { collectFilePaths, stripNullable } from './json-schema.js';
|
|
@@ -12,6 +13,7 @@ const DEFAULT_CLIENT_FILE = 'rpc-client.ts';
|
|
|
12
13
|
const DEFAULT_SESSION_CLIENT_FILE = 'session-client.ts';
|
|
13
14
|
const DEFAULT_SESSION_OPERATION = 'elarion.session';
|
|
14
15
|
const DEFAULT_START_ADAPTER_FILE = 'start-adapter.ts';
|
|
16
|
+
const DEFAULT_EVENTS_CLIENT_FILE = 'events-client.ts';
|
|
15
17
|
export { UnsupportedJsonSchemaError } from './json-schema.js';
|
|
16
18
|
export function generateRpcClientFiles(schema, options = {}) {
|
|
17
19
|
const generatedBy = options.generatedBy ?? DEFAULT_GENERATED_BY;
|
|
@@ -64,6 +66,21 @@ export function generateRpcClientFiles(schema, options = {}) {
|
|
|
64
66
|
schemasLines.push('');
|
|
65
67
|
schemasLines.push('export type RpcResultSchemas = typeof rpcResultSchemas');
|
|
66
68
|
schemasLines.push('');
|
|
69
|
+
// Client-event payload schemas (ADR-0043), emitted only when the schema declares events so event-free
|
|
70
|
+
// schemas keep producing byte-identical output.
|
|
71
|
+
const eventTopics = Object.keys(schema.events ?? {}).sort();
|
|
72
|
+
if (eventTopics.length > 0) {
|
|
73
|
+
schemasLines.push('export const rpcEventPayloadSchemas = {');
|
|
74
|
+
for (const topic of eventTopics) {
|
|
75
|
+
const definition = schema.events[topic];
|
|
76
|
+
const zodSchema = jsonSchemaToZod(stripNullable(definition.payload), createContext(definition.payload, `events.${topic}.payload`), 1);
|
|
77
|
+
schemasLines.push(` ${JSON.stringify(topic)}: ${zodSchema},`);
|
|
78
|
+
}
|
|
79
|
+
schemasLines.push('} as const');
|
|
80
|
+
schemasLines.push('');
|
|
81
|
+
schemasLines.push('export type RpcEventPayloadSchemas = typeof rpcEventPayloadSchemas');
|
|
82
|
+
schemasLines.push('');
|
|
83
|
+
}
|
|
67
84
|
const idempotentMethods = methods.filter((method) => schema.methods[method].idempotent === true);
|
|
68
85
|
// Where each method carries file payloads (x-elarion-file nodes), so the client can convert between
|
|
69
86
|
// native File values and the base64 wire envelope. Methods without files are omitted entirely.
|
|
@@ -104,6 +121,17 @@ export function generateRpcClientFiles(schema, options = {}) {
|
|
|
104
121
|
capabilities: schema.capabilities,
|
|
105
122
|
})
|
|
106
123
|
: undefined;
|
|
124
|
+
// The typed client-event subscription client (ADR-0043) is emitted only when the schema declares an
|
|
125
|
+
// `events` block, so event-free schemas produce byte-identical output.
|
|
126
|
+
const eventsClientFileName = options.eventsClientFileName ?? DEFAULT_EVENTS_CLIENT_FILE;
|
|
127
|
+
const eventsClientSource = eventTopics.length > 0
|
|
128
|
+
? generateEventsClientSource({
|
|
129
|
+
generatedBy,
|
|
130
|
+
sourceLabel,
|
|
131
|
+
schemasFileName,
|
|
132
|
+
topics: eventTopics,
|
|
133
|
+
})
|
|
134
|
+
: undefined;
|
|
107
135
|
// The framework adapter (ADR-boundary: kept opt-in so the core client stays framework-neutral) is emitted only
|
|
108
136
|
// when a framework is requested, so an unopted schema produces byte-identical output.
|
|
109
137
|
const frameworkAdapterFileName = options.frameworkAdapterFileName ?? DEFAULT_START_ADAPTER_FILE;
|
|
@@ -122,6 +150,8 @@ export function generateRpcClientFiles(schema, options = {}) {
|
|
|
122
150
|
sessionClientSource,
|
|
123
151
|
frameworkAdapterFileName: frameworkAdapterSource === undefined ? undefined : frameworkAdapterFileName,
|
|
124
152
|
frameworkAdapterSource,
|
|
153
|
+
eventsClientFileName: eventsClientSource === undefined ? undefined : eventsClientFileName,
|
|
154
|
+
eventsClientSource,
|
|
125
155
|
};
|
|
126
156
|
}
|
|
127
157
|
function createContext(root, path) {
|
package/dist/schema.d.ts
CHANGED
|
@@ -49,9 +49,18 @@ export interface RpcSchemaCapabilities {
|
|
|
49
49
|
permissions?: RpcCapabilityPermission[];
|
|
50
50
|
roles?: string[];
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* One client-event topic from the schema's `events` block (ADR-0043): the payload contract published on the
|
|
54
|
+
* topic. Delivery is at-most-once (SSE) — payloads are hints carrying ids/refs, not state.
|
|
55
|
+
*/
|
|
56
|
+
export interface RpcEventSchema {
|
|
57
|
+
payload: JsonSchema;
|
|
58
|
+
}
|
|
52
59
|
export interface RpcSchema {
|
|
53
60
|
methods: Record<string, RpcMethodSchema>;
|
|
54
61
|
capabilities?: RpcSchemaCapabilities;
|
|
62
|
+
/** Client-event topics by name, emitted by the server exporter when the host declares any (ADR-0043). */
|
|
63
|
+
events?: Record<string, RpcEventSchema>;
|
|
55
64
|
}
|
|
56
65
|
export interface GenerateRpcClientOptions {
|
|
57
66
|
generatedBy?: string;
|
|
@@ -63,6 +72,8 @@ export interface GenerateRpcClientOptions {
|
|
|
63
72
|
sessionClientFileName?: string;
|
|
64
73
|
/** The operation name the client-capability snapshot is served under (default `elarion.session`). */
|
|
65
74
|
sessionOperationName?: string;
|
|
75
|
+
/** Output filename for the client-event subscription client (default `events-client.ts`; emitted only when the schema declares events). */
|
|
76
|
+
eventsClientFileName?: string;
|
|
66
77
|
/**
|
|
67
78
|
* Emit an opt-in framework adapter alongside the neutral core client. `tanstack-start` produces
|
|
68
79
|
* `start-adapter.ts` (request-scoped cookie forwarding via `@tanstack/react-start`). Omitted → no adapter,
|
|
@@ -92,4 +103,10 @@ export interface GeneratedRpcClientFiles {
|
|
|
92
103
|
*/
|
|
93
104
|
frameworkAdapterFileName?: string;
|
|
94
105
|
frameworkAdapterSource?: string;
|
|
106
|
+
/**
|
|
107
|
+
* The typed client-event subscription client (ADR-0043). Present only when the schema declares an `events`
|
|
108
|
+
* block; otherwise both fields are `undefined` and every other output stays byte-identical.
|
|
109
|
+
*/
|
|
110
|
+
eventsClientFileName?: string;
|
|
111
|
+
eventsClientSource?: string;
|
|
95
112
|
}
|
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.85.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",
|