@vyriy/router 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,6 @@ This package provides a small API Gateway router for Lambda handlers.
9
9
  It is intentionally kept small:
10
10
 
11
11
  - matches by HTTP method and exact path
12
- - supports simple prefix dispatch for handler factories such as static file serving
13
12
  - passes API Gateway event data into handlers
14
13
  - returns a Lambda-friendly response shape
15
14
 
@@ -29,16 +28,14 @@ With Yarn:
29
28
  yarn add @vyriy/router
30
29
  ```
31
30
 
32
- ## Usage
31
+ ## Basic Router
33
32
 
34
33
  ```ts
35
34
  import { createRouter } from '@vyriy/router';
36
- import { staticFiles } from '@vyriy/server/static';
37
35
 
38
36
  const router = createRouter();
39
37
 
40
38
  router.get('/health', async ({ event, query, headers, pathParameters, body }) => ({
41
- statusCode: 200,
42
39
  body: JSON.stringify({
43
40
  ok: true,
44
41
  method: event.httpMethod,
@@ -49,8 +46,6 @@ router.get('/health', async ({ event, query, headers, pathParameters, body }) =>
49
46
  }),
50
47
  }));
51
48
 
52
- router.prefix('/static', staticFiles('./public'));
53
-
54
49
  router.fallback(async ({ event }) => ({
55
50
  statusCode: 404,
56
51
  body: JSON.stringify({
@@ -58,16 +53,98 @@ router.fallback(async ({ event }) => ({
58
53
  path: event.path,
59
54
  }),
60
55
  }));
56
+
57
+ export const handler = (event: Parameters<typeof router.route>[0]) => router.route(event);
61
58
  ```
62
59
 
63
- Prefix handlers receive the relative matched path in `pathParameters.proxy`, so they can behave like normal handlers:
60
+ ## Stream Router
61
+
62
+ Use `createStreamRouter()` when handlers write directly to a Lambda response stream. The stream is passed as the second handler argument, and stream handlers do not return a response object.
64
63
 
65
64
  ```ts
66
- router.prefix('/events', async ({ pathParameters, responseStream }) => {
67
- responseStream?.setContentType?.('text/plain');
68
- responseStream?.write(`Path: ${pathParameters?.proxy}`);
69
- responseStream?.end();
65
+ import { createStreamRouter } from '@vyriy/router';
66
+
67
+ const streamRouter = createStreamRouter();
68
+
69
+ streamRouter.get('/events', ({ event, query }, responseStream) => {
70
+ responseStream.setContentType?.('text/plain');
71
+ responseStream.write(`path: ${event.path}\n`);
72
+ responseStream.write(`cursor: ${query?.cursor ?? 'start'}\n`);
73
+ responseStream.end('done');
74
+ });
75
+
76
+ streamRouter.fallback(({ event }, responseStream) => {
77
+ responseStream.setContentType?.('application/json');
78
+ responseStream.end(
79
+ JSON.stringify({
80
+ message: 'Not Found',
81
+ path: event.path,
82
+ }),
83
+ );
70
84
  });
85
+
86
+ export const handler = (
87
+ event: Parameters<typeof streamRouter.route>[0],
88
+ responseStream: Parameters<typeof streamRouter.route>[1],
89
+ ) => streamRouter.route(event, responseStream);
90
+ ```
91
+
92
+ ## Calm Composition
93
+
94
+ The router keeps request matching separate from handler wrappers and local server adapters. A small API can stay as a plain composition of focused packages:
95
+
96
+ ```ts
97
+ import { api } from '@vyriy/handler';
98
+ import { createRouter } from '@vyriy/router';
99
+ import { server } from '@vyriy/server';
100
+
101
+ const router = createRouter();
102
+
103
+ router.get('/health', () => ({
104
+ body: JSON.stringify({
105
+ ok: true,
106
+ }),
107
+ }));
108
+
109
+ const handler = api((event) => router.route(event));
110
+
111
+ server(handler);
112
+ ```
113
+
114
+ The same shape works for Lambda response streaming:
115
+
116
+ ```ts
117
+ import { streamApi } from '@vyriy/handler';
118
+ import { createStreamRouter } from '@vyriy/router';
119
+ import { streamServer } from '@vyriy/server';
120
+
121
+ const router = createStreamRouter();
122
+
123
+ router.get('/events', (_params, responseStream) => {
124
+ responseStream.setContentType?.('text/plain');
125
+ responseStream.end('ok');
126
+ });
127
+
128
+ const handler = streamApi((event, responseStream) => router.route(event, responseStream));
129
+
130
+ streamServer(handler);
131
+ ```
132
+
133
+ For a Lambda-only entrypoint, keep the same composition and export the handler:
134
+
135
+ ```ts
136
+ import { api } from '@vyriy/handler';
137
+ import { createRouter } from '@vyriy/router';
138
+
139
+ const router = createRouter();
140
+
141
+ router.get('/health', () => ({
142
+ body: JSON.stringify({
143
+ ok: true,
144
+ }),
145
+ }));
146
+
147
+ export const handler = api((event) => router.route(event));
71
148
  ```
72
149
 
73
150
  ## Exports
@@ -75,28 +152,33 @@ router.prefix('/events', async ({ pathParameters, responseStream }) => {
75
152
  The package exposes both the root entry and the direct module entry:
76
153
 
77
154
  ```ts
78
- import { createRouter } from '@vyriy/router';
79
- import { Router } from '@vyriy/router/router';
155
+ import { createRouter, createStreamRouter } from '@vyriy/router';
156
+ import { Router, StreamRouter } from '@vyriy/router/router';
80
157
  ```
81
158
 
82
159
  ## API
83
160
 
84
161
  - `createRouter()` returns a chainable router API.
162
+ - `createStreamRouter()` returns a chainable response streaming router API.
85
163
  - `router.get(path, handler)` registers a `GET` handler.
86
164
  - `router.post(path, handler)` registers a `POST` handler.
87
165
  - `router.put(path, handler)` registers a `PUT` handler.
88
166
  - `router.delete(path, handler)` registers a `DELETE` handler.
89
167
  - `router.patch(path, handler)` registers a `PATCH` handler.
90
- - `router.prefix(pathPrefix, handler)` registers a handler for every request under a URL prefix.
91
168
  - `router.fallback(handler)` registers a handler for unmatched requests.
92
169
  - `router.route(event)` resolves the matching route and returns an API Gateway response.
170
+ - `streamRouter.route(event, responseStream)` resolves the matching route and writes to the stream.
171
+
172
+ Route handlers may omit `statusCode`; the router normalizes missing status codes to `200` before returning from `router.route(event)`.
93
173
 
94
- The low-level `Router` class is also available from `@vyriy/router/router` and exposes only:
174
+ The low-level `Router` and `StreamRouter` classes are also available from `@vyriy/router/router`:
95
175
 
96
176
  - `router.on(method, path, handler)`
97
- - `router.prefix(pathPrefix, handler)`
98
177
  - `router.fallback(handler)`
99
178
  - `router.route(event)`
179
+ - `streamRouter.on(method, path, handler)`
180
+ - `streamRouter.fallback(handler)`
181
+ - `streamRouter.route(event, responseStream)`
100
182
 
101
183
  Route handlers receive:
102
184
 
@@ -106,7 +188,12 @@ type HandlerParams = {
106
188
  body?: string;
107
189
  headers?: APIGatewayProxyEvent['headers'];
108
190
  pathParameters?: APIGatewayProxyEvent['pathParameters'];
109
- responseStream?: ResponseStream;
110
191
  event: APIGatewayProxyEvent;
111
192
  };
112
193
  ```
194
+
195
+ Stream route handlers receive the same `HandlerParams` as the first argument and `ResponseStream` as the second argument:
196
+
197
+ ```ts
198
+ type StreamHandler = (params: HandlerParams, responseStream: ResponseStream) => void | Promise<void>;
199
+ ```
package/create.d.ts CHANGED
@@ -1,2 +1,3 @@
1
- import type { RouterApi } from './types.js';
1
+ import type { RouterApi, StreamRouterApi } from './types.js';
2
2
  export declare const createRouter: () => RouterApi;
3
+ export declare const createStreamRouter: () => StreamRouterApi;
package/create.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Router } from './router.js';
1
+ import { Router, StreamRouter } from './router.js';
2
2
  export const createRouter = () => {
3
3
  const router = new Router();
4
4
  const api = {
@@ -26,13 +26,42 @@ export const createRouter = () => {
26
26
  router.on('PATCH', path, handler);
27
27
  return api;
28
28
  },
29
- prefix(pathPrefix, handler) {
30
- router.prefix(pathPrefix, handler);
31
- return api;
32
- },
33
29
  route(event) {
34
30
  return router.route(event);
35
31
  },
36
32
  };
37
33
  return api;
38
34
  };
35
+ export const createStreamRouter = () => {
36
+ const router = new StreamRouter();
37
+ const api = {
38
+ get(path, handler) {
39
+ router.on('GET', path, handler);
40
+ return api;
41
+ },
42
+ post(path, handler) {
43
+ router.on('POST', path, handler);
44
+ return api;
45
+ },
46
+ put(path, handler) {
47
+ router.on('PUT', path, handler);
48
+ return api;
49
+ },
50
+ delete(path, handler) {
51
+ router.on('DELETE', path, handler);
52
+ return api;
53
+ },
54
+ fallback(handler) {
55
+ router.fallback(handler);
56
+ return api;
57
+ },
58
+ patch(path, handler) {
59
+ router.on('PATCH', path, handler);
60
+ return api;
61
+ },
62
+ route(event, responseStream) {
63
+ return router.route(event, responseStream);
64
+ },
65
+ };
66
+ return api;
67
+ };
package/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export * from './create.js';
2
+ export * from './router.js';
2
3
  export type * from './types.js';
package/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export * from './create.js';
2
+ export * from './router.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vyriy/router",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Router utility for Vyriy projects",
5
5
  "type": "module",
6
6
  "main": "./index.js",
package/router.d.ts CHANGED
@@ -1,10 +1,14 @@
1
- import type { APIGatewayProxyEvent, Handler, ResponseStream, RouteResult } from './types.js';
2
- export declare class Router {
3
- private fallbackHandler?;
4
- private readonly routes;
5
- private readonly staticRoutes;
6
- on(method: string, path: string, handler: Handler): this;
7
- fallback(handler: Handler): this;
8
- prefix(pathPrefix: string, handler: Handler): this;
9
- route(event: APIGatewayProxyEvent, responseStream?: ResponseStream): Promise<RouteResult | void>;
1
+ import type { APIGatewayProxyEvent, Handler, ResponseStream, RouteResult, StreamHandler } from './types.js';
2
+ declare class BaseRouter<CurrentHandler> {
3
+ protected fallbackHandler?: CurrentHandler;
4
+ protected readonly routes: Record<string, Record<string, CurrentHandler>>;
5
+ on(method: string, path: string, handler: CurrentHandler): this;
6
+ fallback(handler: CurrentHandler): this;
10
7
  }
8
+ export declare class Router extends BaseRouter<Handler> {
9
+ route(event: APIGatewayProxyEvent): Promise<RouteResult>;
10
+ }
11
+ export declare class StreamRouter extends BaseRouter<StreamHandler> {
12
+ route(event: APIGatewayProxyEvent, responseStream: ResponseStream): Promise<void>;
13
+ }
14
+ export {};
package/router.js CHANGED
@@ -1,25 +1,5 @@
1
1
  import { METHODS, STATUS_CODES } from 'node:http';
2
2
  const SUPPORTED_METHODS = new Set(METHODS.map((method) => method.toUpperCase()));
3
- const removeLeadingSlashes = (value) => {
4
- let start = 0;
5
- while (value[start] === '/') {
6
- start += 1;
7
- }
8
- return value.slice(start);
9
- };
10
- const removeTrailingSlashes = (value) => {
11
- let end = value.length;
12
- while (end > 0 && value[end - 1] === '/') {
13
- end -= 1;
14
- }
15
- return value.slice(0, end);
16
- };
17
- const getPrefixPath = (eventPath, route) => {
18
- if (eventPath !== route.pathPrefix && !eventPath.startsWith(`${route.pathPrefix}/`)) {
19
- return undefined;
20
- }
21
- return decodeURIComponent(removeLeadingSlashes(eventPath.slice(route.pathPrefix.length)));
22
- };
23
3
  const normalizeResult = (result) => ({
24
4
  statusCode: result.statusCode ?? 200,
25
5
  body: result.body,
@@ -27,34 +7,31 @@ const normalizeResult = (result) => ({
27
7
  isBase64Encoded: result.isBase64Encoded,
28
8
  multiValueHeaders: result.multiValueHeaders,
29
9
  });
30
- export class Router {
10
+ const registerRoute = (routes, method, path, handler) => {
11
+ const normalizedMethod = method.toUpperCase();
12
+ if (!SUPPORTED_METHODS.has(normalizedMethod)) {
13
+ throw new Error(`Unsupported HTTP method: ${normalizedMethod}`);
14
+ }
15
+ const routeGroup = (routes[normalizedMethod] ??= {});
16
+ if (routeGroup[path]) {
17
+ throw new Error(`${normalizedMethod} ${path} already exists!`);
18
+ }
19
+ routeGroup[path] = handler;
20
+ };
21
+ class BaseRouter {
31
22
  fallbackHandler;
32
23
  routes = {};
33
- staticRoutes = [];
34
24
  on(method, path, handler) {
35
- const normalizedMethod = method.toUpperCase();
36
- if (!SUPPORTED_METHODS.has(normalizedMethod)) {
37
- throw new Error(`Unsupported HTTP method: ${normalizedMethod}`);
38
- }
39
- const routeGroup = (this.routes[normalizedMethod] ??= {});
40
- if (routeGroup[path]) {
41
- throw new Error(`${normalizedMethod} ${path} already exists!`);
42
- }
43
- routeGroup[path] = handler;
25
+ registerRoute(this.routes, method, path, handler);
44
26
  return this;
45
27
  }
46
28
  fallback(handler) {
47
29
  this.fallbackHandler = handler;
48
30
  return this;
49
31
  }
50
- prefix(pathPrefix, handler) {
51
- this.staticRoutes.push({
52
- handler,
53
- pathPrefix: removeTrailingSlashes(pathPrefix) || '/',
54
- });
55
- return this;
56
- }
57
- async route(event, responseStream) {
32
+ }
33
+ export class Router extends BaseRouter {
34
+ async route(event) {
58
35
  const { httpMethod, path, queryStringParameters, body, headers, pathParameters } = event;
59
36
  const exactHandler = this.routes[httpMethod]?.[path];
60
37
  if (exactHandler) {
@@ -63,30 +40,9 @@ export class Router {
63
40
  body: body ?? undefined,
64
41
  headers,
65
42
  pathParameters: pathParameters ?? undefined,
66
- responseStream,
67
43
  event,
68
44
  });
69
- return result ? normalizeResult(result) : undefined;
70
- }
71
- for (const staticRoute of this.staticRoutes) {
72
- const proxy = getPrefixPath(path, staticRoute);
73
- if (proxy !== undefined) {
74
- const prefixResult = await staticRoute.handler({
75
- query: queryStringParameters ?? undefined,
76
- body: body ?? undefined,
77
- headers,
78
- pathParameters: {
79
- ...pathParameters,
80
- proxy,
81
- },
82
- responseStream,
83
- event,
84
- });
85
- if (prefixResult) {
86
- return normalizeResult(prefixResult);
87
- }
88
- return undefined;
89
- }
45
+ return normalizeResult(result);
90
46
  }
91
47
  if (this.fallbackHandler) {
92
48
  const fallbackResult = await this.fallbackHandler({
@@ -94,10 +50,9 @@ export class Router {
94
50
  body: body ?? undefined,
95
51
  headers,
96
52
  pathParameters: pathParameters ?? undefined,
97
- responseStream,
98
53
  event,
99
54
  });
100
- return fallbackResult ? normalizeResult(fallbackResult) : undefined;
55
+ return normalizeResult(fallbackResult);
101
56
  }
102
57
  return {
103
58
  statusCode: 404,
@@ -107,3 +62,33 @@ export class Router {
107
62
  };
108
63
  }
109
64
  }
65
+ export class StreamRouter extends BaseRouter {
66
+ async route(event, responseStream) {
67
+ const { httpMethod, path, queryStringParameters, body, headers, pathParameters } = event;
68
+ const exactHandler = this.routes[httpMethod]?.[path];
69
+ if (exactHandler) {
70
+ await exactHandler({
71
+ query: queryStringParameters ?? undefined,
72
+ body: body ?? undefined,
73
+ headers,
74
+ pathParameters: pathParameters ?? undefined,
75
+ event,
76
+ }, responseStream);
77
+ return;
78
+ }
79
+ if (this.fallbackHandler) {
80
+ await this.fallbackHandler({
81
+ query: queryStringParameters ?? undefined,
82
+ body: body ?? undefined,
83
+ headers,
84
+ pathParameters: pathParameters ?? undefined,
85
+ event,
86
+ }, responseStream);
87
+ return;
88
+ }
89
+ responseStream.setContentType?.('application/json');
90
+ responseStream.end(JSON.stringify({
91
+ message: STATUS_CODES[404],
92
+ }));
93
+ }
94
+ }
package/types.d.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  import type { APIGatewayProxyEvent, APIGatewayProxyEventQueryStringParameters, APIGatewayProxyResult } from 'aws-lambda';
2
2
  export type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';
3
- export type RouteResult = {
3
+ type MaybePromise<Result> = Result | Promise<Result>;
4
+ export type RouteResult = APIGatewayProxyResult;
5
+ export type RouteHandlerResult = {
4
6
  body: APIGatewayProxyResult['body'];
5
7
  headers?: APIGatewayProxyResult['headers'];
6
8
  isBase64Encoded?: APIGatewayProxyResult['isBase64Encoded'];
@@ -21,10 +23,10 @@ export type HandlerParams = {
21
23
  body?: string;
22
24
  headers?: APIGatewayProxyEvent['headers'];
23
25
  pathParameters?: APIGatewayProxyEvent['pathParameters'];
24
- responseStream?: ResponseStream;
25
26
  event: APIGatewayProxyEvent;
26
27
  };
27
- export type Handler = (params: HandlerParams) => Promise<RouteResult | void>;
28
+ export type Handler = (params: HandlerParams) => MaybePromise<RouteHandlerResult>;
29
+ export type StreamHandler = (params: HandlerParams, responseStream: ResponseStream) => MaybePromise<void>;
28
30
  export type RouterApi = {
29
31
  get(path: string, handler: Handler): RouterApi;
30
32
  post(path: string, handler: Handler): RouterApi;
@@ -32,6 +34,14 @@ export type RouterApi = {
32
34
  delete(path: string, handler: Handler): RouterApi;
33
35
  fallback(handler: Handler): RouterApi;
34
36
  patch(path: string, handler: Handler): RouterApi;
35
- prefix(pathPrefix: string, handler: Handler): RouterApi;
36
- route(event: APIGatewayProxyEvent, responseStream?: ResponseStream): Promise<RouteResult | void>;
37
+ route(event: APIGatewayProxyEvent): Promise<RouteResult>;
38
+ };
39
+ export type StreamRouterApi = {
40
+ get(path: string, handler: StreamHandler): StreamRouterApi;
41
+ post(path: string, handler: StreamHandler): StreamRouterApi;
42
+ put(path: string, handler: StreamHandler): StreamRouterApi;
43
+ delete(path: string, handler: StreamHandler): StreamRouterApi;
44
+ fallback(handler: StreamHandler): StreamRouterApi;
45
+ patch(path: string, handler: StreamHandler): StreamRouterApi;
46
+ route(event: APIGatewayProxyEvent, responseStream: ResponseStream): Promise<void>;
37
47
  };