@richie-rpc/server 1.2.6 → 1.2.7
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 +134 -81
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/websocket.cjs +67 -34
- package/dist/cjs/websocket.cjs.map +3 -3
- package/dist/mjs/index.mjs.map +1 -1
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/websocket.mjs +67 -34
- package/dist/mjs/websocket.mjs.map +3 -3
- package/dist/types/websocket.d.ts +73 -49
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,7 +96,7 @@ Bun.serve({
|
|
|
96
96
|
- ✅ Type-safe status codes with `Status` const object
|
|
97
97
|
- ✅ HTTP Streaming with `StreamEmitter`
|
|
98
98
|
- ✅ Server-Sent Events with `SSEEmitter`
|
|
99
|
-
- ✅ WebSocket router with
|
|
99
|
+
- ✅ WebSocket router with typed messages and custom data validation
|
|
100
100
|
- ✅ Path parameter matching
|
|
101
101
|
- ✅ Query parameter parsing
|
|
102
102
|
- ✅ JSON body parsing
|
|
@@ -332,10 +332,10 @@ const router = createRouter(contract, {
|
|
|
332
332
|
|
|
333
333
|
## WebSocket Router
|
|
334
334
|
|
|
335
|
-
For bidirectional real-time communication, use `createWebSocketRouter
|
|
335
|
+
For bidirectional real-time communication, use `createWebSocketRouter`. The router is generic over the WebSocket type, making it portable across different runtimes (Bun, Node.js with `ws`, Deno, etc.).
|
|
336
336
|
|
|
337
337
|
```typescript
|
|
338
|
-
import { createWebSocketRouter } from '@richie-rpc/server';
|
|
338
|
+
import { createWebSocketRouter, type UpgradeData } from '@richie-rpc/server';
|
|
339
339
|
import { defineWebSocketContract } from '@richie-rpc/core';
|
|
340
340
|
|
|
341
341
|
const wsContract = defineWebSocketContract({
|
|
@@ -352,115 +352,132 @@ const wsContract = defineWebSocketContract({
|
|
|
352
352
|
},
|
|
353
353
|
});
|
|
354
354
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
open(ws) {
|
|
358
|
-
// Called when connection opens
|
|
359
|
-
ws.subscribe(`room:${ws.data.params.roomId}`);
|
|
360
|
-
console.log('User joined room:', ws.data.params.roomId);
|
|
361
|
-
},
|
|
362
|
-
|
|
363
|
-
message(ws, msg) {
|
|
364
|
-
// Called for each validated client message
|
|
365
|
-
if (msg.type === 'sendMessage') {
|
|
366
|
-
ws.publish(`room:${ws.data.params.roomId}`, 'message', {
|
|
367
|
-
userId: 'user1',
|
|
368
|
-
text: msg.payload.text,
|
|
369
|
-
});
|
|
370
|
-
}
|
|
371
|
-
},
|
|
372
|
-
|
|
373
|
-
close(ws) {
|
|
374
|
-
// Called when connection closes
|
|
375
|
-
console.log('User left room:', ws.data.params.roomId);
|
|
376
|
-
},
|
|
377
|
-
|
|
378
|
-
validationError(ws, error) {
|
|
379
|
-
// Called when client message validation fails
|
|
380
|
-
ws.send('error', { code: 'VALIDATION_ERROR', message: error.message });
|
|
381
|
-
},
|
|
382
|
-
},
|
|
383
|
-
});
|
|
384
|
-
```
|
|
385
|
-
|
|
386
|
-
### Typed Per-Connection State
|
|
387
|
-
|
|
388
|
-
You can store typed per-connection state by defining a state interface and passing it as an option. This follows the same pattern as Bun's WebSocket data:
|
|
389
|
-
|
|
390
|
-
```typescript
|
|
391
|
-
// Define your per-connection state type
|
|
392
|
-
interface ChatConnectionState {
|
|
393
|
-
connectionId: string;
|
|
394
|
-
userId?: string;
|
|
395
|
-
username?: string;
|
|
396
|
-
}
|
|
355
|
+
// Define your WebSocket type (Bun example)
|
|
356
|
+
type BunWS = Bun.ServerWebSocket<UpgradeData>;
|
|
397
357
|
|
|
398
358
|
const wsRouter = createWebSocketRouter(
|
|
399
359
|
wsContract,
|
|
400
360
|
{
|
|
401
361
|
chat: {
|
|
402
|
-
open(ws) {
|
|
403
|
-
//
|
|
404
|
-
ws.
|
|
405
|
-
ws.subscribe(`room:${
|
|
362
|
+
open({ ws, params }) {
|
|
363
|
+
// Called when connection opens
|
|
364
|
+
// ws.raw is typed as BunWS, so you get Bun-specific methods
|
|
365
|
+
ws.raw.subscribe(`room:${params.roomId}`);
|
|
366
|
+
console.log('User joined room:', params.roomId);
|
|
406
367
|
},
|
|
407
368
|
|
|
408
|
-
message(ws, msg) {
|
|
409
|
-
//
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
369
|
+
message({ ws, message: msg, params }) {
|
|
370
|
+
// Called for each validated client message
|
|
371
|
+
if (msg.type === 'sendMessage') {
|
|
372
|
+
ws.raw.publish(
|
|
373
|
+
`room:${params.roomId}`,
|
|
374
|
+
JSON.stringify({
|
|
375
|
+
type: 'message',
|
|
376
|
+
payload: { userId: 'user1', text: msg.payload.text },
|
|
377
|
+
})
|
|
378
|
+
);
|
|
416
379
|
}
|
|
380
|
+
},
|
|
417
381
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
text: msg.payload.text,
|
|
422
|
-
});
|
|
423
|
-
}
|
|
382
|
+
close({ params }) {
|
|
383
|
+
// Called when connection closes
|
|
384
|
+
console.log('User left room:', params.roomId);
|
|
424
385
|
},
|
|
425
386
|
|
|
426
|
-
|
|
427
|
-
//
|
|
428
|
-
|
|
387
|
+
validationError({ ws, error }) {
|
|
388
|
+
// Called when client message validation fails
|
|
389
|
+
ws.send('error', { code: 'VALIDATION_ERROR', message: error.message });
|
|
429
390
|
},
|
|
430
391
|
},
|
|
431
392
|
},
|
|
432
|
-
|
|
433
|
-
|
|
393
|
+
{
|
|
394
|
+
// Pass rawWebSocket for type inference - ws.raw will be typed as BunWS
|
|
395
|
+
rawWebSocket: {} as BunWS,
|
|
396
|
+
}
|
|
434
397
|
);
|
|
435
398
|
```
|
|
436
399
|
|
|
437
|
-
|
|
400
|
+
### Handler Arguments
|
|
401
|
+
|
|
402
|
+
All handlers receive a destructured object with:
|
|
403
|
+
|
|
404
|
+
- `ws` - TypedServerWebSocket for sending typed messages
|
|
405
|
+
- `params` - Path parameters (typed from contract)
|
|
406
|
+
- `query` - Query parameters (typed from contract)
|
|
407
|
+
- `headers` - Request headers (typed from contract)
|
|
408
|
+
- `data` - Custom user data (when `dataSchema` is provided)
|
|
409
|
+
- `message` - The validated client message (only in `message` handler)
|
|
438
410
|
|
|
439
411
|
### TypedServerWebSocket Interface
|
|
440
412
|
|
|
441
413
|
- `send(type, payload)` - Send a typed message to the client
|
|
442
|
-
- `subscribe(topic)` - Subscribe to a pub/sub topic
|
|
443
|
-
- `unsubscribe(topic)` - Unsubscribe from a topic
|
|
444
|
-
- `publish(topic, type, payload)` - Broadcast to all subscribers
|
|
445
414
|
- `close(code?, reason?)` - Close the connection
|
|
446
|
-
- `
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
415
|
+
- `raw` - Access the underlying WebSocket (typed based on `rawWebSocket` option)
|
|
416
|
+
|
|
417
|
+
### Generic WebSocket Type
|
|
418
|
+
|
|
419
|
+
The router uses a `GenericWebSocket` interface that any WebSocket implementation can satisfy:
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
type GenericWebSocket = {
|
|
423
|
+
send: (message: string) => void;
|
|
424
|
+
close: (code?: number, reason?: string) => void;
|
|
425
|
+
};
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
Use the `rawWebSocket` option to provide type hints for your specific WebSocket implementation. This enables full type inference for `ws.raw` in your handlers.
|
|
429
|
+
|
|
430
|
+
### Using Custom Data with dataSchema
|
|
431
|
+
|
|
432
|
+
You can pass custom data during WebSocket upgrade and have it validated:
|
|
433
|
+
|
|
434
|
+
```typescript
|
|
435
|
+
const wsRouter = createWebSocketRouter(
|
|
436
|
+
wsContract,
|
|
437
|
+
{
|
|
438
|
+
chat: {
|
|
439
|
+
message({ ws, message, data }) {
|
|
440
|
+
// data is typed and validated
|
|
441
|
+
console.log('User ID:', data.userId);
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
rawWebSocket: {} as BunWS,
|
|
447
|
+
dataSchema: z.object({
|
|
448
|
+
userId: z.string(),
|
|
449
|
+
sessionId: z.string(),
|
|
450
|
+
}),
|
|
451
|
+
}
|
|
452
|
+
);
|
|
453
|
+
```
|
|
450
454
|
|
|
451
455
|
### Integrating with Bun.serve
|
|
452
456
|
|
|
453
457
|
```typescript
|
|
454
|
-
Bun.serve({
|
|
458
|
+
Bun.serve<UpgradeData>({
|
|
455
459
|
port: 3000,
|
|
456
460
|
|
|
457
|
-
websocket:
|
|
461
|
+
websocket: {
|
|
462
|
+
open(ws) {
|
|
463
|
+
wsRouter.websocketHandler.open({ ws, upgradeData: ws.data });
|
|
464
|
+
},
|
|
465
|
+
message(ws, rawMessage) {
|
|
466
|
+
wsRouter.websocketHandler.message({ ws, rawMessage, upgradeData: ws.data });
|
|
467
|
+
},
|
|
468
|
+
close(ws, code, reason) {
|
|
469
|
+
wsRouter.websocketHandler.close({ ws, code, reason, upgradeData: ws.data });
|
|
470
|
+
},
|
|
471
|
+
drain(ws) {
|
|
472
|
+
wsRouter.websocketHandler.drain({ ws, upgradeData: ws.data });
|
|
473
|
+
},
|
|
474
|
+
},
|
|
458
475
|
|
|
459
476
|
async fetch(request, server) {
|
|
460
477
|
// Try WebSocket upgrade
|
|
461
|
-
const
|
|
462
|
-
if (
|
|
463
|
-
if (server.upgrade(request, { data:
|
|
478
|
+
const upgradeData = await wsRouter.matchAndPrepareUpgrade(request);
|
|
479
|
+
if (upgradeData && request.headers.get('upgrade') === 'websocket') {
|
|
480
|
+
if (server.upgrade(request, { data: upgradeData })) return;
|
|
464
481
|
}
|
|
465
482
|
|
|
466
483
|
// Handle regular HTTP requests
|
|
@@ -469,6 +486,42 @@ Bun.serve({
|
|
|
469
486
|
});
|
|
470
487
|
```
|
|
471
488
|
|
|
489
|
+
### With Custom Data
|
|
490
|
+
|
|
491
|
+
When using `dataSchema`, pass the data to each handler:
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
Bun.serve<UpgradeData>({
|
|
495
|
+
websocket: {
|
|
496
|
+
open(ws) {
|
|
497
|
+
wsRouter.websocketHandler.open({
|
|
498
|
+
ws,
|
|
499
|
+
upgradeData: ws.data,
|
|
500
|
+
data: { userId: 'user123', sessionId: 'sess456' },
|
|
501
|
+
});
|
|
502
|
+
},
|
|
503
|
+
message(ws, rawMessage) {
|
|
504
|
+
wsRouter.websocketHandler.message({
|
|
505
|
+
ws,
|
|
506
|
+
rawMessage,
|
|
507
|
+
upgradeData: ws.data,
|
|
508
|
+
data: { userId: 'user123', sessionId: 'sess456' },
|
|
509
|
+
});
|
|
510
|
+
},
|
|
511
|
+
// ... other handlers also receive data
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
async fetch(request, server) {
|
|
515
|
+
const upgradeData = await wsRouter.matchAndPrepareUpgrade(request);
|
|
516
|
+
if (upgradeData && request.headers.get('upgrade') === 'websocket') {
|
|
517
|
+
server.upgrade(request, { data: upgradeData });
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
return new Response('Not found', { status: 404 });
|
|
521
|
+
},
|
|
522
|
+
});
|
|
523
|
+
```
|
|
524
|
+
|
|
472
525
|
## Error Handling
|
|
473
526
|
|
|
474
527
|
The router throws specific error classes that you can catch and handle. These errors are thrown before handlers are called, so you should wrap your router calls in try-catch blocks.
|
package/dist/cjs/index.cjs.map
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../index.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
|
-
"import type {\n Contract,\n DownloadEndpointDefinition,\n EndpointDefinition,\n ExtractBody,\n ExtractChunk,\n ExtractFinalResponse,\n ExtractHeaders,\n ExtractParams,\n ExtractQuery,\n ExtractSSEEventData,\n SSEEndpointDefinition,\n StandardEndpointDefinition,\n StreamingEndpointDefinition,\n} from '@richie-rpc/core';\nimport { formDataToObject, matchPath, parseQuery, Status } from '@richie-rpc/core';\nimport type { z } from 'zod';\n\n// Re-export Status for convenience\nexport { Status };\n\n// Handler input types (for standard endpoints)\nexport type HandlerInput<T extends StandardEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n body: ExtractBody<T>;\n request: Request;\n context: C;\n};\n\n// Handler response type (for standard endpoints)\nexport type HandlerResponse<T extends StandardEndpointDefinition> = {\n [Status in keyof T['responses']]: {\n status: Status;\n body: T['responses'][Status] extends z.ZodTypeAny ? z.infer<T['responses'][Status]> : never;\n headers?: Record<string, string>;\n };\n}[keyof T['responses']];\n\n// Handler function type (for standard endpoints)\nexport type Handler<T extends StandardEndpointDefinition, C = unknown> = (\n input: HandlerInput<T, C>,\n) => Promise<HandlerResponse<T>> | HandlerResponse<T>;\n\n// ============================================\n// Streaming Endpoint Types\n// ============================================\n\n/**\n * Emitter for streaming responses - push-based API\n */\nexport interface StreamEmitter<T extends StreamingEndpointDefinition> {\n /** Send a chunk to the client */\n send(chunk: ExtractChunk<T>): void;\n /** Close the stream with optional final response */\n close(final?: ExtractFinalResponse<T>): void;\n /** Check if stream is still open */\n readonly isOpen: boolean;\n}\n\n/**\n * Handler input for streaming endpoints\n */\nexport type StreamingHandlerInput<T extends StreamingEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n body: ExtractBody<T>;\n request: Request;\n context: C;\n stream: StreamEmitter<T>;\n};\n\n/**\n * Handler function type for streaming endpoints\n */\nexport type StreamingHandler<T extends StreamingEndpointDefinition, C = unknown> = (\n input: StreamingHandlerInput<T, C>,\n) => void | Promise<void>;\n\n// ============================================\n// SSE Endpoint Types\n// ============================================\n\n/**\n * Emitter for SSE responses\n */\nexport interface SSEEmitter<T extends SSEEndpointDefinition> {\n /** Send an event to the client */\n send<K extends keyof T['events']>(\n event: K,\n data: ExtractSSEEventData<T, K>,\n options?: { id?: string },\n ): void;\n /** Close the connection */\n close(): void;\n /** Check if connection is still open */\n readonly isOpen: boolean;\n}\n\n/**\n * Handler input for SSE endpoints\n */\nexport type SSEHandlerInput<T extends SSEEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n request: Request;\n context: C;\n emitter: SSEEmitter<T>;\n /** AbortSignal for detecting client disconnect */\n signal: AbortSignal;\n};\n\n/**\n * Handler function type for SSE endpoints\n * Returns an optional cleanup function\n */\nexport type SSEHandler<T extends SSEEndpointDefinition, C = unknown> = (\n input: SSEHandlerInput<T, C>,\n) => void | (() => void) | Promise<void | (() => void)>;\n\n// ============================================\n// Download Endpoint Types\n// ============================================\n\n/**\n * Handler input for download endpoints\n */\nexport type DownloadHandlerInput<T extends DownloadEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n request: Request;\n context: C;\n};\n\n/**\n * Handler response for download endpoints\n * Success (200) returns File, errors return typed response\n */\nexport type DownloadHandlerResponse<T extends DownloadEndpointDefinition> =\n | { status: 200; body: File; headers?: Record<string, string> }\n | (T['errorResponses'] extends Record<number, z.ZodTypeAny>\n ? {\n [S in keyof T['errorResponses']]: {\n status: S;\n body: T['errorResponses'][S] extends z.ZodTypeAny\n ? z.infer<T['errorResponses'][S]>\n : never;\n headers?: Record<string, string>;\n };\n }[keyof T['errorResponses']]\n : never);\n\n/**\n * Handler function type for download endpoints\n */\nexport type DownloadHandler<T extends DownloadEndpointDefinition, C = unknown> = (\n input: DownloadHandlerInput<T, C>,\n) => Promise<DownloadHandlerResponse<T>> | DownloadHandlerResponse<T>;\n\n// ============================================\n// Contract Handlers (supports all endpoint types)\n// ============================================\n\n/**\n * Contract handlers mapping - conditionally applies handler type based on endpoint type\n */\nexport type ContractHandlers<T extends Contract, C = unknown> = {\n [K in keyof T]: T[K] extends StandardEndpointDefinition\n ? Handler<T[K], C>\n : T[K] extends StreamingEndpointDefinition\n ? StreamingHandler<T[K], C>\n : T[K] extends SSEEndpointDefinition\n ? SSEHandler<T[K], C>\n : T[K] extends DownloadEndpointDefinition\n ? DownloadHandler<T[K], C>\n : never;\n};\n\n// Error classes\nexport class ValidationError extends Error {\n constructor(\n public field: string,\n public issues: z.ZodIssue[],\n message?: string,\n ) {\n super(message || `Validation failed for ${field}`);\n this.name = 'ValidationError';\n }\n}\n\nexport class RouteNotFoundError extends Error {\n constructor(\n public path: string,\n public method: string,\n ) {\n super(`Route not found: ${method} ${path}`);\n this.name = 'RouteNotFoundError';\n }\n}\n\n/**\n * Parse and validate request data\n */\nasync function parseRequest<T extends StandardEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<HandlerInput<T, C>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // Parse body\n let body: any;\n if (endpoint.body) {\n const contentType = request.headers.get('content-type') || '';\n let bodyData: any;\n\n if (contentType.includes('application/json')) {\n bodyData = await request.json();\n } else if (contentType.includes('multipart/form-data')) {\n const formData = await request.formData();\n bodyData = formDataToObject(formData as FormData);\n } else {\n bodyData = await request.text();\n }\n\n const result = endpoint.body.safeParse(bodyData);\n if (!result.success) {\n throw new ValidationError('body', result.error.issues);\n }\n body = result.data;\n }\n\n return { params, query, headers, body, request, context } as HandlerInput<T, C>;\n}\n\n/**\n * Parse and validate request data for streaming endpoints\n */\nasync function parseStreamingRequest<T extends StreamingEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<Omit<StreamingHandlerInput<T, C>, 'stream'>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // Parse body\n let body: any;\n if (endpoint.body) {\n const contentType = request.headers.get('content-type') || '';\n let bodyData: any;\n\n if (contentType.includes('application/json')) {\n bodyData = await request.json();\n } else if (contentType.includes('multipart/form-data')) {\n const formData = await request.formData();\n bodyData = formDataToObject(formData as FormData);\n } else {\n bodyData = await request.text();\n }\n\n const result = endpoint.body.safeParse(bodyData);\n if (!result.success) {\n throw new ValidationError('body', result.error.issues);\n }\n body = result.data;\n }\n\n return { params, query, headers, body, request, context } as Omit<\n StreamingHandlerInput<T, C>,\n 'stream'\n >;\n}\n\n/**\n * Parse and validate request data for SSE endpoints\n */\nasync function parseSSERequest<T extends SSEEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<Omit<SSEHandlerInput<T, C>, 'emitter' | 'signal'>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // SSE endpoints don't have a body (GET only)\n return { params, query, headers, request, context } as Omit<\n SSEHandlerInput<T, C>,\n 'emitter' | 'signal'\n >;\n}\n\n/**\n * Parse and validate request data for download endpoints\n */\nasync function parseDownloadRequest<T extends DownloadEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<DownloadHandlerInput<T, C>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // Download endpoints don't have a body (GET only)\n return { params, query, headers, request, context } as DownloadHandlerInput<T, C>;\n}\n\n/**\n * Validate and create response\n */\nfunction createResponse<T extends StandardEndpointDefinition>(\n endpoint: T,\n handlerResponse: HandlerResponse<T>,\n): Response {\n const { status, body, headers: customHeaders } = handlerResponse;\n\n // Validate response body\n const responseSchema = endpoint.responses[status as keyof typeof endpoint.responses];\n if (responseSchema) {\n const result = responseSchema.safeParse(body);\n if (!result.success) {\n throw new ValidationError(`response[${String(status)}]`, result.error.issues);\n }\n }\n\n // Create response headers\n const responseHeaders = new Headers(customHeaders);\n\n // Handle 204 No Content - must have no body\n if (status === 204) {\n return new Response(null, {\n status: 204,\n headers: responseHeaders,\n });\n }\n\n // For all other responses, return JSON\n if (!responseHeaders.has('content-type')) {\n responseHeaders.set('content-type', 'application/json');\n }\n\n return new Response(JSON.stringify(body), {\n status: status as number,\n headers: responseHeaders,\n });\n}\n\n/**\n * Create response for download endpoints\n */\nfunction createDownloadResponse<T extends DownloadEndpointDefinition>(\n _endpoint: T,\n handlerResponse: DownloadHandlerResponse<T>,\n): Response {\n const { status, body, headers: customHeaders } = handlerResponse;\n const responseHeaders = new Headers(customHeaders);\n\n // Success: return File as binary\n if (status === 200 && body instanceof File) {\n if (!responseHeaders.has('content-type')) {\n responseHeaders.set('content-type', body.type || 'application/octet-stream');\n }\n responseHeaders.set('content-length', String(body.size));\n if (!responseHeaders.has('content-disposition')) {\n const filename = encodeURIComponent(body.name);\n responseHeaders.set(\n 'content-disposition',\n `attachment; filename=\"${filename}\"; filename*=UTF-8''${filename}`,\n );\n }\n return new Response(body, { status: 200, headers: responseHeaders });\n }\n\n // Error: return JSON\n if (!responseHeaders.has('content-type')) {\n responseHeaders.set('content-type', 'application/json');\n }\n return new Response(JSON.stringify(body), {\n status: status as number,\n headers: responseHeaders,\n });\n}\n\n/**\n * Create a streaming NDJSON response\n */\nfunction createStreamingResponse<T extends StreamingEndpointDefinition>(\n handler: (stream: StreamEmitter<T>) => void | Promise<void>,\n): Response {\n const { readable, writable } = new TransformStream();\n const writer = writable.getWriter();\n const encoder = new TextEncoder();\n let closed = false;\n\n const stream: StreamEmitter<T> = {\n send(chunk) {\n if (!closed) {\n writer.write(encoder.encode(`${JSON.stringify(chunk)}\\n`));\n }\n },\n close(final) {\n if (!closed) {\n closed = true;\n if (final !== undefined) {\n writer.write(encoder.encode(`${JSON.stringify({ __final__: true, data: final })}\\n`));\n }\n writer.close();\n }\n },\n get isOpen() {\n return !closed;\n },\n };\n\n // Execute handler (don't await - let it run in background)\n Promise.resolve(handler(stream)).catch((err) => {\n console.error('Streaming handler error:', err);\n stream.close();\n });\n\n return new Response(readable, {\n headers: {\n 'Content-Type': 'application/x-ndjson',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n });\n}\n\n/**\n * Create an SSE response\n */\nfunction createSSEResponse<T extends SSEEndpointDefinition>(\n handler: (\n emitter: SSEEmitter<T>,\n signal: AbortSignal,\n ) => void | (() => void) | Promise<void | (() => void)>,\n): Response {\n const { readable, writable } = new TransformStream();\n const writer = writable.getWriter();\n const encoder = new TextEncoder();\n const controller = new AbortController();\n let closed = false;\n let cleanup: (() => void) | undefined;\n\n const emitter: SSEEmitter<T> = {\n send(event, data, options) {\n if (!closed) {\n let message = '';\n if (options?.id) {\n message += `id: ${options.id}\\n`;\n }\n message += `event: ${String(event)}\\n`;\n message += `data: ${JSON.stringify(data)}\\n\\n`;\n writer.write(encoder.encode(message));\n }\n },\n close() {\n if (!closed) {\n closed = true;\n if (cleanup) cleanup();\n writer.close();\n }\n },\n get isOpen() {\n return !closed;\n },\n };\n\n // Execute handler and get cleanup function\n Promise.resolve(handler(emitter, controller.signal))\n .then((cleanupFn) => {\n if (typeof cleanupFn === 'function') {\n cleanup = cleanupFn;\n }\n })\n .catch((err) => {\n console.error('SSE handler error:', err);\n emitter.close();\n });\n\n // Tee the stream - one for the response, one for disconnect detection\n const [responseStream, detectStream] = readable.tee();\n\n // Handle client disconnect by detecting when the stream is cancelled\n detectStream.pipeTo(new WritableStream()).catch(() => {\n controller.abort();\n emitter.close();\n });\n\n return new Response(responseStream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n });\n}\n\n/**\n * Router configuration options\n */\nexport interface RouterOptions<C = unknown> {\n basePath?: string;\n context?: (request: Request, routeName?: string, endpoint?: EndpointDefinition) => C | Promise<C>;\n}\n\n/**\n * Router class that manages contract endpoints\n */\nexport class Router<T extends Contract, C = unknown> {\n private basePath: string;\n private contextFactory?: (\n request: Request,\n routeName: string,\n endpoint: EndpointDefinition,\n ) => C | Promise<C>;\n\n constructor(\n private contract: T,\n private handlers: ContractHandlers<T, C>,\n options?: RouterOptions<C>,\n ) {\n // Normalize basePath: ensure it starts with / and doesn't end with /\n const bp = options?.basePath || '';\n if (bp) {\n this.basePath = bp.startsWith('/') ? bp : `/${bp}`;\n this.basePath = this.basePath.endsWith('/') ? this.basePath.slice(0, -1) : this.basePath;\n } else {\n this.basePath = '';\n }\n this.contextFactory = options?.context;\n }\n\n /**\n * Find matching endpoint for a request\n */\n private findEndpoint(\n method: string,\n path: string,\n ): {\n name: keyof T;\n endpoint: EndpointDefinition;\n params: Record<string, string>;\n } | null {\n for (const [name, endpoint] of Object.entries(this.contract)) {\n if (endpoint.method === method) {\n const params = matchPath(endpoint.path, path);\n if (params !== null) {\n return { name, endpoint, params };\n }\n }\n }\n return null;\n }\n\n /**\n * Handle a request\n */\n async handle(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const method = request.method;\n let path = url.pathname;\n\n // Strip basePath if configured\n if (this.basePath && path.startsWith(this.basePath)) {\n path = path.slice(this.basePath.length) || '/';\n }\n\n const match = this.findEndpoint(method, path);\n if (!match) {\n throw new RouteNotFoundError(path, method);\n }\n\n const { name, endpoint, params } = match;\n const handler = this.handlers[name];\n\n // Create context if factory is provided\n const context = this.contextFactory\n ? await this.contextFactory(request, String(name), endpoint)\n : (undefined as C);\n\n // Dispatch based on endpoint type\n if (endpoint.type === 'streaming') {\n // Parse request for streaming endpoint\n const input = await parseStreamingRequest(request, endpoint, params, context);\n return createStreamingResponse((stream) => {\n return (handler as StreamingHandler<StreamingEndpointDefinition, C>)({\n ...input,\n stream,\n });\n });\n }\n\n if (endpoint.type === 'sse') {\n // Parse request for SSE endpoint\n const input = await parseSSERequest(request, endpoint, params, context);\n return createSSEResponse((emitter, signal) => {\n return (handler as SSEHandler<SSEEndpointDefinition, C>)({\n ...input,\n emitter,\n signal,\n });\n });\n }\n\n if (endpoint.type === 'download') {\n // Parse request for download endpoint\n const input = await parseDownloadRequest(request, endpoint, params, context);\n const downloadHandler = handler as unknown as DownloadHandler<DownloadEndpointDefinition, C>;\n const response = await downloadHandler(\n input as DownloadHandlerInput<DownloadEndpointDefinition, C>,\n );\n return createDownloadResponse(\n endpoint as DownloadEndpointDefinition,\n response as DownloadHandlerResponse<DownloadEndpointDefinition>,\n );\n }\n\n // Standard endpoint\n const input = await parseRequest(request, endpoint, params, context);\n const standardHandler = handler as unknown as Handler<StandardEndpointDefinition, C>;\n const handlerResponse = await standardHandler(\n input as HandlerInput<StandardEndpointDefinition, C>,\n );\n return createResponse(\n endpoint as StandardEndpointDefinition,\n handlerResponse as HandlerResponse<StandardEndpointDefinition>,\n );\n }\n\n /**\n * Get fetch handler compatible with Bun.serve\n */\n get fetch() {\n return (request: Request) => this.handle(request);\n }\n}\n\n/**\n * Create a router from a contract and handlers\n */\nexport function createRouter<T extends Contract, C = unknown>(\n contract: T,\n handlers: ContractHandlers<T, C>,\n options?: RouterOptions<C>,\n): Router<T, C> {\n return new Router(contract, handlers, options);\n}\n\nexport * from './websocket.cjs';"
|
|
5
|
+
"import type {\n Contract,\n DownloadEndpointDefinition,\n EndpointDefinition,\n ExtractBody,\n ExtractChunk,\n ExtractFinalResponse,\n ExtractHeaders,\n ExtractParams,\n ExtractQuery,\n ExtractSSEEventData,\n SSEEndpointDefinition,\n StandardEndpointDefinition,\n StreamingEndpointDefinition,\n} from '@richie-rpc/core';\nimport { formDataToObject, matchPath, parseQuery, Status } from '@richie-rpc/core';\nimport type { z } from 'zod';\n\n// Re-export Status for convenience\nexport { Status };\n\n// Handler input types (for standard endpoints)\nexport type HandlerInput<T extends StandardEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n body: ExtractBody<T>;\n request: Request;\n context: C;\n};\n\n// Handler response type (for standard endpoints)\nexport type HandlerResponse<T extends StandardEndpointDefinition> = {\n [Status in keyof T['responses']]: {\n status: Status;\n body: T['responses'][Status] extends z.ZodTypeAny ? z.infer<T['responses'][Status]> : never;\n headers?: Record<string, string>;\n };\n}[keyof T['responses']];\n\n// Handler function type (for standard endpoints)\nexport type Handler<T extends StandardEndpointDefinition, C = unknown> = (\n input: HandlerInput<T, C>,\n) => Promise<HandlerResponse<T>> | HandlerResponse<T>;\n\n// ============================================\n// Streaming Endpoint Types\n// ============================================\n\n/**\n * Emitter for streaming responses - push-based API\n */\nexport interface StreamEmitter<T extends StreamingEndpointDefinition> {\n /** Send a chunk to the client */\n send(chunk: ExtractChunk<T>): void;\n /** Close the stream with optional final response */\n close(final?: ExtractFinalResponse<T>): void;\n /** Check if stream is still open */\n readonly isOpen: boolean;\n}\n\n/**\n * Handler input for streaming endpoints\n */\nexport type StreamingHandlerInput<T extends StreamingEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n body: ExtractBody<T>;\n request: Request;\n context: C;\n stream: StreamEmitter<T>;\n};\n\n/**\n * Handler function type for streaming endpoints\n */\nexport type StreamingHandler<T extends StreamingEndpointDefinition, C = unknown> = (\n input: StreamingHandlerInput<T, C>,\n) => void | Promise<void>;\n\n// ============================================\n// SSE Endpoint Types\n// ============================================\n\n/**\n * Emitter for SSE responses\n */\nexport interface SSEEmitter<T extends SSEEndpointDefinition> {\n /** Send an event to the client */\n send<K extends keyof T['events']>(\n event: K,\n data: ExtractSSEEventData<T, K>,\n options?: { id?: string },\n ): void;\n /** Close the connection */\n close(): void;\n /** Check if connection is still open */\n readonly isOpen: boolean;\n}\n\n/**\n * Handler input for SSE endpoints\n */\nexport type SSEHandlerInput<T extends SSEEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n request: Request;\n context: C;\n emitter: SSEEmitter<T>;\n /** AbortSignal for detecting client disconnect */\n signal: AbortSignal;\n};\n\n/**\n * Handler function type for SSE endpoints\n * Returns an optional cleanup function\n */\nexport type SSEHandler<T extends SSEEndpointDefinition, C = unknown> = (\n input: SSEHandlerInput<T, C>,\n) => void | (() => void) | Promise<void | (() => void)>;\n\n// ============================================\n// Download Endpoint Types\n// ============================================\n\n/**\n * Handler input for download endpoints\n */\nexport type DownloadHandlerInput<T extends DownloadEndpointDefinition, C = unknown> = {\n params: ExtractParams<T>;\n query: ExtractQuery<T>;\n headers: ExtractHeaders<T>;\n request: Request;\n context: C;\n};\n\n/**\n * Handler response for download endpoints\n * Success (200) returns File, errors return typed response\n */\nexport type DownloadHandlerResponse<T extends DownloadEndpointDefinition> =\n | { status: 200; body: File; headers?: Record<string, string> }\n | (T['errorResponses'] extends Record<number, z.ZodTypeAny>\n ? {\n [S in keyof T['errorResponses']]: {\n status: S;\n body: T['errorResponses'][S] extends z.ZodTypeAny\n ? z.infer<T['errorResponses'][S]>\n : never;\n headers?: Record<string, string>;\n };\n }[keyof T['errorResponses']]\n : never);\n\n/**\n * Handler function type for download endpoints\n */\nexport type DownloadHandler<T extends DownloadEndpointDefinition, C = unknown> = (\n input: DownloadHandlerInput<T, C>,\n) => Promise<DownloadHandlerResponse<T>> | DownloadHandlerResponse<T>;\n\n// ============================================\n// Contract Handlers (supports all endpoint types)\n// ============================================\n\n/**\n * Contract handlers mapping - conditionally applies handler type based on endpoint type\n */\nexport type ContractHandlers<T extends Contract, C = unknown> = {\n [K in keyof T]: T[K] extends StandardEndpointDefinition\n ? Handler<T[K], C>\n : T[K] extends StreamingEndpointDefinition\n ? StreamingHandler<T[K], C>\n : T[K] extends SSEEndpointDefinition\n ? SSEHandler<T[K], C>\n : T[K] extends DownloadEndpointDefinition\n ? DownloadHandler<T[K], C>\n : never;\n};\n\n// Error classes\nexport class ValidationError extends Error {\n constructor(\n public field: string,\n public issues: z.ZodIssue[],\n message?: string,\n ) {\n super(message || `Validation failed for ${field}`);\n this.name = 'ValidationError';\n }\n}\n\nexport class RouteNotFoundError extends Error {\n constructor(\n public path: string,\n public method: string,\n ) {\n super(`Route not found: ${method} ${path}`);\n this.name = 'RouteNotFoundError';\n }\n}\n\n/**\n * Parse and validate request data\n */\nasync function parseRequest<T extends StandardEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<HandlerInput<T, C>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // Parse body\n let body: any;\n if (endpoint.body) {\n const contentType = request.headers.get('content-type') || '';\n let bodyData: any;\n\n if (contentType.includes('application/json')) {\n bodyData = await request.json();\n } else if (contentType.includes('multipart/form-data')) {\n const formData = await request.formData();\n bodyData = formDataToObject(formData as FormData);\n } else {\n bodyData = await request.text();\n }\n\n const result = endpoint.body.safeParse(bodyData);\n if (!result.success) {\n throw new ValidationError('body', result.error.issues);\n }\n body = result.data;\n }\n\n return { params, query, headers, body, request, context } as HandlerInput<T, C>;\n}\n\n/**\n * Parse and validate request data for streaming endpoints\n */\nasync function parseStreamingRequest<T extends StreamingEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<Omit<StreamingHandlerInput<T, C>, 'stream'>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // Parse body\n let body: any;\n if (endpoint.body) {\n const contentType = request.headers.get('content-type') || '';\n let bodyData: any;\n\n if (contentType.includes('application/json')) {\n bodyData = await request.json();\n } else if (contentType.includes('multipart/form-data')) {\n const formData = await request.formData();\n bodyData = formDataToObject(formData as FormData);\n } else {\n bodyData = await request.text();\n }\n\n const result = endpoint.body.safeParse(bodyData);\n if (!result.success) {\n throw new ValidationError('body', result.error.issues);\n }\n body = result.data;\n }\n\n return { params, query, headers, body, request, context } as Omit<\n StreamingHandlerInput<T, C>,\n 'stream'\n >;\n}\n\n/**\n * Parse and validate request data for SSE endpoints\n */\nasync function parseSSERequest<T extends SSEEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<Omit<SSEHandlerInput<T, C>, 'emitter' | 'signal'>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // SSE endpoints don't have a body (GET only)\n return { params, query, headers, request, context } as Omit<\n SSEHandlerInput<T, C>,\n 'emitter' | 'signal'\n >;\n}\n\n/**\n * Parse and validate request data for download endpoints\n */\nasync function parseDownloadRequest<T extends DownloadEndpointDefinition, C = unknown>(\n request: Request,\n endpoint: T,\n pathParams: Record<string, string>,\n context: C,\n): Promise<DownloadHandlerInput<T, C>> {\n const url = new URL(request.url);\n\n // Parse path params\n let params: any = pathParams;\n if (endpoint.params) {\n const result = endpoint.params.safeParse(pathParams);\n if (!result.success) {\n throw new ValidationError('params', result.error.issues);\n }\n params = result.data;\n }\n\n // Parse query params\n let query: any = {};\n if (endpoint.query) {\n const queryData = parseQuery(url.searchParams);\n const result = endpoint.query.safeParse(queryData);\n if (!result.success) {\n throw new ValidationError('query', result.error.issues);\n }\n query = result.data;\n }\n\n // Parse headers\n let headers: any = {};\n if (endpoint.headers) {\n const headersObj: Record<string, string> = {};\n request.headers.forEach((value, key) => {\n headersObj[key] = value;\n });\n const result = endpoint.headers.safeParse(headersObj);\n if (!result.success) {\n throw new ValidationError('headers', result.error.issues);\n }\n headers = result.data;\n }\n\n // Download endpoints don't have a body (GET only)\n return { params, query, headers, request, context } as DownloadHandlerInput<T, C>;\n}\n\n/**\n * Validate and create response\n */\nfunction createResponse<T extends StandardEndpointDefinition>(\n endpoint: T,\n handlerResponse: HandlerResponse<T>,\n): Response {\n const { status, body, headers: customHeaders } = handlerResponse;\n\n // Validate response body\n const responseSchema = endpoint.responses[status as keyof typeof endpoint.responses];\n if (responseSchema) {\n const result = responseSchema.safeParse(body);\n if (!result.success) {\n throw new ValidationError(`response[${String(status)}]`, result.error.issues);\n }\n }\n\n // Create response headers\n const responseHeaders = new Headers(customHeaders);\n\n // Handle 204 No Content - must have no body\n if (status === 204) {\n return new Response(null, {\n status: 204,\n headers: responseHeaders,\n });\n }\n\n // For all other responses, return JSON\n if (!responseHeaders.has('content-type')) {\n responseHeaders.set('content-type', 'application/json');\n }\n\n return new Response(JSON.stringify(body), {\n status: status as number,\n headers: responseHeaders,\n });\n}\n\n/**\n * Create response for download endpoints\n */\nfunction createDownloadResponse<T extends DownloadEndpointDefinition>(\n _endpoint: T,\n handlerResponse: DownloadHandlerResponse<T>,\n): Response {\n const { status, body, headers: customHeaders } = handlerResponse;\n const responseHeaders = new Headers(customHeaders);\n\n // Success: return File as binary\n if (status === 200 && body instanceof File) {\n if (!responseHeaders.has('content-type')) {\n responseHeaders.set('content-type', body.type || 'application/octet-stream');\n }\n responseHeaders.set('content-length', String(body.size));\n if (!responseHeaders.has('content-disposition')) {\n const filename = encodeURIComponent(body.name);\n responseHeaders.set(\n 'content-disposition',\n `attachment; filename=\"${filename}\"; filename*=UTF-8''${filename}`,\n );\n }\n return new Response(body, { status: 200, headers: responseHeaders });\n }\n\n // Error: return JSON\n if (!responseHeaders.has('content-type')) {\n responseHeaders.set('content-type', 'application/json');\n }\n return new Response(JSON.stringify(body), {\n status: status as number,\n headers: responseHeaders,\n });\n}\n\n/**\n * Create a streaming NDJSON response\n */\nfunction createStreamingResponse<T extends StreamingEndpointDefinition>(\n handler: (stream: StreamEmitter<T>) => void | Promise<void>,\n): Response {\n const { readable, writable } = new TransformStream();\n const writer = writable.getWriter();\n const encoder = new TextEncoder();\n let closed = false;\n\n const stream: StreamEmitter<T> = {\n send(chunk) {\n if (!closed) {\n writer.write(encoder.encode(`${JSON.stringify(chunk)}\\n`));\n }\n },\n close(final) {\n if (!closed) {\n closed = true;\n if (final !== undefined) {\n writer.write(encoder.encode(`${JSON.stringify({ __final__: true, data: final })}\\n`));\n }\n writer.close();\n }\n },\n get isOpen() {\n return !closed;\n },\n };\n\n // Execute handler (don't await - let it run in background)\n Promise.resolve(handler(stream)).catch((err) => {\n console.error('Streaming handler error:', err);\n stream.close();\n });\n\n return new Response(readable, {\n headers: {\n 'Content-Type': 'application/x-ndjson',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n });\n}\n\n/**\n * Create an SSE response\n */\nfunction createSSEResponse<T extends SSEEndpointDefinition>(\n handler: (\n emitter: SSEEmitter<T>,\n signal: AbortSignal,\n ) => void | (() => void) | Promise<void | (() => void)>,\n): Response {\n const { readable, writable } = new TransformStream();\n const writer = writable.getWriter();\n const encoder = new TextEncoder();\n const controller = new AbortController();\n let closed = false;\n let cleanup: (() => void) | undefined;\n\n const emitter: SSEEmitter<T> = {\n send(event, data, options) {\n if (!closed) {\n let message = '';\n if (options?.id) {\n message += `id: ${options.id}\\n`;\n }\n message += `event: ${String(event)}\\n`;\n message += `data: ${JSON.stringify(data)}\\n\\n`;\n writer.write(encoder.encode(message));\n }\n },\n close() {\n if (!closed) {\n closed = true;\n if (cleanup) cleanup();\n writer.close();\n }\n },\n get isOpen() {\n return !closed;\n },\n };\n\n // Execute handler and get cleanup function\n Promise.resolve(handler(emitter, controller.signal))\n .then((cleanupFn) => {\n if (typeof cleanupFn === 'function') {\n cleanup = cleanupFn;\n }\n })\n .catch((err) => {\n console.error('SSE handler error:', err);\n emitter.close();\n });\n\n // Tee the stream - one for the response, one for disconnect detection\n const [responseStream, detectStream] = readable.tee();\n\n // Handle client disconnect by detecting when the stream is cancelled\n detectStream.pipeTo(new WritableStream()).catch(() => {\n controller.abort();\n emitter.close();\n });\n\n return new Response(responseStream, {\n headers: {\n 'Content-Type': 'text/event-stream',\n 'Cache-Control': 'no-cache',\n Connection: 'keep-alive',\n },\n });\n}\n\n/**\n * Router configuration options\n */\nexport interface RouterOptions<C = unknown> {\n basePath?: string;\n context?: (request: Request, routeName?: string, endpoint?: EndpointDefinition) => C | Promise<C>;\n}\n\n/**\n * Router class that manages contract endpoints\n */\nexport class Router<T extends Contract, C = unknown> {\n private basePath: string;\n private contextFactory?: (\n request: Request,\n routeName: string,\n endpoint: EndpointDefinition,\n ) => C | Promise<C>;\n\n constructor(\n private contract: T,\n private handlers: ContractHandlers<T, C>,\n options?: RouterOptions<C>,\n ) {\n // Normalize basePath: ensure it starts with / and doesn't end with /\n const bp = options?.basePath || '';\n if (bp) {\n this.basePath = bp.startsWith('/') ? bp : `/${bp}`;\n this.basePath = this.basePath.endsWith('/') ? this.basePath.slice(0, -1) : this.basePath;\n } else {\n this.basePath = '';\n }\n this.contextFactory = options?.context;\n }\n\n /**\n * Find matching endpoint for a request\n */\n private findEndpoint(\n method: string,\n path: string,\n ): {\n name: keyof T;\n endpoint: EndpointDefinition;\n params: Record<string, string>;\n } | null {\n for (const [name, endpoint] of Object.entries(this.contract)) {\n if (endpoint.method === method) {\n const params = matchPath(endpoint.path, path);\n if (params !== null) {\n return { name, endpoint, params };\n }\n }\n }\n return null;\n }\n\n /**\n * Handle a request\n */\n async handle(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const method = request.method;\n let path = url.pathname;\n\n // Strip basePath if configured\n if (this.basePath && path.startsWith(this.basePath)) {\n path = path.slice(this.basePath.length) || '/';\n }\n\n const match = this.findEndpoint(method, path);\n if (!match) {\n throw new RouteNotFoundError(path, method);\n }\n\n const { name, endpoint, params } = match;\n const handler = this.handlers[name];\n\n // Create context if factory is provided\n const context = this.contextFactory\n ? await this.contextFactory(request, String(name), endpoint)\n : (undefined as C);\n\n // Dispatch based on endpoint type\n if (endpoint.type === 'streaming') {\n // Parse request for streaming endpoint\n const input = await parseStreamingRequest(request, endpoint, params, context);\n return createStreamingResponse((stream) => {\n return (handler as StreamingHandler<StreamingEndpointDefinition, C>)({\n ...input,\n stream,\n });\n });\n }\n\n if (endpoint.type === 'sse') {\n // Parse request for SSE endpoint\n const input = await parseSSERequest(request, endpoint, params, context);\n return createSSEResponse((emitter, signal) => {\n return (handler as SSEHandler<SSEEndpointDefinition, C>)({\n ...input,\n emitter,\n signal,\n });\n });\n }\n\n if (endpoint.type === 'download') {\n // Parse request for download endpoint\n const input = await parseDownloadRequest(request, endpoint, params, context);\n const downloadHandler = handler as unknown as DownloadHandler<DownloadEndpointDefinition, C>;\n const response = await downloadHandler(\n input as DownloadHandlerInput<DownloadEndpointDefinition, C>,\n );\n return createDownloadResponse(\n endpoint as DownloadEndpointDefinition,\n response as DownloadHandlerResponse<DownloadEndpointDefinition>,\n );\n }\n\n // Standard endpoint\n const input = await parseRequest(request, endpoint, params, context);\n const standardHandler = handler as unknown as Handler<StandardEndpointDefinition, C>;\n const handlerResponse = await standardHandler(\n input as HandlerInput<StandardEndpointDefinition, C>,\n );\n return createResponse(\n endpoint as StandardEndpointDefinition,\n handlerResponse as HandlerResponse<StandardEndpointDefinition>,\n );\n }\n\n /**\n * Get fetch handler compatible with Bun.serve\n */\n get fetch() {\n return (request: Request) => this.handle(request);\n }\n}\n\n/**\n * Create a router from a contract and handlers\n */\nexport function createRouter<T extends Contract, C = unknown>(\n contract: T,\n handlers: ContractHandlers<T, C>,\n options?: RouterOptions<C>,\n): Router<T, C> {\n return new Router(contract, handlers, options);\n}\n\nexport * from './websocket.cjs';\n"
|
|
6
6
|
],
|
|
7
7
|
"mappings": "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",
|
|
8
8
|
"debugId": "04227CF08860524664756E2164756E21",
|
package/dist/cjs/package.json
CHANGED
package/dist/cjs/websocket.cjs
CHANGED
|
@@ -55,20 +55,8 @@ function createTypedWebSocket(ws) {
|
|
|
55
55
|
send(type, payload) {
|
|
56
56
|
ws.send(JSON.stringify({ type, payload }));
|
|
57
57
|
},
|
|
58
|
-
subscribe(topic) {
|
|
59
|
-
ws.subscribe(topic);
|
|
60
|
-
},
|
|
61
|
-
unsubscribe(topic) {
|
|
62
|
-
ws.unsubscribe(topic);
|
|
63
|
-
},
|
|
64
|
-
publish(topic, type, payload) {
|
|
65
|
-
ws.publish(topic, JSON.stringify({ type, payload }));
|
|
66
|
-
},
|
|
67
58
|
close(code, reason) {
|
|
68
59
|
ws.close(code, reason);
|
|
69
|
-
},
|
|
70
|
-
get data() {
|
|
71
|
-
return ws.data;
|
|
72
60
|
}
|
|
73
61
|
};
|
|
74
62
|
}
|
|
@@ -77,7 +65,7 @@ class WebSocketRouter {
|
|
|
77
65
|
contract;
|
|
78
66
|
handlers;
|
|
79
67
|
basePath;
|
|
80
|
-
|
|
68
|
+
dataSchema;
|
|
81
69
|
constructor(contract, handlers, options) {
|
|
82
70
|
this.contract = contract;
|
|
83
71
|
this.handlers = handlers;
|
|
@@ -88,7 +76,7 @@ class WebSocketRouter {
|
|
|
88
76
|
} else {
|
|
89
77
|
this.basePath = "";
|
|
90
78
|
}
|
|
91
|
-
this.
|
|
79
|
+
this.dataSchema = options?.dataSchema;
|
|
92
80
|
}
|
|
93
81
|
findEndpoint(path) {
|
|
94
82
|
for (const [name, endpoint] of Object.entries(this.contract)) {
|
|
@@ -149,21 +137,28 @@ class WebSocketRouter {
|
|
|
149
137
|
const { name, endpoint, params: rawParams } = match;
|
|
150
138
|
try {
|
|
151
139
|
const { params, query, headers } = this.parseUpgradeParams(request, endpoint, rawParams);
|
|
152
|
-
const context = this.contextFactory ? await this.contextFactory(request, String(name), endpoint) : undefined;
|
|
153
140
|
return {
|
|
154
141
|
endpointName: String(name),
|
|
155
142
|
endpoint,
|
|
156
143
|
params,
|
|
157
144
|
query,
|
|
158
|
-
headers
|
|
159
|
-
context,
|
|
160
|
-
state: {}
|
|
145
|
+
headers
|
|
161
146
|
};
|
|
162
147
|
} catch (err) {
|
|
163
148
|
console.error("WebSocket upgrade validation failed:", err);
|
|
164
149
|
return null;
|
|
165
150
|
}
|
|
166
151
|
}
|
|
152
|
+
validateData(data) {
|
|
153
|
+
if (this.dataSchema) {
|
|
154
|
+
const result = this.dataSchema.safeParse(data);
|
|
155
|
+
if (!result.success) {
|
|
156
|
+
throw new WebSocketValidationError("data", result.error.issues);
|
|
157
|
+
}
|
|
158
|
+
return result.data;
|
|
159
|
+
}
|
|
160
|
+
return data;
|
|
161
|
+
}
|
|
167
162
|
validateMessage(endpoint, rawMessage) {
|
|
168
163
|
const messageStr = typeof rawMessage === "string" ? rawMessage : new TextDecoder().decode(rawMessage);
|
|
169
164
|
const parsed = JSON.parse(messageStr);
|
|
@@ -186,29 +181,46 @@ class WebSocketRouter {
|
|
|
186
181
|
}
|
|
187
182
|
get websocketHandler() {
|
|
188
183
|
return {
|
|
189
|
-
open: async (ws) => {
|
|
190
|
-
const
|
|
191
|
-
const endpointHandlers = this.handlers[data.endpointName];
|
|
184
|
+
open: async ({ ws, upgradeData, data }) => {
|
|
185
|
+
const endpointHandlers = this.handlers[upgradeData.endpointName];
|
|
192
186
|
if (!endpointHandlers)
|
|
193
187
|
return;
|
|
188
|
+
const validatedData = this.validateData(data);
|
|
194
189
|
const typedWs = createTypedWebSocket(ws);
|
|
195
190
|
if (endpointHandlers.open) {
|
|
196
|
-
await endpointHandlers.open(
|
|
191
|
+
await endpointHandlers.open({
|
|
192
|
+
ws: typedWs,
|
|
193
|
+
params: upgradeData.params,
|
|
194
|
+
query: upgradeData.query,
|
|
195
|
+
headers: upgradeData.headers,
|
|
196
|
+
data: validatedData
|
|
197
|
+
});
|
|
197
198
|
}
|
|
198
199
|
},
|
|
199
|
-
message: async (ws,
|
|
200
|
-
const
|
|
201
|
-
const endpointHandlers = this.handlers[data.endpointName];
|
|
200
|
+
message: async ({ ws, rawMessage, upgradeData, data }) => {
|
|
201
|
+
const endpointHandlers = this.handlers[upgradeData.endpointName];
|
|
202
202
|
if (!endpointHandlers)
|
|
203
203
|
return;
|
|
204
|
+
const validatedData = this.validateData(data);
|
|
204
205
|
const typedWs = createTypedWebSocket(ws);
|
|
205
206
|
try {
|
|
206
|
-
const validatedMessage = this.validateMessage(
|
|
207
|
-
await endpointHandlers.message(
|
|
207
|
+
const validatedMessage = this.validateMessage(upgradeData.endpoint, typeof rawMessage === "string" ? rawMessage : rawMessage.buffer);
|
|
208
|
+
await endpointHandlers.message({
|
|
209
|
+
ws: typedWs,
|
|
210
|
+
message: validatedMessage,
|
|
211
|
+
params: upgradeData.params,
|
|
212
|
+
query: upgradeData.query,
|
|
213
|
+
headers: upgradeData.headers,
|
|
214
|
+
data: validatedData
|
|
215
|
+
});
|
|
208
216
|
} catch (err) {
|
|
209
217
|
if (err instanceof WebSocketValidationError) {
|
|
210
218
|
if (endpointHandlers.validationError) {
|
|
211
|
-
endpointHandlers.validationError(
|
|
219
|
+
endpointHandlers.validationError({
|
|
220
|
+
ws: typedWs,
|
|
221
|
+
error: err,
|
|
222
|
+
data: validatedData
|
|
223
|
+
});
|
|
212
224
|
} else {
|
|
213
225
|
typedWs.send("error", {
|
|
214
226
|
code: "VALIDATION_ERROR",
|
|
@@ -221,17 +233,38 @@ class WebSocketRouter {
|
|
|
221
233
|
}
|
|
222
234
|
}
|
|
223
235
|
},
|
|
224
|
-
close: (ws,
|
|
225
|
-
const
|
|
226
|
-
const endpointHandlers = this.handlers[data.endpointName];
|
|
236
|
+
close: ({ ws, upgradeData, data }) => {
|
|
237
|
+
const endpointHandlers = this.handlers[upgradeData.endpointName];
|
|
227
238
|
if (!endpointHandlers)
|
|
228
239
|
return;
|
|
240
|
+
const validatedData = this.validateData(data);
|
|
229
241
|
const typedWs = createTypedWebSocket(ws);
|
|
230
242
|
if (endpointHandlers.close) {
|
|
231
|
-
endpointHandlers.close(
|
|
243
|
+
endpointHandlers.close({
|
|
244
|
+
ws: typedWs,
|
|
245
|
+
params: upgradeData.params,
|
|
246
|
+
query: upgradeData.query,
|
|
247
|
+
headers: upgradeData.headers,
|
|
248
|
+
data: validatedData
|
|
249
|
+
});
|
|
232
250
|
}
|
|
233
251
|
},
|
|
234
|
-
drain: () => {
|
|
252
|
+
drain: ({ ws, upgradeData, data }) => {
|
|
253
|
+
const endpointHandlers = this.handlers[upgradeData.endpointName];
|
|
254
|
+
if (!endpointHandlers)
|
|
255
|
+
return;
|
|
256
|
+
const validatedData = this.validateData(data);
|
|
257
|
+
const typedWs = createTypedWebSocket(ws);
|
|
258
|
+
if (endpointHandlers.drain) {
|
|
259
|
+
endpointHandlers.drain({
|
|
260
|
+
ws: typedWs,
|
|
261
|
+
params: upgradeData.params,
|
|
262
|
+
query: upgradeData.query,
|
|
263
|
+
headers: upgradeData.headers,
|
|
264
|
+
data: validatedData
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
}
|
|
235
268
|
};
|
|
236
269
|
}
|
|
237
270
|
}
|
|
@@ -240,4 +273,4 @@ function createWebSocketRouter(contract, handlers, options) {
|
|
|
240
273
|
}
|
|
241
274
|
})
|
|
242
275
|
|
|
243
|
-
//# debugId=
|
|
276
|
+
//# debugId=E702494F39B5D7E664756E2164756E21
|