@thi.ng/server 0.1.0 → 0.2.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.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Change Log
2
2
 
3
- - **Last updated**: 2025-01-29T16:25:48Z
3
+ - **Last updated**: 2025-01-30T15:45:22Z
4
4
  - **Generator**: [thi.ng/monopub](https://thi.ng/monopub)
5
5
 
6
6
  All notable changes to this project will be documented in this file.
@@ -11,6 +11,22 @@ See [Conventional Commits](https://conventionalcommits.org/) for commit guidelin
11
11
  **Note:** Unlisted _patch_ versions only involve non-code or otherwise excluded changes
12
12
  and/or version bumps of transitive dependencies.
13
13
 
14
+ ## [0.2.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.2.0) (2025-01-30)
15
+
16
+ #### 🚀 Features
17
+
18
+ - add generics, various other updates ([a340f65](https://github.com/thi-ng/umbrella/commit/a340f65))
19
+ - add generics to most main types/interfaces
20
+ - refactor `SessionInterceptor` as class w/ pluggable storage
21
+ - add `ISessionStore` and `InMemorySessionStore` impl
22
+ - update ServerOpts to allow augmenting request context object
23
+ - add default HTTP OPTIONS handler
24
+ - update Server cookie parsing
25
+ - add StaticOpts.auth flag
26
+ - update logRequest() interceptor
27
+ - update pkg exports
28
+ - update tests
29
+
14
30
  ## [0.1.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.1.0) (2025-01-29)
15
31
 
16
32
  #### 🚀 Features
package/README.md CHANGED
@@ -39,38 +39,47 @@ implementations.
39
39
  ### Main features
40
40
 
41
41
  - Declarative & parametric routing (incl. validation and coercion of route
42
- params)
42
+ params)
43
+ - Uses [@thi.ng/router](https://github.com/thi-ng/umbrella/tree/develop/packages/router) as implementation
43
44
  - Multiple HTTP methods per route
44
- - Composable & customizable interceptors (global for all routes and/or for
45
- indidual routes & HTTP methods)
45
+ - Built-in HTTP OPTIONS handler for listing available route methods
46
+ - Fallback HTTP HEAD to GET method (if available)
46
47
  - Asynchronous route handler processing
48
+ - Composable & customizable interceptor chains
49
+ - Global interceptors for all routes and/or local for individual routes & HTTP methods
47
50
  - Automatic parsing of cookies and URL query strings (incl. nested params)
51
+ - In-memory session storage & route interceptor
48
52
  - Configurable file serving (`ReadableStream`-based) with automatic MIME-type
49
- detection and support for Etags, as well as Brotli, Gzip and Deflate compression
53
+ detection and support for Etags, as well as Brotli, Gzip and Deflate
54
+ compression
50
55
  - Utilities for parsing form-encoded multipart request bodies
51
56
 
52
57
  ### Interceptors
53
58
 
54
- Interceptors are additionally injected handlers (aka middleware) which are
59
+ Interceptors are additionally injected route handlers (aka middleware) which are
55
60
  pre/post-processed before/after a route's main handler and can be used for
56
61
  validation, cancellation or other side effects. Each single interceptor can have
57
- a `pre` and/or `post` phase function. Post-phase interceptors are processed in
58
- reverse order. See
62
+ a `pre` and/or `post` phase function. Each route handler can define its own
63
+ interceptor chains, which will be appended to the globally defined interceptors
64
+ (applied to all routes). Post-phase interceptors are processed in reverse order.
65
+ See
59
66
  [`Interceptor`](https://docs.thi.ng/umbrella/server/interfaces/Interceptor.html)
60
67
  for more details.
61
68
 
69
+ ![Diagram illustrating interceptor processing order](https://raw.githubusercontent.com/thi-ng/umbrella/develop/assets/server/server-interceptors.png)
70
+
62
71
  #### Available interceptors
63
72
 
64
- - [`authenticateWith()`](https://docs.thi.ng/umbrella/server/functions/authenticateWith.html)
65
- - [`cacheControl()`](https://docs.thi.ng/umbrella/server/functions/cacheControl.html)
66
- - [`crossOriginOpenerPolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginOpenerPolicy.html)
67
- - [`crossOriginResourcePolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginResourcePolicy.html)
68
- - [`injectHeaders()`](https://docs.thi.ng/umbrella/server/functions/injectHeaders.html)
69
- - [`logRequest()`](https://docs.thi.ng/umbrella/server/functions/logRequest.html)
70
- - [`logResponse()`](https://docs.thi.ng/umbrella/server/functions/logResponse.html)
71
- - [`referrerPolicy()`](https://docs.thi.ng/umbrella/server/functions/referrerPolicy.html)
72
- - [`serverSession()`](https://docs.thi.ng/umbrella/server/functions/serverSession.html)
73
- - [`strictTransportSecurity()`](https://docs.thi.ng/umbrella/server/functions/strictTransportSecurity.html)
73
+ - [`authenticateWith()`](https://docs.thi.ng/umbrella/server/functions/authenticateWith.html): Predicate function based authentication
74
+ - [`cacheControl()`](https://docs.thi.ng/umbrella/server/functions/cacheControl.html): Cache control header injection
75
+ - [`crossOriginOpenerPolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginOpenerPolicy-1.html): Policy header injection
76
+ - [`crossOriginResourcePolicy()`](https://docs.thi.ng/umbrella/server/functions/crossOriginResourcePolicy-1.html): Policy header injection
77
+ - [`injectHeaders()`](https://docs.thi.ng/umbrella/server/functions/injectHeaders.html): Arbitrary header injection
78
+ - [`logRequest()`](https://docs.thi.ng/umbrella/server/functions/logRequest.html): Request detail logging
79
+ - [`logResponse()`](https://docs.thi.ng/umbrella/server/functions/logResponse.html): Response logging
80
+ - [`referrerPolicy()`](https://docs.thi.ng/umbrella/server/functions/referrerPolicy-1.html): Policy header injection
81
+ - [`serverSession()`](https://docs.thi.ng/umbrella/server/functions/serverSession-1.html): User defined in-memory sessions with TTL
82
+ - [`strictTransportSecurity()`](https://docs.thi.ng/umbrella/server/functions/strictTransportSecurity.html): Policy header injection
74
83
 
75
84
  #### Custom interceptors
76
85
 
@@ -138,7 +147,7 @@ For Node.js REPL:
138
147
  const ser = await import("@thi.ng/server");
139
148
  ```
140
149
 
141
- Package sizes (brotli'd, pre-treeshake): ESM: 3.75 KB
150
+ Package sizes (brotli'd, pre-treeshake): ESM: 4.02 KB
142
151
 
143
152
  ## Dependencies
144
153
 
@@ -164,34 +173,96 @@ Note: @thi.ng/api is in _most_ cases a type-only import (not used at runtime)
164
173
  ### Usage example
165
174
 
166
175
  ```ts tangle:export/readme-hello.ts
167
- import {
168
- server, staticFiles, cacheControl, etagFileHash, logRequest
169
- } from "@thi.ng/server";
176
+ import * as srv from "@thi.ng/server";
177
+
178
+ // all route handlers & interceptors receive a request context object
179
+ // here we define an extended/customized version
180
+ interface AppCtx extends srv.RequestCtx {
181
+ session?: AppSession;
182
+ }
183
+
184
+ // customized version of the default server session type
185
+ interface AppSession extends srv.ServerSession {
186
+ user?: string;
187
+ locale?: string;
188
+ }
189
+
190
+ // interceptor for injecting/managing sessions
191
+ // by default uses in-memory storage/cache
192
+ const session = srv.serverSession<AppCtx, AppSession>();
170
193
 
171
- const app = server({
194
+ // create server with given config
195
+ const app = srv.server<AppCtx>({
196
+ // global interceptors (used for all routes)
197
+ intercept: [
198
+ // log all requests (using server's configured logger)
199
+ srv.logRequest(),
200
+ // lookup/create sessions (using above interceptor)
201
+ session,
202
+ // ensure routes with `auth` flag have a logged-in user
203
+ srv.authenticateWith<AppCtx>((ctx) => !!ctx.session?.user),
204
+ ],
205
+ // route definitions (more can be added dynamically later)
172
206
  routes: [
173
- // define a route for static files
174
- staticFiles({
207
+ // define a route for serving static assets
208
+ srv.staticFiles({
209
+ // ensure only logged-in users can access
210
+ auth: true,
211
+ // use compression (if client supports it)
175
212
  compress: true,
176
- etag: etagFileHash(),
213
+ // route prefix
214
+ prefix: "assets",
215
+ // map to current CWD
216
+ rootDir: ".",
217
+ // strategy for computing etags (optional)
218
+ etag: srv.etagFileHash(),
177
219
  // route specific interceptors
178
- intercept: [
179
- cacheControl({ maxAge: 3600 })
180
- ]
220
+ intercept: [srv.cacheControl({ maxAge: 3600 })],
181
221
  }),
182
- // parametric route
222
+ // define a dummy login route
223
+ {
224
+ id: "login",
225
+ match: "/login",
226
+ handlers: {
227
+ // each route can specify handlers for various HTTP methods
228
+ post: async (ctx) => {
229
+ const { user, pass } = await srv.parseRequestFormData(ctx.req);
230
+ ctx.logger.info("login details", user, pass);
231
+ if (user === "thi.ng" && pass === "1234") {
232
+ ctx.session!.user = user;
233
+ ctx.res.writeHead(200).end("logged in as " + user);
234
+ } else {
235
+ ctx.res.writeHead(403).end("login failed");
236
+ }
237
+ },
238
+ },
239
+ },
240
+ // dummy logout route
241
+ {
242
+ id: "logout",
243
+ match: "/logout",
244
+ // use auth flag here to ensure route is only accessible if valid session
245
+ auth: true,
246
+ handlers: {
247
+ get: async (ctx) => {
248
+ // remove session & force expire session cookie
249
+ await session.delete(ctx, ctx.session!.id);
250
+ ctx.res.writeHead(200).end("logged out");
251
+ },
252
+ },
253
+ },
254
+ // parametric route (w/ optional validator)
183
255
  {
184
256
  id: "hello",
185
257
  match: "/hello/?name",
186
- // optional validator(s)
187
258
  validate: {
188
259
  name: { check: (x) => /^[a-z]+$/i.test(x) },
189
260
  },
190
- // each route can specify handlers for various HTTP methods
191
261
  handlers: {
192
- get: async ({ match, res }) =>
193
- res.writeHead(200, { "content-type": "text/plain"})
194
- .end(`hello, ${match.params!.name.toLowerCase()}!`)
262
+ get: async ({ match, res }) => {
263
+ res.writeHead(200, { "content-type": "text/plain" })
264
+ .end(`hello, ${match.params!.name}!`);
265
+ },
195
266
  },
196
267
  },
197
268
  // another route to demonstrate role/usage of route IDs
@@ -201,14 +272,13 @@ const app = server({
201
272
  match: "/alias/?name",
202
273
  handlers: {
203
274
  get: ({ server, match, res }) =>
204
- server.redirectToRoute(res, { id: "hello", params: match.params })
205
- }
206
- }
275
+ server.redirectToRoute(res, {
276
+ id: "hello",
277
+ params: match.params,
278
+ }),
279
+ },
280
+ },
207
281
  ],
208
- // global interceptors (used for all routes)
209
- intercept: [
210
- logRequest(),
211
- ]
212
282
  });
213
283
 
214
284
  await app.start();
package/api.d.ts CHANGED
@@ -3,8 +3,8 @@ import type { ILogger } from "@thi.ng/logger";
3
3
  import type { Route, RouteMatch } from "@thi.ng/router";
4
4
  import type { IncomingMessage, ServerResponse } from "node:http";
5
5
  import type { Server } from "./server.js";
6
- import type { ServerSession } from "./interceptors/session.js";
7
- export interface ServerOpts {
6
+ export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
7
+ export interface ServerOpts<CTX extends RequestCtx = RequestCtx> {
8
8
  logger: ILogger;
9
9
  /**
10
10
  * SSL configuration
@@ -29,7 +29,7 @@ export interface ServerOpts {
29
29
  * Initial list of routes (more can be added dynamically via
30
30
  * {@link Server.addRoutes}).
31
31
  */
32
- routes: ServerRoute[];
32
+ routes: ServerRoute<CTX>[];
33
33
  /**
34
34
  * Route prefix. Default: `/`. All routes are assumed to have this prefix
35
35
  * prepended. If given, the prefix MUST end with `/`.
@@ -44,13 +44,22 @@ export interface ServerOpts {
44
44
  * Interceptors (aka pre/post middlewares) which are to be applied to all
45
45
  * route handlers (in the given order).
46
46
  */
47
- intercept: Interceptor[];
47
+ intercept: Interceptor<CTX>[];
48
+ /**
49
+ * User defined function to augment the {@link RequestCtx} object for each
50
+ * request before processing its handler & interceptors.
51
+ */
52
+ context: Fn<RequestCtx, CTX>;
48
53
  }
49
- export interface ServerRoute extends Route {
50
- handlers: Partial<Record<Method, RequestHandler>>;
54
+ export interface ServerRoute<CTX extends RequestCtx = RequestCtx> extends Route {
55
+ handlers: Partial<Record<Method, RequestHandler<CTX>>>;
51
56
  }
52
- export interface CompiledServerRoute extends Route {
53
- handlers: Partial<Record<Method, CompiledHandler>>;
57
+ /**
58
+ * Version of {@link ServerRoute} whose handlers/interceptors already have been
59
+ * pre-processed.
60
+ */
61
+ export interface CompiledServerRoute<CTX extends RequestCtx = RequestCtx> extends Route {
62
+ handlers: Partial<Record<Method, CompiledHandler<CTX>>>;
54
63
  }
55
64
  export interface RequestCtx {
56
65
  server: Server;
@@ -63,12 +72,12 @@ export interface RequestCtx {
63
72
  query: Record<string, any>;
64
73
  cookies?: Record<string, string>;
65
74
  session?: ServerSession;
66
- [id: string]: any;
67
75
  }
68
- export type HandlerResult = MaybePromise<Maybe<RequestCtx> | void>;
69
- export type RequestHandler = Fn<RequestCtx, HandlerResult> | InterceptedRequestHandler;
70
- export interface InterceptedRequestHandler {
71
- fn: Fn<RequestCtx, HandlerResult>;
76
+ export type HandlerResult = MaybePromise<void>;
77
+ export type InterceptorResult = MaybePromise<boolean>;
78
+ export type RequestHandler<CTX extends RequestCtx = RequestCtx> = Fn<CTX, HandlerResult> | InterceptedRequestHandler<CTX>;
79
+ export interface InterceptedRequestHandler<CTX extends RequestCtx = RequestCtx> {
80
+ fn: Fn<CTX, HandlerResult>;
72
81
  /**
73
82
  * List of interceptors which will be executed when processing the main
74
83
  * handler {@link InterceptedRequestHandler.fn}.
@@ -85,14 +94,14 @@ export interface InterceptedRequestHandler {
85
94
  * If an interceptor function returns false, further processing stops and
86
95
  * response will be closed.
87
96
  */
88
- intercept: Interceptor[];
97
+ intercept: Interceptor<CTX>[];
89
98
  }
90
- export interface CompiledHandler {
91
- fn: Fn<RequestCtx, HandlerResult>;
92
- pre?: Fn<RequestCtx, InterceptorResult>[];
93
- post?: Fn<RequestCtx, InterceptorResult>[];
99
+ export interface CompiledHandler<CTX extends RequestCtx = RequestCtx> {
100
+ fn: Fn<CTX, HandlerResult>;
101
+ pre?: Fn<CTX, InterceptorResult>[];
102
+ post?: Fn<CTX, InterceptorResult>[];
94
103
  }
95
- export interface Interceptor {
104
+ export interface Interceptor<CTX extends RequestCtx = RequestCtx> {
96
105
  /**
97
106
  * Interceptor function which will be run BEFORE the main route handler (aka
98
107
  * {@link InterceptedRequestHandler.fn}). If an interceptor needs to cancel
@@ -100,15 +109,47 @@ export interface Interceptor {
100
109
  * pre-interceptors, the main handler and all post-interceptors will be
101
110
  * skipped.
102
111
  */
103
- pre?: Fn<RequestCtx, InterceptorResult>;
112
+ pre?: Fn<CTX, InterceptorResult>;
104
113
  /**
105
114
  * Interceptor function which will be run AFTER the main route handler (aka
106
115
  * {@link InterceptedRequestHandler.fn}). If an interceptor needs to cancel
107
116
  * the request processing it must return `false`. In this case any further
108
117
  * post-interceptors will be skipped.
109
118
  */
110
- post?: Fn<RequestCtx, InterceptorResult>;
119
+ post?: Fn<CTX, InterceptorResult>;
120
+ }
121
+ export interface ServerSession {
122
+ id: string;
123
+ flash?: FlashMsg;
124
+ }
125
+ export interface FlashMsg {
126
+ type: "success" | "info" | "warn" | "error";
127
+ body: any;
128
+ }
129
+ export interface ISessionStore<T extends ServerSession = ServerSession> {
130
+ /**
131
+ * Attempts to retrieve the session for given `id`.
132
+ *
133
+ * @param id
134
+ */
135
+ get(id: string): MaybePromise<Maybe<T>>;
136
+ /**
137
+ * Adds given `session` to underlying storage.
138
+ *
139
+ * @param session
140
+ */
141
+ set(session: T): MaybePromise<boolean>;
142
+ /**
143
+ * Attempts to delete the session for given `id` from storage. Returns true
144
+ * if successful.
145
+ *
146
+ * @param id
147
+ */
148
+ delete(id: string): MaybePromise<boolean>;
149
+ /**
150
+ * Configured Time-To-Live for stored sessions. Will also be used to
151
+ * configure the `max-age` attribute of the session ID cookie.
152
+ */
153
+ readonly ttl: number;
111
154
  }
112
- export type InterceptorResult = MaybePromise<boolean>;
113
- export type Method = "get" | "put" | "post" | "delete" | "head" | "options" | "patch";
114
155
  //# sourceMappingURL=api.d.ts.map
package/index.d.ts CHANGED
@@ -6,10 +6,11 @@ export * from "./interceptors/cache-control.js";
6
6
  export * from "./interceptors/inject-headers.js";
7
7
  export * from "./interceptors/logging.js";
8
8
  export * from "./interceptors/referrer-policy.js";
9
- export * from "./interceptors/session.js";
10
9
  export * from "./interceptors/strict-transport.js";
11
10
  export * from "./interceptors/x-origin-opener.js";
12
11
  export * from "./interceptors/x-origin-resource.js";
12
+ export * from "./session/session.js";
13
+ export * from "./session/memory.js";
13
14
  export * from "./utils/cookies.js";
14
15
  export * from "./utils/cache.js";
15
16
  export * from "./utils/formdata.js";
package/index.js CHANGED
@@ -6,10 +6,11 @@ export * from "./interceptors/cache-control.js";
6
6
  export * from "./interceptors/inject-headers.js";
7
7
  export * from "./interceptors/logging.js";
8
8
  export * from "./interceptors/referrer-policy.js";
9
- export * from "./interceptors/session.js";
10
9
  export * from "./interceptors/strict-transport.js";
11
10
  export * from "./interceptors/x-origin-opener.js";
12
11
  export * from "./interceptors/x-origin-resource.js";
12
+ export * from "./session/session.js";
13
+ export * from "./session/memory.js";
13
14
  export * from "./utils/cookies.js";
14
15
  export * from "./utils/cache.js";
15
16
  export * from "./utils/formdata.js";
@@ -14,5 +14,5 @@ import type { Interceptor, RequestCtx } from "../api.js";
14
14
  *
15
15
  * @param pred
16
16
  */
17
- export declare const authenticateWith: (pred: Predicate<RequestCtx>) => Interceptor;
17
+ export declare const authenticateWith: <CTX extends RequestCtx>(pred: Predicate<CTX>) => Interceptor<CTX>;
18
18
  //# sourceMappingURL=auth-route.d.ts.map
@@ -4,12 +4,11 @@ const __method = (level) => (isString(level) ? level : LogLevel[level]).toLowerC
4
4
  const logRequest = (level = "INFO") => {
5
5
  const method = __method(level);
6
6
  return {
7
- pre: ({ logger, req, match, query }) => {
7
+ pre: ({ logger, req, match, query, cookies }) => {
8
8
  logger[method]("request route", req.method, match);
9
9
  logger[method]("request headers", req.headers);
10
- if (Object.keys(query).length) {
11
- logger[method]("request query", query);
12
- }
10
+ logger[method]("request cookies", cookies);
11
+ logger[method]("request query", query);
13
12
  return true;
14
13
  }
15
14
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thi.ng/server",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Minimal HTTP server with declarative routing, static file serving and freely extensible via pre/post interceptors",
5
5
  "type": "module",
6
6
  "module": "./index.js",
@@ -90,6 +90,7 @@
90
90
  "./*.js",
91
91
  "./*.d.ts",
92
92
  "interceptors",
93
+ "session",
93
94
  "utils"
94
95
  ],
95
96
  "exports": {
@@ -114,9 +115,6 @@
114
115
  "./interceptors/referrer-policy": {
115
116
  "default": "./interceptors/referrer-policy.js"
116
117
  },
117
- "./interceptors/session": {
118
- "default": "./interceptors/session.js"
119
- },
120
118
  "./interceptors/strict-transport": {
121
119
  "default": "./interceptors/strict-transport.js"
122
120
  },
@@ -129,6 +127,12 @@
129
127
  "./server": {
130
128
  "default": "./server.js"
131
129
  },
130
+ "./session/memory": {
131
+ "default": "./session/memory.js"
132
+ },
133
+ "./session/session": {
134
+ "default": "./session/session.js"
135
+ },
132
136
  "./static": {
133
137
  "default": "./static.js"
134
138
  },
@@ -149,5 +153,5 @@
149
153
  "status": "alpha",
150
154
  "year": 2024
151
155
  },
152
- "gitHead": "fc1d498e8d4b690db873c30cc594352a804e7a65\n"
156
+ "gitHead": "078de98f4365f0d472c87198bcd6a112e732d9ef\n"
153
157
  }
package/server.d.ts CHANGED
@@ -1,18 +1,20 @@
1
+ import { type Fn } from "@thi.ng/api";
1
2
  import { type ILogger } from "@thi.ng/logger";
2
3
  import { Router, type RouteMatch } from "@thi.ng/router";
3
4
  import * as http from "node:http";
4
5
  import type { CompiledHandler, CompiledServerRoute, RequestCtx, ServerOpts, ServerRoute } from "./api.js";
5
- export declare class Server {
6
- opts: Partial<ServerOpts>;
6
+ export declare class Server<CTX extends RequestCtx = RequestCtx> {
7
+ opts: Partial<ServerOpts<CTX>>;
7
8
  logger: ILogger;
8
- router: Router;
9
+ router: Router<CompiledServerRoute<CTX>>;
9
10
  server: http.Server;
10
- constructor(opts?: Partial<ServerOpts>);
11
+ protected augmentCtx: Fn<RequestCtx, CTX>;
12
+ constructor(opts?: Partial<ServerOpts<CTX>>);
11
13
  start(): Promise<boolean>;
12
14
  stop(): Promise<boolean>;
13
- listener(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
14
- runHandler({ fn, pre, post }: CompiledHandler, ctx: RequestCtx): Promise<void>;
15
- protected compileRoute(route: ServerRoute): CompiledServerRoute;
15
+ protected listener(req: http.IncomingMessage, res: http.ServerResponse): Promise<void>;
16
+ protected runHandler({ fn, pre, post }: CompiledHandler, ctx: CTX): Promise<void>;
17
+ protected compileRoute(route: ServerRoute<CTX>): CompiledServerRoute<CTX>;
16
18
  addRoutes(routes: ServerRoute[]): void;
17
19
  sendFile({ req, res }: RequestCtx, path: string, headers?: http.OutgoingHttpHeaders, compress?: boolean): Promise<void>;
18
20
  unauthorized(res: http.ServerResponse): void;
@@ -21,5 +23,5 @@ export declare class Server {
21
23
  redirectTo(res: http.ServerResponse, location: string): void;
22
24
  redirectToRoute(res: http.ServerResponse, route: RouteMatch): void;
23
25
  }
24
- export declare const server: (opts?: Partial<ServerOpts>) => Server;
26
+ export declare const server: <CTX extends RequestCtx>(opts?: Partial<ServerOpts<CTX>>) => Server<CTX>;
25
27
  //# sourceMappingURL=server.d.ts.map
package/server.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { identity } from "@thi.ng/api";
1
2
  import { isFunction } from "@thi.ng/checks";
2
3
  import { readText } from "@thi.ng/file-io";
3
4
  import { ConsoleLogger } from "@thi.ng/logger";
@@ -10,11 +11,13 @@ import { pipeline, Transform } from "node:stream";
10
11
  import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
11
12
  import { parseCoookies } from "./utils/cookies.js";
12
13
  import { parseSearchParams } from "./utils/formdata.js";
14
+ import { upper } from "@thi.ng/strings";
13
15
  const MISSING = "__missing";
14
16
  class Server {
15
17
  constructor(opts = {}) {
16
18
  this.opts = opts;
17
19
  this.logger = opts.logger ?? new ConsoleLogger("server");
20
+ this.augmentCtx = opts.context ?? identity;
18
21
  const routes = [
19
22
  {
20
23
  id: MISSING,
@@ -35,6 +38,7 @@ class Server {
35
38
  logger;
36
39
  router;
37
40
  server;
41
+ augmentCtx;
38
42
  async start() {
39
43
  const ssl = this.opts.ssl;
40
44
  const port = this.opts.port ?? (ssl ? 443 : 8080);
@@ -76,9 +80,10 @@ class Server {
76
80
  const query = parseSearchParams(url.searchParams);
77
81
  const match = this.router.route(path);
78
82
  const route = this.router.routeForID(match.id).spec;
79
- const rawCookies = req.headers["set-cookie"]?.join(";");
83
+ const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
80
84
  const cookies = rawCookies ? parseCoookies(rawCookies) : {};
81
- const ctx = {
85
+ const ctx = this.augmentCtx({
86
+ // @ts-ignore
82
87
  server: this,
83
88
  logger: this.logger,
84
89
  req,
@@ -88,12 +93,18 @@ class Server {
88
93
  cookies,
89
94
  route,
90
95
  match
91
- };
96
+ });
92
97
  if (match.id === MISSING) {
93
98
  this.runHandler(route.handlers.get, ctx);
94
99
  return;
95
100
  }
96
101
  let method = ctx.query?.__method || req.method.toLowerCase();
102
+ if (method === "options" && !route.handlers.options) {
103
+ res.writeHead(204, {
104
+ allow: Object.keys(route.handlers).map(upper).join(", ")
105
+ }).end();
106
+ return;
107
+ }
97
108
  if (method === "head" && !route.handlers.head && route.handlers.get) {
98
109
  method = "get";
99
110
  }
@@ -0,0 +1,41 @@
1
+ import { TLRUCache } from "@thi.ng/cache";
2
+ import type { ISessionStore, ServerSession } from "../api.js";
3
+ export interface InMemorySessionOpts<T extends ServerSession = ServerSession> {
4
+ /**
5
+ * Session timeout in seconds.
6
+ *
7
+ * @defaultValue 3600
8
+ */
9
+ ttl: number;
10
+ /**
11
+ * If true (default), a session's cache span automatically extends by
12
+ * {@link InMemorySessionOpts.ttl} with each request. If false, the session
13
+ * auto-expires after TTL since session creation.
14
+ *
15
+ * @defaultValue true
16
+ */
17
+ autoExtend: boolean;
18
+ /**
19
+ * Initial record of active sessions (none by default).
20
+ */
21
+ initial: Record<string, T>;
22
+ }
23
+ /**
24
+ * Session storage implementation for use with {@link serverSession}, using an
25
+ * in-memory TLRU Cache with configurable TTL.
26
+ */
27
+ export declare class InMemorySessionStore<T extends ServerSession = ServerSession> implements ISessionStore<T> {
28
+ readonly ttl: number;
29
+ protected sessions: TLRUCache<string, T>;
30
+ constructor({ ttl, autoExtend, initial, }?: Partial<InMemorySessionOpts<T>>);
31
+ get(id: string): T | undefined;
32
+ set(session: T): boolean;
33
+ delete(id: string): boolean;
34
+ }
35
+ /**
36
+ * Factory function for creating a new {@link InMemorySessionStore}.
37
+ *
38
+ * @param opts
39
+ */
40
+ export declare const inMemorySessionStore: <T extends ServerSession = ServerSession>(opts?: Partial<InMemorySessionOpts<T>>) => InMemorySessionStore<T>;
41
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1,34 @@
1
+ import { TLRUCache } from "@thi.ng/cache";
2
+ class InMemorySessionStore {
3
+ ttl;
4
+ sessions;
5
+ constructor({
6
+ ttl = 3600,
7
+ autoExtend = true,
8
+ initial
9
+ } = {}) {
10
+ this.ttl = ttl;
11
+ this.sessions = new TLRUCache(
12
+ initial ? Object.entries(initial) : null,
13
+ {
14
+ ttl: ttl * 1e3,
15
+ autoExtend
16
+ }
17
+ );
18
+ }
19
+ get(id) {
20
+ return this.sessions.get(id);
21
+ }
22
+ set(session) {
23
+ this.sessions.set(session.id, session);
24
+ return true;
25
+ }
26
+ delete(id) {
27
+ return this.sessions.delete(id);
28
+ }
29
+ }
30
+ const inMemorySessionStore = (opts) => new InMemorySessionStore(opts);
31
+ export {
32
+ InMemorySessionStore,
33
+ inMemorySessionStore
34
+ };
@@ -0,0 +1,43 @@
1
+ import type { Fn } from "@thi.ng/api";
2
+ import { ServerResponse } from "node:http";
3
+ import type { Interceptor, ISessionStore, RequestCtx, ServerSession } from "../api.js";
4
+ export interface SessionOpts<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> {
5
+ /**
6
+ * Session storage implementation. Default: {@link InMemorySessionStore}.
7
+ */
8
+ store: ISessionStore<SESSION>;
9
+ /**
10
+ * Factory function to create a new session object. By default the object
11
+ * only contains a {@link ServerSession.id} (UUID v4).
12
+ */
13
+ factory: Fn<CTX, SESSION>;
14
+ /**
15
+ * Session cookie name
16
+ *
17
+ * @defaultValue "__sid"
18
+ */
19
+ cookieName?: string;
20
+ /**
21
+ * Additional session cookie config options.
22
+ *
23
+ * @defaultValue "Secure;HttpOnly;SameSite=Strict;Path=/"
24
+ */
25
+ cookieOpts?: string;
26
+ }
27
+ export declare class SessionInterceptor<CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession> implements Interceptor<CTX> {
28
+ store: ISessionStore<SESSION>;
29
+ factory: Fn<CTX, SESSION>;
30
+ cookieName: string;
31
+ cookieOpts: string;
32
+ constructor({ store, factory, cookieName, cookieOpts, }?: Partial<SessionOpts<CTX, SESSION>>);
33
+ pre(ctx: CTX): Promise<boolean>;
34
+ delete(ctx: CTX, sessionID: string): Promise<void>;
35
+ withSession(res: ServerResponse, sessionID: string): void;
36
+ }
37
+ /**
38
+ * Factory function to create a new {@link SessionInterceptor} instance.
39
+ *
40
+ * @param opts
41
+ */
42
+ export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts?: Partial<SessionOpts<CTX, SESSION>>) => SessionInterceptor<CTX, SESSION>;
43
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1,54 @@
1
+ import { uuid } from "@thi.ng/uuid";
2
+ import { ServerResponse } from "node:http";
3
+ import { inMemorySessionStore } from "./memory.js";
4
+ class SessionInterceptor {
5
+ store;
6
+ factory;
7
+ cookieName;
8
+ cookieOpts;
9
+ constructor({
10
+ store = inMemorySessionStore(),
11
+ factory = () => ({ id: uuid() }),
12
+ cookieName = "__sid",
13
+ cookieOpts = "Secure;HttpOnly;SameSite=Strict;Path=/"
14
+ } = {}) {
15
+ this.store = store;
16
+ this.factory = factory;
17
+ this.cookieName = cookieName;
18
+ this.cookieOpts = cookieOpts;
19
+ }
20
+ async pre(ctx) {
21
+ const { res, logger, cookies } = ctx;
22
+ const id = cookies?.[this.cookieName];
23
+ let session = id ? await this.store.get(id) : void 0;
24
+ if (!session) {
25
+ session = this.factory(ctx);
26
+ logger.info("new session:", session.id);
27
+ this.store.set(session);
28
+ }
29
+ ctx.session = session;
30
+ this.withSession(res, session.id);
31
+ return true;
32
+ }
33
+ async delete(ctx, sessionID) {
34
+ if (await this.store.delete(sessionID)) {
35
+ ctx.logger.info("delete session:", sessionID);
36
+ ctx.session = void 0;
37
+ ctx.res.appendHeader(
38
+ "set-cookie",
39
+ `${this.cookieName}=;Expires=Thu, 01 Jan 1970 00:00:00 GMT;${this.cookieOpts}`
40
+ );
41
+ }
42
+ }
43
+ withSession(res, sessionID) {
44
+ res.appendHeader(
45
+ "set-cookie",
46
+ `${this.cookieName}=${sessionID};Max-Age=${this.store.ttl};${this.cookieOpts}`
47
+ );
48
+ }
49
+ }
50
+ const serverSession = (opts) => new SessionInterceptor(opts);
51
+ export {
52
+ SessionInterceptor,
53
+ serverSession
54
+ };
package/static.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import type { Fn, MaybePromise, Predicate } from "@thi.ng/api";
2
2
  import { type HashAlgo } from "@thi.ng/file-io";
3
3
  import type { OutgoingHttpHeaders } from "node:http";
4
- import type { Interceptor, ServerRoute } from "./api.js";
4
+ import type { Interceptor, RequestCtx, ServerRoute } from "./api.js";
5
5
  /**
6
6
  * Static file configuration options.
7
7
  */
8
- export interface StaticOpts {
8
+ export interface StaticOpts<CTX extends RequestCtx = RequestCtx> {
9
9
  /**
10
10
  * Path to local root directory for static assets. Also see
11
11
  * {@link StaticOpts.prefix}
@@ -29,7 +29,7 @@ export interface StaticOpts {
29
29
  /**
30
30
  * Additional route specific interceptors.
31
31
  */
32
- intercept: Interceptor[];
32
+ intercept: Interceptor<CTX>[];
33
33
  /**
34
34
  * Additional common headers (e.g. cache control) for all static files
35
35
  */
@@ -46,6 +46,13 @@ export interface StaticOpts {
46
46
  * file is guaranteed to exist when this function is called.
47
47
  */
48
48
  etag: Fn<string, MaybePromise<string>>;
49
+ /**
50
+ * If true, the route will have its `auth` flag enabled, e.g. for use with
51
+ * the {@link authenticateWith} interceptor.
52
+ *
53
+ * @defaultValue false
54
+ */
55
+ auth: boolean;
49
56
  }
50
57
  /**
51
58
  * Defines a configurable {@link ServerRoute} and handler for serving static
@@ -54,7 +61,7 @@ export interface StaticOpts {
54
61
  *
55
62
  * @param opts
56
63
  */
57
- export declare const staticFiles: ({ prefix, rootDir, intercept, filter, compress, etag, headers, }?: Partial<StaticOpts>) => ServerRoute;
64
+ export declare const staticFiles: <CTX extends RequestCtx = RequestCtx>({ prefix, rootDir, intercept, filter, compress, auth, etag, headers, }?: Partial<StaticOpts<CTX>>) => ServerRoute<CTX>;
58
65
  /**
59
66
  * Etag header value function for {@link StaticOpts.etag}. Computes Etag based
60
67
  * on file modified date.
package/static.js CHANGED
@@ -9,11 +9,13 @@ const staticFiles = ({
9
9
  intercept = [],
10
10
  filter = () => true,
11
11
  compress = false,
12
+ auth = false,
12
13
  etag,
13
14
  headers
14
15
  } = {}) => ({
15
16
  id: "__static",
16
17
  match: [prefix, "+"],
18
+ auth,
17
19
  handlers: {
18
20
  head: {
19
21
  fn: async (ctx) => {
@@ -1,51 +0,0 @@
1
- import type { Fn } from "@thi.ng/api";
2
- import * as http from "node:http";
3
- import type { Interceptor, RequestCtx } from "../api.js";
4
- export interface SessionOpts<T extends ServerSession> {
5
- /**
6
- * Factory function to create a new session object. By default the object
7
- * only contains a {@link ServerSession.id} (UUID v4).
8
- */
9
- factory: Fn<RequestCtx, T>;
10
- /**
11
- * Initial record of active sessions (none by default).
12
- */
13
- initial: Record<string, T>;
14
- /**
15
- * Session cookie name
16
- *
17
- * @defaultValue "__sid"
18
- */
19
- cookieName?: string;
20
- /**
21
- * Additional session cookie config options.
22
- *
23
- * @defaultValue "Secure;HttpOnly;SameSite=Strict;Path=/"
24
- */
25
- cookieOpts?: string;
26
- /**
27
- * Session timeout in seconds.
28
- *
29
- * @defaultValue 3600
30
- */
31
- ttl?: number;
32
- }
33
- export interface ServerSession {
34
- id: string;
35
- flash?: FlashMsg;
36
- }
37
- export interface FlashMsg {
38
- type: "success" | "info" | "warn" | "error";
39
- body: any;
40
- }
41
- export interface SessionInterceptor extends Interceptor {
42
- /**
43
- * Adds configured session cookie to response.
44
- *
45
- * @param res
46
- * @param sessionID
47
- */
48
- withSession(res: http.ServerResponse, sessionID: string): http.ServerResponse;
49
- }
50
- export declare const serverSession: <T extends ServerSession>(opts?: Partial<SessionOpts<T>>) => SessionInterceptor;
51
- //# sourceMappingURL=session.d.ts.map
@@ -1,38 +0,0 @@
1
- import { TLRUCache } from "@thi.ng/cache";
2
- import { uuid } from "@thi.ng/uuid";
3
- import * as http from "node:http";
4
- const serverSession = (opts = {}) => {
5
- const factory = opts.factory ?? (() => ({ id: uuid() }));
6
- const ttl = opts.ttl ?? 3600;
7
- const cookieName = opts.cookieName ?? "__sid";
8
- const cookieOpts = `Max-Age=${ttl};` + (opts.cookieOpts ?? "Secure;HttpOnly;SameSite=Strict;Path=/");
9
- const sessions = new TLRUCache(
10
- opts.initial ? Object.entries(opts.initial) : null,
11
- {
12
- ttl: ttl * 1e3,
13
- autoExtend: true
14
- }
15
- );
16
- return {
17
- pre(ctx) {
18
- const { res, logger, cookies } = ctx;
19
- let id = cookies?.[cookieName];
20
- let session = id ? sessions.get(id) : void 0;
21
- if (!session) {
22
- session = factory(ctx);
23
- logger.info("new session", session);
24
- sessions.set(session.id, session);
25
- }
26
- ctx.session = session;
27
- this.withSession(res, session.id);
28
- return true;
29
- },
30
- withSession: (res, sessionID) => res.appendHeader(
31
- "set-cookie",
32
- `${cookieName}=${sessionID};${cookieOpts}`
33
- )
34
- };
35
- };
36
- export {
37
- serverSession
38
- };