@atcute/xrpc-server 0.1.11 → 1.0.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 (63) hide show
  1. package/README.md +81 -20
  2. package/dist/auth/jwt-creator.d.ts +4 -2
  3. package/dist/auth/jwt-creator.d.ts.map +1 -1
  4. package/dist/auth/jwt-creator.js +1 -1
  5. package/dist/auth/jwt-creator.js.map +1 -1
  6. package/dist/auth/jwt-verifier.d.ts +69 -8
  7. package/dist/auth/jwt-verifier.d.ts.map +1 -1
  8. package/dist/auth/jwt-verifier.js +131 -20
  9. package/dist/auth/jwt-verifier.js.map +1 -1
  10. package/dist/auth/jwt.d.ts +7 -2
  11. package/dist/auth/jwt.d.ts.map +1 -1
  12. package/dist/auth/jwt.js +14 -7
  13. package/dist/auth/jwt.js.map +1 -1
  14. package/dist/main/response.js.map +1 -1
  15. package/dist/main/router.d.ts +32 -4
  16. package/dist/main/router.d.ts.map +1 -1
  17. package/dist/main/router.js +66 -11
  18. package/dist/main/router.js.map +1 -1
  19. package/dist/main/types/operation.d.ts +8 -0
  20. package/dist/main/types/operation.d.ts.map +1 -1
  21. package/dist/main/types/websocket.d.ts +8 -0
  22. package/dist/main/types/websocket.d.ts.map +1 -1
  23. package/dist/main/utils/frames.d.ts +2 -2
  24. package/dist/main/utils/frames.d.ts.map +1 -1
  25. package/dist/main/utils/frames.js.map +1 -1
  26. package/dist/main/utils/middlewares.d.ts.map +1 -1
  27. package/dist/main/utils/middlewares.js.map +1 -1
  28. package/dist/main/utils/namespaced.d.ts.map +1 -1
  29. package/dist/main/utils/namespaced.js.map +1 -1
  30. package/dist/main/utils/request-input.d.ts +1 -1
  31. package/dist/main/utils/request-input.d.ts.map +1 -1
  32. package/dist/main/utils/request-input.js.map +1 -1
  33. package/dist/main/utils/request-params.d.ts +1 -1
  34. package/dist/main/utils/request-params.d.ts.map +1 -1
  35. package/dist/main/utils/request-params.js.map +1 -1
  36. package/dist/main/utils/response.d.ts +1 -1
  37. package/dist/main/utils/response.d.ts.map +1 -1
  38. package/dist/main/utils/response.js.map +1 -1
  39. package/dist/main/utils/websocket-mock.d.ts +8 -9
  40. package/dist/main/utils/websocket-mock.d.ts.map +1 -1
  41. package/dist/main/utils/websocket-mock.js +11 -6
  42. package/dist/main/utils/websocket-mock.js.map +1 -1
  43. package/dist/main/xrpc-error.d.ts +55 -15
  44. package/dist/main/xrpc-error.d.ts.map +1 -1
  45. package/dist/main/xrpc-error.js +66 -26
  46. package/dist/main/xrpc-error.js.map +1 -1
  47. package/dist/main/xrpc-handler.js.map +1 -1
  48. package/dist/middlewares/cors.d.ts.map +1 -1
  49. package/dist/middlewares/cors.js.map +1 -1
  50. package/lib/auth/jwt-creator.ts +5 -3
  51. package/lib/auth/jwt-verifier.ts +206 -26
  52. package/lib/auth/jwt.ts +21 -10
  53. package/lib/main/router.ts +98 -15
  54. package/lib/main/types/operation.ts +8 -0
  55. package/lib/main/types/websocket.ts +8 -0
  56. package/lib/main/utils/websocket-mock.ts +20 -14
  57. package/lib/main/xrpc-error.ts +107 -44
  58. package/package.json +20 -15
  59. package/dist/main/utils/event-emitter.d.ts +0 -37
  60. package/dist/main/utils/event-emitter.d.ts.map +0 -1
  61. package/dist/main/utils/event-emitter.js +0 -96
  62. package/dist/main/utils/event-emitter.js.map +0 -1
  63. package/lib/main/utils/event-emitter.ts +0 -116
@@ -1,61 +1,161 @@
1
1
  import { getPublicKeyFromDidController, verifySig, type FoundPublicKey } from '@atcute/crypto';
2
- import { getAtprotoVerificationMaterial, type DidDocument } from '@atcute/identity';
2
+ import { getVerificationMaterial, type DidDocument } from '@atcute/identity';
3
3
  import { type DidDocumentResolver } from '@atcute/identity-resolver';
4
4
  import type { Did, Nsid } from '@atcute/lexicons';
5
+ import type { AtprotoAudience } from '@atcute/lexicons/syntax';
5
6
  import * as uint8arrays from '@atcute/uint8array';
6
7
 
8
+ import { AuthRequiredError } from '../main/xrpc-error.ts';
7
9
  import type { Result } from '../types/misc.ts';
8
10
 
9
11
  import { parseJwt, type ParsedJwt } from './jwt.ts';
10
12
  import type { AuthError } from './types.ts';
11
13
 
14
+ type SupportedKid = `#${string}`;
15
+ /** only `#atproto` is accepted as a signing key identifier for now */
16
+ const DEFAULT_KID: SupportedKid = '#atproto';
17
+
18
+ /**
19
+ * replay-protection store for service JWTs. when configured on a verifier,
20
+ * tokens must carry a `jti` claim and the verifier consults this store to
21
+ * reject duplicates.
22
+ */
23
+ export interface ReplayStore {
24
+ /**
25
+ * record a `(iss, jti)` pair seen now.
26
+ *
27
+ * @param key issuer + token identifier; implementations decide how to
28
+ * encode this into a storage key.
29
+ * @param ttlSeconds how long the entry must be retained. implementations
30
+ * are free to retain it for longer.
31
+ * @returns `true` if the pair was previously unseen (token is unique),
32
+ * `false` if the pair has been recorded before (replay).
33
+ */
34
+ check(key: { iss: Did; jti: string }, ttlSeconds: number): Promise<boolean>;
35
+ }
36
+
12
37
  export interface ServiceJwtVerifierOptions {
13
- serviceDid: Did | null;
38
+ /**
39
+ * list of `aud` values accepted by this service; each entry is a bare DID or a DID with
40
+ * service fragment (e.g. `did:web:x.example#svc`), and incoming tokens must exact-match any entry.
41
+ *
42
+ * pass `null` to skip audience validation (accept any audience). an empty array rejects every
43
+ * audience, which is useful when a service wants to fail closed until configured.
44
+ */
45
+ acceptAudiences: (Did | AtprotoAudience)[] | null;
14
46
  resolver: DidDocumentResolver;
47
+ /**
48
+ * maximum token lifetime window in seconds. rejects tokens whose `exp` is
49
+ * more than this far in the future or whose `iat` is more than this far in
50
+ * the past. defaults to 300 (5 minutes), matching atproto convention.
51
+ */
52
+ maxAge?: number;
53
+ /**
54
+ * clock-skew leeway in seconds applied to `exp` and `nbf` comparisons.
55
+ * defaults to 5 seconds.
56
+ */
57
+ clockLeeway?: number;
58
+ /**
59
+ * optional replay-protection store. when provided, tokens must carry a
60
+ * `jti` claim and the verifier rejects any `(iss, jti)` the store reports
61
+ * as previously seen.
62
+ */
63
+ replayStore?: ReplayStore;
15
64
  }
16
65
 
17
66
  export interface VerifyJwtOptions {
18
- lxm: Nsid | Nsid[] | null;
67
+ lxm: Nsid | Nsid[];
68
+ /** abort signal forwarded to DID resolution; falls back to `request.signal` in `verifyRequest`. */
69
+ signal?: AbortSignal;
19
70
  }
20
71
 
21
72
  export interface VerifiedJwt {
22
73
  issuer: Did;
23
- audience: Did;
24
- lxm: string | undefined;
74
+ audience: Did | AtprotoAudience;
75
+ lxm: Nsid;
25
76
  }
26
77
 
78
+ const BEARER_PREFIX = 'Bearer ';
79
+
27
80
  export class ServiceJwtVerifier {
28
81
  didDocResolver: DidDocumentResolver;
29
- serviceDid: Did | null;
82
+ acceptAudiences: (Did | AtprotoAudience)[] | null;
83
+ maxAge: number;
84
+ clockLeeway: number;
85
+ replayStore?: ReplayStore;
30
86
 
31
87
  constructor(options: ServiceJwtVerifierOptions) {
32
88
  this.didDocResolver = options.resolver;
33
- this.serviceDid = options.serviceDid;
89
+ this.acceptAudiences = options.acceptAudiences;
90
+ this.maxAge = options.maxAge ?? 5 * 60;
91
+ this.clockLeeway = options.clockLeeway ?? 5;
92
+ this.replayStore = options.replayStore;
93
+ }
94
+
95
+ /**
96
+ * parse the Authorization header, verify the bearer token, and return the
97
+ * validated claims. throws {@link AuthRequiredError} with a populated
98
+ * `WWW-Authenticate: Bearer` challenge on every failure path.
99
+ *
100
+ * @param request incoming request; `request.signal` is forwarded to DID
101
+ * resolution unless `options.signal` overrides it.
102
+ * @param options verification options; `lxm` restricts which lexicon
103
+ * methods the token is allowed to invoke.
104
+ * @throws {AuthRequiredError} on missing header, malformed token,
105
+ * signature mismatch, audience/lxm rejection, replay, or expiry.
106
+ */
107
+ async verifyRequest(request: Request, options: VerifyJwtOptions): Promise<VerifiedJwt> {
108
+ const authorization = request.headers.get('authorization');
109
+ if (authorization === null) {
110
+ throw new AuthRequiredError({
111
+ message: 'authorization header required',
112
+ wwwAuthenticate: { scheme: 'Bearer' },
113
+ });
114
+ }
115
+
116
+ if (!authorization.startsWith(BEARER_PREFIX)) {
117
+ throw authError({ error: 'MissingBearer', description: 'expected a bearer token' });
118
+ }
119
+
120
+ const token = authorization.slice(BEARER_PREFIX.length).trim();
121
+ const signal = options.signal ?? request.signal;
122
+
123
+ const result = await this.#verifyToken(token, { lxm: options.lxm, signal });
124
+ if (!result.ok) {
125
+ throw authError(result.error);
126
+ }
127
+
128
+ return result.value;
34
129
  }
35
130
 
36
- async #getSigningKey(issuer: Did, noCache: boolean): Promise<Result<FoundPublicKey, AuthError>> {
131
+ async #getSigningKey(
132
+ issuer: Did,
133
+ kid: SupportedKid,
134
+ noCache: boolean,
135
+ signal: AbortSignal,
136
+ ): Promise<Result<FoundPublicKey, AuthError>> {
37
137
  let didDocument: DidDocument;
38
138
  let key: FoundPublicKey;
39
139
 
40
140
  try {
41
- didDocument = await this.didDocResolver.resolve(issuer, { noCache });
141
+ didDocument = await this.didDocResolver.resolve(issuer, { noCache, signal });
42
142
  } catch {
43
143
  return {
44
144
  ok: false,
45
145
  error: {
46
- error: 'UnresolvedDidDocument',
146
+ error: 'DidResolutionFailed',
47
147
  description: `failed to retrieve did document for ${issuer}`,
48
148
  },
49
149
  };
50
150
  }
51
151
 
52
- const controller = getAtprotoVerificationMaterial(didDocument);
152
+ const controller = getVerificationMaterial(didDocument, kid);
53
153
  if (!controller) {
54
154
  return {
55
155
  ok: false,
56
156
  error: {
57
157
  error: 'BadJwtIssuer',
58
- description: `${issuer} does not have an atproto verification material`,
158
+ description: `${issuer} does not have a ${kid} verification material`,
59
159
  },
60
160
  };
61
161
  }
@@ -67,7 +167,7 @@ export class ServiceJwtVerifier {
67
167
  ok: false,
68
168
  error: {
69
169
  error: 'BadJwtIssuer',
70
- description: `${issuer} has invalid atproto verification material`,
170
+ description: `${issuer} has invalid ${kid} verification material`,
71
171
  },
72
172
  };
73
173
  }
@@ -92,8 +192,11 @@ export class ServiceJwtVerifier {
92
192
  }
93
193
  }
94
194
 
95
- async verify(jwtString: string, options?: VerifyJwtOptions): Promise<Result<VerifiedJwt, AuthError>> {
96
- const parsed = parseJwt(jwtString);
195
+ async #verifyToken(
196
+ token: string,
197
+ options: { lxm: Nsid | Nsid[]; signal: AbortSignal },
198
+ ): Promise<Result<VerifiedJwt, AuthError>> {
199
+ const parsed = parseJwt(token);
97
200
  if (!parsed.ok) {
98
201
  return parsed;
99
202
  }
@@ -114,7 +217,33 @@ export class ServiceJwtVerifier {
114
217
  }
115
218
  }
116
219
 
117
- if (Date.now() / 1_000 > payload.exp) {
220
+ // resolve the `kid` header (defaulting to `#atproto`) and restrict to the set of
221
+ // identifiers this verifier knows how to look up in the issuer's DID document.
222
+ // matches proposal 0014's "safe default" for SDKs.
223
+ const kid: string = header.kid ?? DEFAULT_KID;
224
+ if (kid !== DEFAULT_KID) {
225
+ return {
226
+ ok: false,
227
+ error: {
228
+ error: 'BadJwtIssuer',
229
+ description: `unsupported signing key identifier (${kid})`,
230
+ },
231
+ };
232
+ }
233
+
234
+ const now = Math.floor(Date.now() / 1_000);
235
+
236
+ if (payload.nbf !== undefined && now < payload.nbf - this.clockLeeway) {
237
+ return {
238
+ ok: false,
239
+ error: {
240
+ error: 'JwtNotYetValid',
241
+ description: `jwt is not yet valid`,
242
+ },
243
+ };
244
+ }
245
+
246
+ if (now > payload.exp + this.clockLeeway) {
118
247
  return {
119
248
  ok: false,
120
249
  error: {
@@ -124,20 +253,33 @@ export class ServiceJwtVerifier {
124
253
  };
125
254
  }
126
255
 
127
- if (this.serviceDid !== undefined && this.serviceDid !== payload.aud) {
256
+ // prevent issuers from minting very long-lived tokens: the configured max-age
257
+ // window bounds how far `exp` can be in the future and how far `iat` can be in
258
+ // the past.
259
+ if (payload.exp - now > this.maxAge || (payload.iat !== undefined && now - payload.iat > this.maxAge)) {
260
+ return {
261
+ ok: false,
262
+ error: {
263
+ error: 'JwtTooOld',
264
+ description: `jwt exceeds maximum age (${this.maxAge}s)`,
265
+ },
266
+ };
267
+ }
268
+
269
+ if (this.acceptAudiences !== null && !this.acceptAudiences.includes(payload.aud)) {
128
270
  return {
129
271
  ok: false,
130
272
  error: {
131
- error: 'BadJwtAudience',
132
- description: `jwt audience does not match (expected ${this.serviceDid})`,
273
+ error: 'InvalidAudience',
274
+ description:
275
+ this.acceptAudiences.length === 0
276
+ ? `jwt audience does not match (no audiences accepted)`
277
+ : `jwt audience does not match (expected one of: ${this.acceptAudiences.join(', ')})`,
133
278
  },
134
279
  };
135
280
  }
136
281
 
137
- if (
138
- options?.lxm != null &&
139
- (typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm!))
140
- ) {
282
+ if (typeof options.lxm === 'string' ? options.lxm !== payload.lxm : !options.lxm.includes(payload.lxm)) {
141
283
  return {
142
284
  ok: false,
143
285
  error: {
@@ -147,7 +289,22 @@ export class ServiceJwtVerifier {
147
289
  };
148
290
  }
149
291
 
150
- const key = await this.#getSigningKey(payload.iss, false);
292
+ let jti: string | undefined;
293
+ if (this.replayStore !== undefined) {
294
+ if (payload.jti === undefined) {
295
+ return {
296
+ ok: false,
297
+ error: {
298
+ error: 'BadJwt',
299
+ description: `jwt is missing the jti claim (required for replay protection)`,
300
+ },
301
+ };
302
+ }
303
+
304
+ jti = payload.jti;
305
+ }
306
+
307
+ const key = await this.#getSigningKey(payload.iss, kid, false, options.signal);
151
308
  if (!key.ok) {
152
309
  return key;
153
310
  }
@@ -165,7 +322,7 @@ export class ServiceJwtVerifier {
165
322
 
166
323
  if (!isValid) {
167
324
  // try again, uncached
168
- const freshKey = await this.#getSigningKey(payload.iss, true);
325
+ const freshKey = await this.#getSigningKey(payload.iss, kid, true, options.signal);
169
326
  if (!freshKey.ok) {
170
327
  return freshKey;
171
328
  }
@@ -183,7 +340,7 @@ export class ServiceJwtVerifier {
183
340
 
184
341
  // only revalidate if it's a different key
185
342
  if (!uint8arrays.equals(freshKey.value.publicKeyBytes, key.value.publicKeyBytes)) {
186
- const result = await this.#verifySignature(key.value, parsed.value);
343
+ const result = await this.#verifySignature(freshKey.value, parsed.value);
187
344
  if (!result.ok) {
188
345
  return result;
189
346
  }
@@ -203,6 +360,22 @@ export class ServiceJwtVerifier {
203
360
  };
204
361
  }
205
362
 
363
+ // replay-store check runs after signature verification so forged tokens
364
+ // can't burn entries (memory dos) or evict legitimate `(iss, jti)` pairs
365
+ // before the real request lands.
366
+ if (this.replayStore !== undefined && jti !== undefined) {
367
+ const unique = await this.replayStore.check({ iss: payload.iss, jti }, this.maxAge);
368
+ if (!unique) {
369
+ return {
370
+ ok: false,
371
+ error: {
372
+ error: 'NonceNotUnique',
373
+ description: `jwt has been used before`,
374
+ },
375
+ };
376
+ }
377
+ }
378
+
206
379
  return {
207
380
  ok: true,
208
381
  value: {
@@ -213,3 +386,10 @@ export class ServiceJwtVerifier {
213
386
  };
214
387
  }
215
388
  }
389
+
390
+ const authError = (err: AuthError): AuthRequiredError => {
391
+ return new AuthRequiredError({
392
+ message: err.description,
393
+ wwwAuthenticate: { scheme: 'Bearer', params: { error: err.error } },
394
+ });
395
+ };
package/lib/auth/jwt.ts CHANGED
@@ -1,5 +1,6 @@
1
+ import { isAtprotoAudience } from '@atcute/identity';
1
2
  import type { Did, Nsid } from '@atcute/lexicons';
2
- import { isDid, isNsid } from '@atcute/lexicons/syntax';
3
+ import { isDid, isNsid, type AtprotoAudience } from '@atcute/lexicons/syntax';
3
4
  import { fromBase64Url } from '@atcute/multibase';
4
5
  import { decodeUtf8From, encodeUtf8 } from '@atcute/uint8array';
5
6
 
@@ -10,6 +11,9 @@ import type { Result } from '../types/misc.ts';
10
11
  import type { AuthError } from './types.ts';
11
12
 
12
13
  const didString = v.string().assert(isDid, `must be a did`);
14
+ const audienceString = v
15
+ .string()
16
+ .assert((input) => isAtprotoAudience(input) || isDid(input), `must be a did or atproto audience`);
13
17
  const nsidString = v.string().assert(isNsid, `must be an nsid`);
14
18
 
15
19
  const integer = v.number().assert((input) => input >= 0 && Number.isSafeInteger(input), `must be an integer`);
@@ -17,19 +21,24 @@ const integer = v.number().assert((input) => input >= 0 && Number.isSafeInteger(
17
21
  export interface JwtHeader {
18
22
  typ?: string;
19
23
  alg: string;
24
+ /** signing key identifier; a DID fragment, defaults to `#atproto` when absent */
25
+ kid?: string;
20
26
  }
21
27
 
22
28
  const jwtHeader: v.Type<JwtHeader> = v.object({
23
29
  typ: v.string().optional(),
24
30
  alg: v.string(),
31
+ kid: v.string().optional(),
25
32
  });
26
33
 
27
34
  export interface JwtPayload {
28
35
  iss: Did;
29
- aud: Did;
36
+ aud: Did | AtprotoAudience;
30
37
  exp: number;
31
38
  iat?: number;
32
- lxm?: Nsid;
39
+ /** not-before time; token is invalid before this unix timestamp */
40
+ nbf?: number;
41
+ lxm: Nsid;
33
42
  jti?: string;
34
43
  }
35
44
 
@@ -37,14 +46,16 @@ const jwtPayload: v.Type<JwtPayload> = v
37
46
  .object({
38
47
  /** issuer */
39
48
  iss: didString,
40
- /** target audience */
41
- aud: didString,
49
+ /** target audience; a bare DID or a DID with service fragment (e.g. `did:web:x.example#svc`) */
50
+ aud: audienceString,
42
51
  /** expiration time */
43
52
  exp: integer,
44
53
  /** creation time */
45
54
  iat: integer.optional(),
46
- /** xrpc operation being invoked */
47
- lxm: nsidString.optional(),
55
+ /** not-before time */
56
+ nbf: integer.optional(),
57
+ /** xrpc operation being invoked; required per atproto service auth spec */
58
+ lxm: nsidString,
48
59
  /** unique identifier */
49
60
  jti: v.string().optional(),
50
61
  })
@@ -74,7 +85,7 @@ const readJwtPortion = <T>(schema: v.Type<T>, input: string): Result<T, AuthErro
74
85
  return {
75
86
  ok: false,
76
87
  error: {
77
- error: `MalformedJwt`,
88
+ error: `BadJwt`,
78
89
  description: `jwt is malformed`,
79
90
  },
80
91
  };
@@ -88,7 +99,7 @@ const readJwtSignature = (input: string): Result<Uint8Array<ArrayBuffer>, AuthEr
88
99
  return {
89
100
  ok: false,
90
101
  error: {
91
- error: `MalformedJwt`,
102
+ error: `BadJwt`,
92
103
  description: `jwt is malformed`,
93
104
  },
94
105
  };
@@ -100,7 +111,7 @@ export const parseJwt = (jwtString: string): Result<ParsedJwt, AuthError> => {
100
111
  return {
101
112
  ok: false,
102
113
  error: {
103
- error: `MalformedJwt`,
114
+ error: `BadJwt`,
104
115
  description: `jwt is malformed`,
105
116
  },
106
117
  };
@@ -38,8 +38,13 @@ type InternalRouteData = {
38
38
  export type FetchMiddleware = Middleware<[request: Request], Promise<Response>>;
39
39
 
40
40
  export type NotFoundHandler = (request: Request) => Promisable<Response>;
41
+ export type HealthCheckHandler = (request: Request) => Promisable<Response>;
41
42
  export type ExceptionHandler = (error: unknown, request: Request) => Promisable<Response>;
42
- export type SubscriptionExceptionHandler = (error: unknown, request: Request) => void;
43
+
44
+ /** telemetry hook invoked for unexpected HTTP handler errors; fire-and-forget. */
45
+ export type ErrorObserver = (ctx: { error: unknown; request: Request }) => void;
46
+ /** telemetry hook invoked for unexpected subscription errors; fire-and-forget. */
47
+ export type SocketErrorObserver = (ctx: { error: unknown; request: Request }) => void;
43
48
 
44
49
  export const defaultExceptionHandler: ExceptionHandler = (error: unknown) => {
45
50
  if (error instanceof XRPCError) {
@@ -60,23 +65,40 @@ export const defaultNotFoundHandler: NotFoundHandler = () => {
60
65
  return new Response('Not Found', { status: 404 });
61
66
  };
62
67
 
63
- export const defaultSubscriptionExceptionHandler: SubscriptionExceptionHandler = (error: unknown) => {
64
- throw error;
65
- };
66
-
67
68
  export interface XRPCRouterOptions {
68
69
  middlewares?: FetchMiddleware[];
69
70
  handleNotFound?: NotFoundHandler;
71
+ /**
72
+ * optional handler for `/xrpc/_health`. when provided, the router answers
73
+ * health-check requests by invoking this handler; when absent, the path
74
+ * falls through to `handleNotFound`. `_health` is not part of the atproto
75
+ * XRPC spec, so callers opt in explicitly.
76
+ */
77
+ handleHealthCheck?: HealthCheckHandler;
78
+ /** translates a thrown error into an HTTP response. */
70
79
  handleException?: ExceptionHandler;
71
- handleSubscriptionException?: SubscriptionExceptionHandler;
80
+ /**
81
+ * fire-and-forget telemetry hook for unexpected HTTP errors. not invoked for
82
+ * client-induced errors (aborted requests, `XRPCError` subclasses, thrown
83
+ * `Response` objects).
84
+ */
85
+ onError?: ErrorObserver;
86
+ /**
87
+ * fire-and-forget telemetry hook for unexpected subscription errors. not
88
+ * invoked for aborted signals or `XRPCSubscriptionError` (which is
89
+ * translated to an error frame).
90
+ */
91
+ onSocketError?: SocketErrorObserver;
72
92
  websocket?: WebSocketAdapter;
73
93
  }
74
94
 
75
95
  export class XRPCRouter {
76
96
  #handlers: Record<string, InternalRouteData> = {};
77
97
  #handleNotFound: NotFoundHandler;
98
+ #handleHealthCheck?: HealthCheckHandler;
78
99
  #handleException: ExceptionHandler;
79
- #handleSubscriptionException: SubscriptionExceptionHandler;
100
+ #onError?: ErrorObserver;
101
+ #onSocketError?: SocketErrorObserver;
80
102
  #websocket?: WebSocketAdapter;
81
103
 
82
104
  fetch: (request: Request) => Promise<Response>;
@@ -85,7 +107,9 @@ export class XRPCRouter {
85
107
  middlewares = [],
86
108
  handleException = defaultExceptionHandler,
87
109
  handleNotFound = defaultNotFoundHandler,
88
- handleSubscriptionException = defaultSubscriptionExceptionHandler,
110
+ handleHealthCheck,
111
+ onError,
112
+ onSocketError,
89
113
  websocket,
90
114
  }: XRPCRouterOptions = {}) {
91
115
  const runner = createAsyncMiddlewareRunner([...middlewares, (request) => this.#dispatch(request)]);
@@ -93,10 +117,46 @@ export class XRPCRouter {
93
117
  this.fetch = (request) => runner(request);
94
118
  this.#handleException = handleException;
95
119
  this.#handleNotFound = handleNotFound;
96
- this.#handleSubscriptionException = handleSubscriptionException;
120
+ this.#handleHealthCheck = handleHealthCheck;
121
+ this.#onError = onError;
122
+ this.#onSocketError = onSocketError;
97
123
  this.#websocket = websocket;
98
124
  }
99
125
 
126
+ #observeError(error: unknown, request: Request): void {
127
+ // client-induced errors are not bugs; skip telemetry
128
+ if (request.signal.aborted) {
129
+ return;
130
+ }
131
+ if (error instanceof XRPCError) {
132
+ return;
133
+ }
134
+ if (error instanceof Response) {
135
+ return;
136
+ }
137
+
138
+ try {
139
+ this.#onError?.({ error, request });
140
+ } catch {
141
+ // observer threw; swallow to keep response path deterministic
142
+ }
143
+ }
144
+
145
+ #observeSocketError(error: unknown, request: Request): void {
146
+ if (request.signal.aborted) {
147
+ return;
148
+ }
149
+ if (error instanceof XRPCSubscriptionError) {
150
+ return;
151
+ }
152
+
153
+ try {
154
+ this.#onSocketError?.({ error, request });
155
+ } catch {
156
+ // observer threw; swallow to keep socket close path deterministic
157
+ }
158
+ }
159
+
100
160
  async #dispatch(request: Request): Promise<Response> {
101
161
  const url = new URL(request.url);
102
162
  const pathname = url.pathname;
@@ -107,15 +167,31 @@ export class XRPCRouter {
107
167
 
108
168
  const nsid = pathname.slice('/xrpc/'.length);
109
169
 
170
+ if (nsid === '_health' && this.#handleHealthCheck !== undefined) {
171
+ try {
172
+ return await this.#handleHealthCheck(request);
173
+ } catch (err) {
174
+ if (request.signal.aborted) {
175
+ return new Response(null, { status: 499 });
176
+ }
177
+
178
+ this.#observeError(err, request);
179
+ return this.#handleException(err, request);
180
+ }
181
+ }
182
+
110
183
  const route = this.#handlers[nsid];
111
184
  if (route === undefined) {
112
185
  return this.#handleNotFound(request);
113
186
  }
114
187
 
115
- if (request.method !== route.method) {
188
+ // allow HEAD alongside GET; the runtime is responsible for stripping the
189
+ // response body per the Fetch API.
190
+ const allowed = request.method === route.method || (route.method === 'GET' && request.method === 'HEAD');
191
+ if (!allowed) {
116
192
  return Response.json(
117
- { error: 'InvalidHttpMethod', message: `invalid http method (expected ${route.method})` },
118
- { status: 405, headers: { allow: `${route.method}` } },
193
+ { error: 'InvalidRequest', message: `invalid http method (expected ${route.method})` },
194
+ { status: 405, headers: { allow: route.method === 'GET' ? 'GET, HEAD' : route.method } },
119
195
  );
120
196
  }
121
197
 
@@ -131,6 +207,7 @@ export class XRPCRouter {
131
207
  return new Response(null, { status: 499 });
132
208
  }
133
209
 
210
+ this.#observeError(err, request);
134
211
  return this.#handleException(err, request);
135
212
  }
136
213
  }
@@ -350,23 +427,29 @@ export class XRPCRouter {
350
427
 
351
428
  const frame = encodeMessageFrame(body, type);
352
429
  await ws.send(frame);
430
+ const drained = ws.drain();
431
+ if (drained) {
432
+ await drained;
433
+ }
353
434
  }
354
435
 
355
436
  ws.close(1000);
356
437
  } catch (err) {
357
438
  if (err instanceof XRPCSubscriptionError) {
358
- const frame = encodeErrorFrame(err.error, err.description);
439
+ const frame = encodeErrorFrame(err.error, err.message || undefined);
359
440
 
360
441
  try {
361
442
  await ws.send(frame);
362
- } catch {}
443
+ } catch {
444
+ // best-effort, socket may already be closed
445
+ }
363
446
 
364
447
  ws.close(err.closeCode, err.error);
365
448
  return;
366
449
  }
367
450
 
368
451
  ws.close(1011, `internal server error`);
369
- this.#handleSubscriptionException(err, request);
452
+ this.#observeSocketError(err, request);
370
453
  }
371
454
  });
372
455
 
@@ -12,6 +12,14 @@ import type {
12
12
  import type { Literal, Promisable } from '../../types/misc.ts';
13
13
  import type { JSONResponse } from '../response.ts';
14
14
 
15
+ /**
16
+ * untyped variant of {@link QueryContext} / {@link ProcedureContext}.
17
+ *
18
+ * `input` is set only when the lexicon declares a `lex` input body and the
19
+ * request JSON parsed successfully. for blob inputs (and for procedures that
20
+ * declare no input at all) it is `undefined`; handlers that expect a blob
21
+ * should stream from `request.body` directly.
22
+ */
15
23
  export type UnknownOperationContext = {
16
24
  request: Request;
17
25
  signal: AbortSignal;
@@ -3,6 +3,14 @@ import type { Promisable } from '../../types/misc.ts';
3
3
  export interface WebSocketConnection {
4
4
  signal: AbortSignal;
5
5
  send(data: Uint8Array): void | Promise<void>;
6
+ /**
7
+ * backpressure hook invoked by the router after every frame it sends.
8
+ * adapters that can observe the outgoing send buffer (Node `ws`, Bun, Deno)
9
+ * should resolve only once the buffer has drained below a healthy threshold.
10
+ * adapters without that visibility (e.g. Cloudflare Workers) should return
11
+ * synchronously.
12
+ */
13
+ drain(): void | Promise<void>;
6
14
  close(code?: number, reason?: string): void;
7
15
  }
8
16