@nats-kit/rpc 0.3.0

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +94 -0
  4. package/dist/commonjs/client.d.ts +9 -0
  5. package/dist/commonjs/client.d.ts.map +1 -0
  6. package/dist/commonjs/client.js +307 -0
  7. package/dist/commonjs/client.js.map +1 -0
  8. package/dist/commonjs/contract.d.ts +59 -0
  9. package/dist/commonjs/contract.d.ts.map +1 -0
  10. package/dist/commonjs/contract.js +56 -0
  11. package/dist/commonjs/contract.js.map +1 -0
  12. package/dist/commonjs/errors.d.ts +58 -0
  13. package/dist/commonjs/errors.d.ts.map +1 -0
  14. package/dist/commonjs/errors.js +74 -0
  15. package/dist/commonjs/errors.js.map +1 -0
  16. package/dist/commonjs/eslint/index.d.ts +22 -0
  17. package/dist/commonjs/eslint/index.d.ts.map +1 -0
  18. package/dist/commonjs/eslint/index.js +33 -0
  19. package/dist/commonjs/eslint/index.js.map +1 -0
  20. package/dist/commonjs/events.d.ts +55 -0
  21. package/dist/commonjs/events.d.ts.map +1 -0
  22. package/dist/commonjs/events.js +82 -0
  23. package/dist/commonjs/events.js.map +1 -0
  24. package/dist/commonjs/headers.d.ts +53 -0
  25. package/dist/commonjs/headers.d.ts.map +1 -0
  26. package/dist/commonjs/headers.js +116 -0
  27. package/dist/commonjs/headers.js.map +1 -0
  28. package/dist/commonjs/index.d.ts +15 -0
  29. package/dist/commonjs/index.d.ts.map +1 -0
  30. package/dist/commonjs/index.js +61 -0
  31. package/dist/commonjs/index.js.map +1 -0
  32. package/dist/commonjs/kv.d.ts +13 -0
  33. package/dist/commonjs/kv.d.ts.map +1 -0
  34. package/dist/commonjs/kv.js +23 -0
  35. package/dist/commonjs/kv.js.map +1 -0
  36. package/dist/commonjs/package.json +4 -0
  37. package/dist/commonjs/server.d.ts +45 -0
  38. package/dist/commonjs/server.d.ts.map +1 -0
  39. package/dist/commonjs/server.js +433 -0
  40. package/dist/commonjs/server.js.map +1 -0
  41. package/dist/commonjs/types.d.ts +100 -0
  42. package/dist/commonjs/types.d.ts.map +1 -0
  43. package/dist/commonjs/types.js +3 -0
  44. package/dist/commonjs/types.js.map +1 -0
  45. package/dist/esm/client.d.ts +9 -0
  46. package/dist/esm/client.d.ts.map +1 -0
  47. package/dist/esm/client.js +304 -0
  48. package/dist/esm/client.js.map +1 -0
  49. package/dist/esm/contract.d.ts +59 -0
  50. package/dist/esm/contract.d.ts.map +1 -0
  51. package/dist/esm/contract.js +48 -0
  52. package/dist/esm/contract.js.map +1 -0
  53. package/dist/esm/errors.d.ts +58 -0
  54. package/dist/esm/errors.d.ts.map +1 -0
  55. package/dist/esm/errors.js +67 -0
  56. package/dist/esm/errors.js.map +1 -0
  57. package/dist/esm/eslint/index.d.ts +22 -0
  58. package/dist/esm/eslint/index.d.ts.map +1 -0
  59. package/dist/esm/eslint/index.js +30 -0
  60. package/dist/esm/eslint/index.js.map +1 -0
  61. package/dist/esm/events.d.ts +55 -0
  62. package/dist/esm/events.d.ts.map +1 -0
  63. package/dist/esm/events.js +77 -0
  64. package/dist/esm/events.js.map +1 -0
  65. package/dist/esm/headers.d.ts +53 -0
  66. package/dist/esm/headers.d.ts.map +1 -0
  67. package/dist/esm/headers.js +108 -0
  68. package/dist/esm/headers.js.map +1 -0
  69. package/dist/esm/index.d.ts +15 -0
  70. package/dist/esm/index.d.ts.map +1 -0
  71. package/dist/esm/index.js +25 -0
  72. package/dist/esm/index.js.map +1 -0
  73. package/dist/esm/kv.d.ts +13 -0
  74. package/dist/esm/kv.d.ts.map +1 -0
  75. package/dist/esm/kv.js +20 -0
  76. package/dist/esm/kv.js.map +1 -0
  77. package/dist/esm/package.json +4 -0
  78. package/dist/esm/server.d.ts +45 -0
  79. package/dist/esm/server.d.ts.map +1 -0
  80. package/dist/esm/server.js +426 -0
  81. package/dist/esm/server.js.map +1 -0
  82. package/dist/esm/types.d.ts +100 -0
  83. package/dist/esm/types.d.ts.map +1 -0
  84. package/dist/esm/types.js +2 -0
  85. package/dist/esm/types.js.map +1 -0
  86. package/package.json +103 -0
  87. package/src/client.ts +377 -0
  88. package/src/contract.ts +105 -0
  89. package/src/errors.ts +100 -0
  90. package/src/eslint/index.ts +30 -0
  91. package/src/events.ts +139 -0
  92. package/src/headers.ts +147 -0
  93. package/src/index.ts +76 -0
  94. package/src/kv.ts +33 -0
  95. package/src/server.ts +500 -0
  96. package/src/types.ts +119 -0
package/src/client.ts ADDED
@@ -0,0 +1,377 @@
1
+ import { nuid } from "@nats-io/nuid";
2
+ import { Match } from "@nats-io/nats-core";
3
+ import {
4
+ ClosedConnectionError,
5
+ ConnectionError,
6
+ NoRespondersError,
7
+ RequestError,
8
+ TimeoutError,
9
+ noopTelemetry,
10
+ type MsgHdrs,
11
+ } from "@nats-kit/core";
12
+
13
+ import { type ServiceContract, methodSubject } from "./contract.js";
14
+ import {
15
+ RpcError,
16
+ SERVICE_CODE_BY_STATUS,
17
+ SERVICE_ERROR_CODES,
18
+ TRANSPORT_ERROR_CODES,
19
+ isRpcError,
20
+ } from "./errors.js";
21
+ import {
22
+ HEADER_CALLER_ID,
23
+ HEADER_CALLER_KIND,
24
+ HEADER_CALLER_ROLE,
25
+ HEADER_CONTENT_TYPE,
26
+ HEADER_DEADLINE,
27
+ HEADER_REQUEST_ID,
28
+ HEADER_TRACEPARENT,
29
+ HEADER_TRACESTATE,
30
+ buildRequestHeaders,
31
+ } from "./headers.js";
32
+ import {
33
+ type RpcCallOptions,
34
+ type RpcCaller,
35
+ type RpcClient,
36
+ type RpcClientOptions,
37
+ } from "./types.js";
38
+
39
+ const DEFAULT_TIMEOUT_MS = 10_000;
40
+ const RESERVED_HEADER_NAMES = new Set(
41
+ [
42
+ HEADER_REQUEST_ID,
43
+ HEADER_CALLER_KIND,
44
+ HEADER_CALLER_ID,
45
+ HEADER_CALLER_ROLE,
46
+ HEADER_DEADLINE,
47
+ HEADER_CONTENT_TYPE,
48
+ HEADER_TRACEPARENT,
49
+ HEADER_TRACESTATE,
50
+ ].map((name) => name.toLowerCase()),
51
+ );
52
+
53
+ function assertAllowedExtraHeaders(extra: Record<string, string> | undefined): void {
54
+ if (!extra) return;
55
+ for (const name of Object.keys(extra)) {
56
+ if (name.toLowerCase().startsWith("nats-")) {
57
+ throw new Error(`createRpcClient: cannot set a "Nats-*" header ("${name}")`);
58
+ }
59
+ if (RESERVED_HEADER_NAMES.has(name.toLowerCase())) {
60
+ throw new Error(`createRpcClient: "${name}" is set by the client, not overridable`);
61
+ }
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Parse a JSON response body, tolerating an empty buffer (§4.5: service
67
+ * errors MAY have no body at all — uncaught handler throws emit one).
68
+ */
69
+ function tryParseJson(data: Uint8Array): { ok: true; value: unknown } | { ok: false } {
70
+ if (data.length === 0) return { ok: false };
71
+ try {
72
+ return { ok: true, value: JSON.parse(new TextDecoder().decode(data)) };
73
+ } catch {
74
+ return { ok: false };
75
+ }
76
+ }
77
+
78
+ /** Build the `createRpcClient` implementation for a single contract method. */
79
+ function buildMethodCaller<C extends ServiceContract>(
80
+ contract: C,
81
+ methodName: string,
82
+ options: RpcClientOptions,
83
+ ): (input: unknown, opts?: RpcCallOptions) => Promise<unknown> {
84
+ const methodDef = contract.methods[methodName];
85
+ if (!methodDef) {
86
+ throw new Error(`createRpcClient: unknown method "${methodName}"`);
87
+ }
88
+ const subject = methodSubject(contract, methodName);
89
+ const validateResponses = options.validateResponses ?? true;
90
+ const logger = options.logger;
91
+ const telemetry = options.telemetry ?? noopTelemetry;
92
+
93
+ return async (input: unknown, callOpts: RpcCallOptions = {}): Promise<unknown> => {
94
+ assertAllowedExtraHeaders(callOpts.headers);
95
+
96
+ const requestId = callOpts.requestId ?? nuid.next();
97
+ const start = Date.now();
98
+ let outcome:
99
+ | "ok"
100
+ | "domain-error"
101
+ | "service-error"
102
+ | "timeout"
103
+ | "no-responders"
104
+ | "aborted"
105
+ | "connection-lost" = "ok";
106
+
107
+ try {
108
+ telemetry.onRpcRequest?.(subject, requestId);
109
+
110
+ const parsedInputResult = methodDef.input.safeParse(input);
111
+ if (!parsedInputResult.success) {
112
+ outcome = "service-error";
113
+ throw new RpcError({
114
+ kind: "service",
115
+ code: SERVICE_ERROR_CODES.BAD_REQUEST,
116
+ message: "input failed local schema validation",
117
+ details: parsedInputResult.error.issues,
118
+ subject,
119
+ requestId,
120
+ });
121
+ }
122
+ const parsedInput = parsedInputResult.data;
123
+ const caller: RpcCaller = callOpts.caller ?? options.caller ?? { kind: "service" };
124
+ const timeoutMs =
125
+ callOpts.timeoutMs ?? methodDef.timeoutMs ?? options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
126
+ const deadlineMs = callOpts.deadlineMs ?? Date.now() + timeoutMs;
127
+
128
+ const reqHeaders: MsgHdrs = buildRequestHeaders({
129
+ requestId,
130
+ caller,
131
+ deadlineMs,
132
+ traceparent: callOpts.traceparent,
133
+ extra: callOpts.headers,
134
+ });
135
+
136
+ const body = new TextEncoder().encode(JSON.stringify(parsedInput));
137
+
138
+ await options.nats.waitForReady();
139
+ const nc = options.nats.getConnection();
140
+
141
+ let reply: { data: Uint8Array; headers?: MsgHdrs };
142
+ try {
143
+ const requestPromise = nc.request(subject, body, {
144
+ timeout: timeoutMs,
145
+ headers: reqHeaders,
146
+ });
147
+ reply = callOpts.signal
148
+ ? await raceWithSignal(requestPromise, callOpts.signal)
149
+ : await requestPromise;
150
+ } catch (error) {
151
+ throw toTransportError(error, subject, requestId);
152
+ }
153
+
154
+ const svcErrorCode = reply.headers?.get("Nats-Service-Error-Code", Match.IgnoreCase);
155
+ if (svcErrorCode) {
156
+ outcome = "service-error";
157
+ const status = Number(svcErrorCode);
158
+ const description =
159
+ reply.headers?.get("Nats-Service-Error", Match.IgnoreCase) || "service error";
160
+ const parsedBody = tryParseJson(reply.data);
161
+ const details =
162
+ parsedBody.ok && typeof parsedBody.value === "object" && parsedBody.value !== null
163
+ ? (parsedBody.value as { details?: unknown }).details
164
+ : undefined;
165
+ throw new RpcError({
166
+ kind: "service",
167
+ code: SERVICE_CODE_BY_STATUS[status] ?? SERVICE_ERROR_CODES.SERVICE_ERROR,
168
+ message: description,
169
+ serviceCode: status,
170
+ details,
171
+ subject,
172
+ requestId,
173
+ });
174
+ }
175
+
176
+ const parsedBody = tryParseJson(reply.data);
177
+ if (!parsedBody.ok || typeof parsedBody.value !== "object" || parsedBody.value === null) {
178
+ outcome = "service-error";
179
+ throw new RpcError({
180
+ kind: "service",
181
+ code: SERVICE_ERROR_CODES.MALFORMED_REPLY,
182
+ message: "reply body was not valid JSON",
183
+ subject,
184
+ requestId,
185
+ });
186
+ }
187
+
188
+ const envelope = parsedBody.value as {
189
+ ok?: boolean;
190
+ result?: unknown;
191
+ error?: { code?: string; message?: string; details?: unknown };
192
+ };
193
+
194
+ if (envelope.ok === false) {
195
+ outcome = "domain-error";
196
+ throw new RpcError({
197
+ kind: "domain",
198
+ code: envelope.error?.code ?? "UNKNOWN",
199
+ message: envelope.error?.message ?? "domain error",
200
+ details: envelope.error?.details,
201
+ subject,
202
+ requestId,
203
+ });
204
+ }
205
+
206
+ if (envelope.ok !== true) {
207
+ outcome = "service-error";
208
+ throw new RpcError({
209
+ kind: "service",
210
+ code: SERVICE_ERROR_CODES.MALFORMED_REPLY,
211
+ message: 'reply body missing "ok"',
212
+ subject,
213
+ requestId,
214
+ });
215
+ }
216
+
217
+ if (!validateResponses) {
218
+ return envelope.result;
219
+ }
220
+
221
+ // Wire spec §4.3: a method with no output sends `"result": null` on
222
+ // the wire, but `z.void()`/`z.undefined()` only accept `undefined` —
223
+ // retry with `undefined` before treating a `null` result as malformed.
224
+ let parsedOutput = methodDef.output.safeParse(envelope.result);
225
+ if (!parsedOutput.success && envelope.result === null) {
226
+ parsedOutput = methodDef.output.safeParse(undefined);
227
+ }
228
+ if (!parsedOutput.success) {
229
+ outcome = "service-error";
230
+ throw new RpcError({
231
+ kind: "service",
232
+ code: SERVICE_ERROR_CODES.MALFORMED_REPLY,
233
+ message: "reply result failed output schema validation",
234
+ details: parsedOutput.error.issues,
235
+ subject,
236
+ requestId,
237
+ });
238
+ }
239
+
240
+ return parsedOutput.data;
241
+ } catch (error) {
242
+ // Transport errors (timeout, no-responders, connection-lost, aborted)
243
+ // never flow through the `outcome = "..."` assignments above — derive
244
+ // it here from the normalised `RpcError` before it's telemetered.
245
+ if (outcome === "ok" && isRpcError(error)) {
246
+ if (error.kind === "transport") {
247
+ outcome =
248
+ error.code === TRANSPORT_ERROR_CODES.TIMEOUT
249
+ ? "timeout"
250
+ : error.code === TRANSPORT_ERROR_CODES.NO_RESPONDERS
251
+ ? "no-responders"
252
+ : error.code === TRANSPORT_ERROR_CODES.ABORTED
253
+ ? "aborted"
254
+ : error.code === TRANSPORT_ERROR_CODES.CONNECTION_LOST
255
+ ? "connection-lost"
256
+ : "service-error";
257
+ } else if (error.kind === "domain") {
258
+ outcome = "domain-error";
259
+ } else {
260
+ outcome = "service-error";
261
+ }
262
+ }
263
+ throw error;
264
+ } finally {
265
+ logger?.debug?.(
266
+ { subject, requestId, outcome, durationMs: Date.now() - start },
267
+ "RPC call completed",
268
+ );
269
+ telemetry.onRpcResponse?.(subject, outcome, Date.now() - start);
270
+ }
271
+ };
272
+ }
273
+
274
+ function toTransportError(error: unknown, subject: string, requestId: string): RpcError {
275
+ // `raceWithSignal` already builds a fully-formed `RpcError` (kind:
276
+ // "transport", code: ABORTED) when the caller's signal fires — pass it
277
+ // through unchanged instead of reclassifying it as CONNECTION_LOST.
278
+ if (isRpcError(error)) return error;
279
+ if (error instanceof RequestError) {
280
+ if (error.isNoResponders() || error.cause instanceof NoRespondersError) {
281
+ return new RpcError({
282
+ kind: "transport",
283
+ code: TRANSPORT_ERROR_CODES.NO_RESPONDERS,
284
+ message: "no responders for subject",
285
+ subject,
286
+ requestId,
287
+ cause: error,
288
+ });
289
+ }
290
+ if (error.cause instanceof TimeoutError) {
291
+ return new RpcError({
292
+ kind: "transport",
293
+ code: TRANSPORT_ERROR_CODES.TIMEOUT,
294
+ message: "request timed out",
295
+ subject,
296
+ requestId,
297
+ cause: error,
298
+ });
299
+ }
300
+ }
301
+ if (error instanceof TimeoutError) {
302
+ return new RpcError({
303
+ kind: "transport",
304
+ code: TRANSPORT_ERROR_CODES.TIMEOUT,
305
+ message: "request timed out",
306
+ subject,
307
+ requestId,
308
+ cause: error,
309
+ });
310
+ }
311
+ if (error instanceof NoRespondersError) {
312
+ return new RpcError({
313
+ kind: "transport",
314
+ code: TRANSPORT_ERROR_CODES.NO_RESPONDERS,
315
+ message: "no responders for subject",
316
+ subject,
317
+ requestId,
318
+ cause: error,
319
+ });
320
+ }
321
+ if (error instanceof ClosedConnectionError || error instanceof ConnectionError) {
322
+ return new RpcError({
323
+ kind: "transport",
324
+ code: TRANSPORT_ERROR_CODES.CONNECTION_LOST,
325
+ message: "connection closed or lost",
326
+ subject,
327
+ requestId,
328
+ cause: error,
329
+ });
330
+ }
331
+ return new RpcError({
332
+ kind: "transport",
333
+ code: TRANSPORT_ERROR_CODES.CONNECTION_LOST,
334
+ message: error instanceof Error ? error.message : "request failed",
335
+ subject,
336
+ requestId,
337
+ cause: error,
338
+ });
339
+ }
340
+
341
+ function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
342
+ if (signal.aborted) {
343
+ return Promise.reject(new RpcError({ kind: "transport", code: TRANSPORT_ERROR_CODES.ABORTED, message: "aborted" }));
344
+ }
345
+ return new Promise<T>((resolve, reject) => {
346
+ const onAbort = () => {
347
+ reject(new RpcError({ kind: "transport", code: TRANSPORT_ERROR_CODES.ABORTED, message: "aborted" }));
348
+ };
349
+ signal.addEventListener("abort", onAbort, { once: true });
350
+ promise.then(
351
+ (value) => {
352
+ signal.removeEventListener("abort", onAbort);
353
+ resolve(value);
354
+ },
355
+ (error: unknown) => {
356
+ signal.removeEventListener("abort", onAbort);
357
+ reject(error);
358
+ },
359
+ );
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Build a typed `RpcClient<C>` — one method per contract entry, request
365
+ * headers per §4.2, and error normalisation per §4.6. Rejects with
366
+ * {@link RpcError} in every non-success case; never leaks `{ok:false}`.
367
+ */
368
+ export function createRpcClient<C extends ServiceContract>(
369
+ contract: C,
370
+ options: RpcClientOptions,
371
+ ): RpcClient<C> {
372
+ const client: Record<string, (input: unknown, opts?: RpcCallOptions) => Promise<unknown>> = {};
373
+ for (const methodName of Object.keys(contract.methods)) {
374
+ client[methodName] = buildMethodCaller(contract, methodName, options);
375
+ }
376
+ return client as RpcClient<C>;
377
+ }
@@ -0,0 +1,105 @@
1
+ import { type z } from "zod";
2
+
3
+ /** Segment grammar shared by `app`, `service` and every method name (§4.1). */
4
+ export const SUBJECT_SEGMENT_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
5
+
6
+ /**
7
+ * Definition of a single RPC method within a {@link ServiceContract}.
8
+ *
9
+ * See design §2.1. `errors` is a closed set of domain error codes the method
10
+ * may return via `fail()` — kept as a literal tuple by {@link method} so
11
+ * `RpcHandlers<C>` and `isDomainError` can narrow against it.
12
+ */
13
+ export interface RpcMethodDef<
14
+ I extends z.ZodType = z.ZodType,
15
+ O extends z.ZodType = z.ZodType,
16
+ E extends readonly string[] = readonly string[],
17
+ > {
18
+ input: I;
19
+ output: O;
20
+ /** Closed set of domain error codes this method may return. */
21
+ errors?: E;
22
+ /**
23
+ * Roles the caller must hold. Empty/absent means no role requirement.
24
+ * Enforced by the library BEFORE dispatch: a caller missing any of these
25
+ * gets a 403 service error and the handler is never invoked.
26
+ */
27
+ requiredRoles?: readonly string[];
28
+ /** Default client timeout for this method. Falls back to 10_000. */
29
+ timeoutMs?: number;
30
+ /** Free-form endpoint metadata; surfaces in `nats micro info`. */
31
+ metadata?: Record<string, string>;
32
+ }
33
+
34
+ /**
35
+ * Identity helper that preserves the literal tuple type of `errors` — a bare
36
+ * object literal would widen `errors: ["NOT_FOUND"]` to `string[]`, which
37
+ * would flatten every domain error union to `string` throughout the contract.
38
+ */
39
+ export function method<
40
+ I extends z.ZodType,
41
+ O extends z.ZodType,
42
+ const E extends readonly string[] = readonly [],
43
+ >(def: RpcMethodDef<I, O, E>): RpcMethodDef<I, O, E> {
44
+ return def;
45
+ }
46
+
47
+ /** A named collection of RPC methods, addressed at `<app>.<service>.<method>` (§2.1, §4.1). */
48
+ export interface ServiceContract<
49
+ M extends Record<string, RpcMethodDef> = Record<string, RpcMethodDef>,
50
+ > {
51
+ /** Subject segment 1. `[a-z0-9-]+`. */
52
+ app: string;
53
+ /** Subject segment 2. `[a-z0-9-]+`. */
54
+ service: string;
55
+ /** SemVer — `@nats-io/services` rejects anything else (`parseSemVer`). */
56
+ version: string;
57
+ description?: string;
58
+ metadata?: Record<string, string>;
59
+ /** Keys are method names; each must be `[a-z0-9-]+`. */
60
+ methods: M;
61
+ }
62
+
63
+ /** SemVer as required by `@nats-io/services`' `ServiceConfig.version` (`parseSemVer`). */
64
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
65
+
66
+ /**
67
+ * Validate and return a {@link ServiceContract}. Throws synchronously on a
68
+ * malformed `app`/`service`/`version`/method name — a contract is authored
69
+ * once and imported everywhere, so failing fast at module-load time is
70
+ * preferable to a runtime surprise deep inside `createRpcServer`.
71
+ */
72
+ export function defineService<const M extends Record<string, RpcMethodDef>>(
73
+ def: ServiceContract<M>,
74
+ ): ServiceContract<M> {
75
+ if (!SUBJECT_SEGMENT_RE.test(def.app)) {
76
+ throw new Error(`defineService: invalid app segment "${def.app}"`);
77
+ }
78
+ if (!SUBJECT_SEGMENT_RE.test(def.service)) {
79
+ throw new Error(`defineService: invalid service segment "${def.service}"`);
80
+ }
81
+ if (!SEMVER_RE.test(def.version)) {
82
+ throw new Error(`defineService: invalid semver version "${def.version}"`);
83
+ }
84
+ for (const name of Object.keys(def.methods)) {
85
+ if (!SUBJECT_SEGMENT_RE.test(name)) {
86
+ throw new Error(`defineService: invalid method name "${name}"`);
87
+ }
88
+ }
89
+ return def;
90
+ }
91
+
92
+ /** The subject a method is served/called on: `<app>.<service>.<method>` (§4.1). */
93
+ export function methodSubject(contract: ServiceContract, methodName: string): string {
94
+ return `${contract.app}.${contract.service}.${methodName}`;
95
+ }
96
+
97
+ /** The micro service `name` (dots are rejected by `Svcm.add`, so `-` joins app/service). */
98
+ export function serviceName(contract: ServiceContract): string {
99
+ return `${contract.app}-${contract.service}`;
100
+ }
101
+
102
+ /** The group subject prefix every endpoint is registered under. */
103
+ export function groupSubject(contract: ServiceContract): string {
104
+ return `${contract.app}.${contract.service}`;
105
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * RPC error taxonomy (design §2.3, wire spec §4.5–§4.6).
3
+ *
4
+ * - `transport`: never touched the network (no-responders, timeout,
5
+ * connection lost, local abort), synthesised entirely by the client.
6
+ * - `service`: an ADR-32 header error (`Nats-Service-Error-Code`) — bad
7
+ * envelope, auth, or a server-side crash. Counted in micro's `num_errors`.
8
+ * - `domain`: an application outcome carried in the response BODY
9
+ * (`{ok:false,error:{code,...}}`). Never touches micro's error counter.
10
+ */
11
+ export type RpcErrorKind = "transport" | "service" | "domain";
12
+
13
+ export interface RpcErrorInit {
14
+ code: string;
15
+ message: string;
16
+ kind: RpcErrorKind;
17
+ details?: unknown;
18
+ /** ADR-32 numeric code, present only when `kind === "service"`. */
19
+ serviceCode?: number;
20
+ subject?: string;
21
+ requestId?: string;
22
+ cause?: unknown;
23
+ }
24
+
25
+ /** The single error type every `RpcClient` method call rejects with. */
26
+ export class RpcError extends Error {
27
+ readonly code: string;
28
+ readonly kind: RpcErrorKind;
29
+ readonly details?: unknown;
30
+ readonly serviceCode?: number;
31
+ readonly subject?: string;
32
+ readonly requestId?: string;
33
+
34
+ constructor(init: RpcErrorInit) {
35
+ super(init.message, init.cause !== undefined ? { cause: init.cause } : undefined);
36
+ this.name = "RpcError";
37
+ this.code = init.code;
38
+ this.kind = init.kind;
39
+ this.details = init.details;
40
+ this.serviceCode = init.serviceCode;
41
+ this.subject = init.subject;
42
+ this.requestId = init.requestId;
43
+ }
44
+ }
45
+
46
+ export function isRpcError(e: unknown): e is RpcError {
47
+ return e instanceof RpcError;
48
+ }
49
+
50
+ export function isTransportError(e: unknown): e is RpcError {
51
+ return isRpcError(e) && e.kind === "transport";
52
+ }
53
+
54
+ export function isDomainError<C extends string>(
55
+ e: unknown,
56
+ code?: C,
57
+ ): e is RpcError {
58
+ if (!isRpcError(e) || e.kind !== "domain") return false;
59
+ return code === undefined || e.code === code;
60
+ }
61
+
62
+ /** Reserved transport codes (§4.6) — never appear on the wire. */
63
+ export const TRANSPORT_ERROR_CODES = {
64
+ NO_RESPONDERS: "NO_RESPONDERS",
65
+ TIMEOUT: "TIMEOUT",
66
+ CONNECTION_LOST: "CONNECTION_LOST",
67
+ ABORTED: "ABORTED",
68
+ } as const;
69
+
70
+ /** Reserved service codes (§2.3) — the numeric-to-string mapping from §4.6. */
71
+ export const SERVICE_ERROR_CODES = {
72
+ BAD_REQUEST: "BAD_REQUEST",
73
+ UNAUTHENTICATED: "UNAUTHENTICATED",
74
+ FORBIDDEN: "FORBIDDEN",
75
+ NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
76
+ INTERNAL: "INTERNAL",
77
+ UNAVAILABLE: "UNAVAILABLE",
78
+ MALFORMED_REPLY: "MALFORMED_REPLY",
79
+ SERVICE_ERROR: "SERVICE_ERROR",
80
+ } as const;
81
+
82
+ /** §4.6 client normalisation step (2): ADR-32 numeric code -> reserved string code. */
83
+ export const SERVICE_CODE_BY_STATUS: Record<number, string> = {
84
+ 400: SERVICE_ERROR_CODES.BAD_REQUEST,
85
+ 401: SERVICE_ERROR_CODES.UNAUTHENTICATED,
86
+ 403: SERVICE_ERROR_CODES.FORBIDDEN,
87
+ 404: SERVICE_ERROR_CODES.NOT_IMPLEMENTED,
88
+ 500: SERVICE_ERROR_CODES.INTERNAL,
89
+ 503: SERVICE_ERROR_CODES.UNAVAILABLE,
90
+ };
91
+
92
+ /** Server-side reverse mapping: a domain/service code back to its wire status (§2.4). */
93
+ export const STATUS_BY_SERVICE_CODE: Partial<Record<string, number>> = {
94
+ [SERVICE_ERROR_CODES.BAD_REQUEST]: 400,
95
+ [SERVICE_ERROR_CODES.UNAUTHENTICATED]: 401,
96
+ [SERVICE_ERROR_CODES.FORBIDDEN]: 403,
97
+ [SERVICE_ERROR_CODES.NOT_IMPLEMENTED]: 404,
98
+ [SERVICE_ERROR_CODES.INTERNAL]: 500,
99
+ [SERVICE_ERROR_CODES.UNAVAILABLE]: 503,
100
+ };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * ESLint flat-config snippet (design §2.8): a lint-level nudge, not a
3
+ * guarantee — `no-restricted-properties` is purely syntactic. Spread into a
4
+ * consumer's flat config:
5
+ *
6
+ * ```js
7
+ * import { rpcBoundaryRules } from "@nats-kit/rpc/eslint";
8
+ * export default [{ files: ["src/**\/*.ts"], rules: { ...rpcBoundaryRules } }];
9
+ * ```
10
+ */
11
+ export const rpcBoundaryRules = {
12
+ "no-restricted-properties": [
13
+ "error",
14
+ {
15
+ property: "respondError",
16
+ message:
17
+ "Return fail(...) or throw RpcError from an RPC handler; the rpc layer owns ADR-32 error headers.",
18
+ },
19
+ {
20
+ object: "nc",
21
+ property: "request",
22
+ message: "Use a typed createRpcClient() method instead of raw nc.request().",
23
+ },
24
+ {
25
+ object: "msg",
26
+ property: "respond",
27
+ message: "Raw msg.respond() bypasses the RPC envelope. Use an RPC handler.",
28
+ },
29
+ ],
30
+ };