@thi.ng/server 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/README.md +111 -41
- package/api.d.ts +66 -25
- package/index.d.ts +3 -1
- package/index.js +3 -1
- package/interceptors/auth-route.d.ts +1 -1
- package/interceptors/auth-route.js +1 -1
- package/interceptors/logging.js +3 -4
- package/package.json +12 -5
- package/server.d.ts +29 -15
- package/server.js +64 -28
- package/session/memory.d.ts +41 -0
- package/session/memory.js +34 -0
- package/session/session.d.ts +44 -0
- package/session/session.js +61 -0
- package/static.d.ts +11 -4
- package/static.js +4 -2
- package/utils/host.d.ts +42 -0
- package/utils/host.js +40 -0
- package/interceptors/session.d.ts +0 -51
- package/interceptors/session.js +0 -38
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Change Log
|
|
2
2
|
|
|
3
|
-
- **Last updated**: 2025-
|
|
3
|
+
- **Last updated**: 2025-02-02T22:46:17Z
|
|
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,29 @@ 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.3.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.3.0) (2025-02-02)
|
|
15
|
+
|
|
16
|
+
#### 🚀 Features
|
|
17
|
+
|
|
18
|
+
- add more HTTP error response methods ([5731ff3](https://github.com/thi-ng/umbrella/commit/5731ff3))
|
|
19
|
+
- add ServerResponse, IPv6 support ([22f64c5](https://github.com/thi-ng/umbrella/commit/22f64c5))
|
|
20
|
+
|
|
21
|
+
## [0.2.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.2.0) (2025-01-30)
|
|
22
|
+
|
|
23
|
+
#### 🚀 Features
|
|
24
|
+
|
|
25
|
+
- add generics, various other updates ([a340f65](https://github.com/thi-ng/umbrella/commit/a340f65))
|
|
26
|
+
- add generics to most main types/interfaces
|
|
27
|
+
- refactor `SessionInterceptor` as class w/ pluggable storage
|
|
28
|
+
- add `ISessionStore` and `InMemorySessionStore` impl
|
|
29
|
+
- update ServerOpts to allow augmenting request context object
|
|
30
|
+
- add default HTTP OPTIONS handler
|
|
31
|
+
- update Server cookie parsing
|
|
32
|
+
- add StaticOpts.auth flag
|
|
33
|
+
- update logRequest() interceptor
|
|
34
|
+
- update pkg exports
|
|
35
|
+
- update tests
|
|
36
|
+
|
|
14
37
|
## [0.1.0](https://github.com/thi-ng/umbrella/tree/@thi.ng/server@0.1.0) (2025-01-29)
|
|
15
38
|
|
|
16
39
|
#### 🚀 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
|
-
-
|
|
45
|
-
|
|
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
|
|
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.
|
|
58
|
-
|
|
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
|
+

|
|
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:
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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, {
|
|
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
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Fn, Maybe, MaybePromise } from "@thi.ng/api";
|
|
2
2
|
import type { ILogger } from "@thi.ng/logger";
|
|
3
3
|
import type { Route, RouteMatch } from "@thi.ng/router";
|
|
4
|
-
import type { IncomingMessage
|
|
5
|
-
import type { Server } from "./server.js";
|
|
6
|
-
|
|
7
|
-
export interface ServerOpts {
|
|
4
|
+
import type { IncomingMessage } from "node:http";
|
|
5
|
+
import type { ServerResponse, Server } from "./server.js";
|
|
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
|
-
|
|
53
|
-
|
|
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<
|
|
69
|
-
export type
|
|
70
|
-
export
|
|
71
|
-
|
|
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<
|
|
92
|
-
pre?: Fn<
|
|
93
|
-
post?: Fn<
|
|
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<
|
|
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<
|
|
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,12 +6,14 @@ 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";
|
|
16
|
+
export * from "./utils/host.js";
|
|
15
17
|
export * from "./utils/formdata.js";
|
|
16
18
|
export * from "./utils/multipart.js";
|
|
17
19
|
//# sourceMappingURL=index.d.ts.map
|
package/index.js
CHANGED
|
@@ -6,11 +6,13 @@ 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";
|
|
16
|
+
export * from "./utils/host.js";
|
|
15
17
|
export * from "./utils/formdata.js";
|
|
16
18
|
export * from "./utils/multipart.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<
|
|
17
|
+
export declare const authenticateWith: <CTX extends RequestCtx>(pred: Predicate<CTX>) => Interceptor<CTX>;
|
|
18
18
|
//# sourceMappingURL=auth-route.d.ts.map
|
package/interceptors/logging.js
CHANGED
|
@@ -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
|
-
|
|
11
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.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
|
},
|
|
@@ -141,6 +145,9 @@
|
|
|
141
145
|
"./utils/formdata": {
|
|
142
146
|
"default": "./utils/formdata.js"
|
|
143
147
|
},
|
|
148
|
+
"./utils/host": {
|
|
149
|
+
"default": "./utils/host.js"
|
|
150
|
+
},
|
|
144
151
|
"./utils/multipart": {
|
|
145
152
|
"default": "./utils/multipart.js"
|
|
146
153
|
}
|
|
@@ -149,5 +156,5 @@
|
|
|
149
156
|
"status": "alpha",
|
|
150
157
|
"year": 2024
|
|
151
158
|
},
|
|
152
|
-
"gitHead": "
|
|
159
|
+
"gitHead": "fa1407b41ef907a5523d30bcb28691a5aed6e85c\n"
|
|
153
160
|
}
|
package/server.d.ts
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
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
|
-
server: http.Server
|
|
10
|
-
|
|
9
|
+
router: Router<CompiledServerRoute<CTX>>;
|
|
10
|
+
server: http.Server<typeof http.IncomingMessage, typeof ServerResponse>;
|
|
11
|
+
host: string;
|
|
12
|
+
protected augmentCtx: Fn<RequestCtx, CTX>;
|
|
13
|
+
constructor(opts?: Partial<ServerOpts<CTX>>);
|
|
11
14
|
start(): Promise<boolean>;
|
|
12
15
|
stop(): Promise<boolean>;
|
|
13
|
-
listener(req: http.IncomingMessage, res:
|
|
14
|
-
runHandler({ fn, pre, post }: CompiledHandler, ctx:
|
|
15
|
-
protected compileRoute(route: ServerRoute): CompiledServerRoute
|
|
16
|
-
addRoutes(routes: ServerRoute[]): void;
|
|
16
|
+
protected listener(req: http.IncomingMessage, res: ServerResponse): Promise<void>;
|
|
17
|
+
protected runHandler({ fn, pre, post }: CompiledHandler, ctx: CTX): Promise<void>;
|
|
18
|
+
protected compileRoute(route: ServerRoute<CTX>): CompiledServerRoute<CTX>;
|
|
19
|
+
addRoutes(routes: ServerRoute<CTX>[]): void;
|
|
17
20
|
sendFile({ req, res }: RequestCtx, path: string, headers?: http.OutgoingHttpHeaders, compress?: boolean): Promise<void>;
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
redirectToRoute(res: ServerResponse, route: RouteMatch): void;
|
|
22
|
+
}
|
|
23
|
+
export declare const server: <CTX extends RequestCtx>(opts?: Partial<ServerOpts<CTX>>) => Server<CTX>;
|
|
24
|
+
/**
|
|
25
|
+
* Extended version of the default NodeJS ServerResponse with additional methods
|
|
26
|
+
* for various commonly used HTTP statuses/errors.
|
|
27
|
+
*/
|
|
28
|
+
export declare class ServerResponse extends http.ServerResponse<http.IncomingMessage> {
|
|
29
|
+
noContent(headers?: http.OutgoingHttpHeaders): void;
|
|
30
|
+
redirectTo(location: string, headers?: http.OutgoingHttpHeaders): void;
|
|
31
|
+
seeOther(location: string, headers?: http.OutgoingHttpHeaders): void;
|
|
32
|
+
unmodified(headers?: http.OutgoingHttpHeaders): void;
|
|
33
|
+
unauthorized(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
34
|
+
forbidden(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
35
|
+
missing(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
36
|
+
notAllowed(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
37
|
+
notAcceptable(headers?: http.OutgoingHttpHeaders, body?: any): void;
|
|
23
38
|
}
|
|
24
|
-
export declare const server: (opts?: Partial<ServerOpts>) => Server;
|
|
25
39
|
//# sourceMappingURL=server.d.ts.map
|
package/server.js
CHANGED
|
@@ -1,26 +1,33 @@
|
|
|
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";
|
|
4
5
|
import { preferredTypeForPath } from "@thi.ng/mime";
|
|
5
6
|
import { Router } from "@thi.ng/router";
|
|
7
|
+
import { upper } from "@thi.ng/strings";
|
|
6
8
|
import { createReadStream } from "node:fs";
|
|
7
9
|
import * as http from "node:http";
|
|
8
10
|
import * as https from "node:https";
|
|
11
|
+
import { isIPv6 } from "node:net";
|
|
9
12
|
import { pipeline, Transform } from "node:stream";
|
|
10
13
|
import { createBrotliCompress, createDeflate, createGzip } from "node:zlib";
|
|
11
14
|
import { parseCoookies } from "./utils/cookies.js";
|
|
12
15
|
import { parseSearchParams } from "./utils/formdata.js";
|
|
16
|
+
import { isMatchingHost, normalizeIPv6Address } from "./utils/host.js";
|
|
13
17
|
const MISSING = "__missing";
|
|
14
18
|
class Server {
|
|
15
19
|
constructor(opts = {}) {
|
|
16
20
|
this.opts = opts;
|
|
17
21
|
this.logger = opts.logger ?? new ConsoleLogger("server");
|
|
22
|
+
this.host = opts.host ?? "localhost";
|
|
23
|
+
if (isIPv6(this.host)) this.host = normalizeIPv6Address(this.host);
|
|
24
|
+
this.augmentCtx = opts.context ?? identity;
|
|
18
25
|
const routes = [
|
|
19
26
|
{
|
|
20
27
|
id: MISSING,
|
|
21
28
|
match: ["__404__"],
|
|
22
29
|
handlers: {
|
|
23
|
-
get: async ({ res }) =>
|
|
30
|
+
get: async ({ res }) => res.missing()
|
|
24
31
|
}
|
|
25
32
|
},
|
|
26
33
|
...this.opts.routes ?? []
|
|
@@ -35,18 +42,22 @@ class Server {
|
|
|
35
42
|
logger;
|
|
36
43
|
router;
|
|
37
44
|
server;
|
|
45
|
+
host;
|
|
46
|
+
augmentCtx;
|
|
38
47
|
async start() {
|
|
39
|
-
const ssl = this.opts
|
|
40
|
-
const port = this.opts.port ?? (ssl ? 443 : 8080);
|
|
41
|
-
const host = this.opts.host ?? "localhost";
|
|
48
|
+
const { ssl, host = "localhost", port = ssl ? 443 : 8080 } = this.opts;
|
|
42
49
|
try {
|
|
43
50
|
this.server = ssl ? https.createServer(
|
|
44
51
|
{
|
|
45
52
|
key: readText(ssl.key, this.logger),
|
|
46
|
-
cert: readText(ssl.cert, this.logger)
|
|
53
|
+
cert: readText(ssl.cert, this.logger),
|
|
54
|
+
ServerResponse
|
|
47
55
|
},
|
|
48
56
|
this.listener.bind(this)
|
|
49
|
-
) : http.createServer(
|
|
57
|
+
) : http.createServer(
|
|
58
|
+
{ ServerResponse },
|
|
59
|
+
this.listener.bind(this)
|
|
60
|
+
);
|
|
50
61
|
this.server.listen(port, host, void 0, () => {
|
|
51
62
|
this.logger.info(
|
|
52
63
|
`starting server: http${ssl ? "s" : ""}://${host}:${port}`
|
|
@@ -66,19 +77,20 @@ class Server {
|
|
|
66
77
|
return true;
|
|
67
78
|
}
|
|
68
79
|
async listener(req, res) {
|
|
69
|
-
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
70
|
-
if (this.opts.host && this.opts.host !== url.host) {
|
|
71
|
-
res.writeHead(503).end();
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
const path = decodeURIComponent(url.pathname);
|
|
75
80
|
try {
|
|
81
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
82
|
+
if (this.opts.host && !isMatchingHost(url.hostname, this.opts.host)) {
|
|
83
|
+
res.writeHead(503).end();
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const path = decodeURIComponent(url.pathname);
|
|
76
87
|
const query = parseSearchParams(url.searchParams);
|
|
77
88
|
const match = this.router.route(path);
|
|
78
89
|
const route = this.router.routeForID(match.id).spec;
|
|
79
|
-
const rawCookies = req.headers["set-cookie"]?.join(";");
|
|
90
|
+
const rawCookies = req.headers["cookie"] || req.headers["set-cookie"]?.join(";");
|
|
80
91
|
const cookies = rawCookies ? parseCoookies(rawCookies) : {};
|
|
81
|
-
const ctx = {
|
|
92
|
+
const ctx = this.augmentCtx({
|
|
93
|
+
// @ts-ignore
|
|
82
94
|
server: this,
|
|
83
95
|
logger: this.logger,
|
|
84
96
|
req,
|
|
@@ -88,12 +100,18 @@ class Server {
|
|
|
88
100
|
cookies,
|
|
89
101
|
route,
|
|
90
102
|
match
|
|
91
|
-
};
|
|
103
|
+
});
|
|
92
104
|
if (match.id === MISSING) {
|
|
93
105
|
this.runHandler(route.handlers.get, ctx);
|
|
94
106
|
return;
|
|
95
107
|
}
|
|
96
108
|
let method = ctx.query?.__method || req.method.toLowerCase();
|
|
109
|
+
if (method === "options" && !route.handlers.options) {
|
|
110
|
+
res.writeHead(204, {
|
|
111
|
+
allow: Object.keys(route.handlers).map(upper).join(", ")
|
|
112
|
+
}).end();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
97
115
|
if (method === "head" && !route.handlers.head && route.handlers.get) {
|
|
98
116
|
method = "get";
|
|
99
117
|
}
|
|
@@ -101,7 +119,7 @@ class Server {
|
|
|
101
119
|
if (handler) {
|
|
102
120
|
this.runHandler(handler, ctx);
|
|
103
121
|
} else {
|
|
104
|
-
res.
|
|
122
|
+
res.notAllowed();
|
|
105
123
|
}
|
|
106
124
|
} catch (e) {
|
|
107
125
|
this.logger.warn("error:", e.message);
|
|
@@ -185,29 +203,47 @@ class Server {
|
|
|
185
203
|
encoding ? pipeline(src, encoding.tx(), res, finalize) : pipeline(src, res, finalize);
|
|
186
204
|
} catch (e) {
|
|
187
205
|
this.logger.warn(e.message);
|
|
188
|
-
|
|
206
|
+
res.missing();
|
|
189
207
|
resolve();
|
|
190
208
|
}
|
|
191
209
|
});
|
|
192
210
|
}
|
|
193
|
-
|
|
194
|
-
res.
|
|
211
|
+
redirectToRoute(res, route) {
|
|
212
|
+
res.redirectTo(this.router.format(route));
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const server = (opts) => new Server(opts);
|
|
216
|
+
class ServerResponse extends http.ServerResponse {
|
|
217
|
+
noContent(headers) {
|
|
218
|
+
this.writeHead(204, headers).end();
|
|
195
219
|
}
|
|
196
|
-
|
|
197
|
-
|
|
220
|
+
redirectTo(location, headers) {
|
|
221
|
+
this.writeHead(302, { ...headers, location }).end();
|
|
198
222
|
}
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
seeOther(location, headers) {
|
|
224
|
+
this.writeHead(303, { ...headers, location }).end();
|
|
201
225
|
}
|
|
202
|
-
|
|
203
|
-
|
|
226
|
+
unmodified(headers) {
|
|
227
|
+
this.writeHead(304, headers).end();
|
|
204
228
|
}
|
|
205
|
-
|
|
206
|
-
this.
|
|
229
|
+
unauthorized(headers, body) {
|
|
230
|
+
this.writeHead(401, headers).end(body);
|
|
231
|
+
}
|
|
232
|
+
forbidden(headers, body) {
|
|
233
|
+
this.writeHead(403, headers).end(body);
|
|
234
|
+
}
|
|
235
|
+
missing(headers, body) {
|
|
236
|
+
this.writeHead(404, headers).end(body);
|
|
237
|
+
}
|
|
238
|
+
notAllowed(headers, body) {
|
|
239
|
+
this.writeHead(405, headers).end(body);
|
|
240
|
+
}
|
|
241
|
+
notAcceptable(headers, body) {
|
|
242
|
+
this.writeHead(406, headers).end(body);
|
|
207
243
|
}
|
|
208
244
|
}
|
|
209
|
-
const server = (opts) => new Server(opts);
|
|
210
245
|
export {
|
|
211
246
|
Server,
|
|
247
|
+
ServerResponse,
|
|
212
248
|
server
|
|
213
249
|
};
|
|
@@ -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,44 @@
|
|
|
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
|
+
newSession(ctx: CTX): Promise<SESSION | undefined>;
|
|
36
|
+
withSession(res: ServerResponse, sessionID: string): ServerResponse<import("http").IncomingMessage>;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Factory function to create a new {@link SessionInterceptor} instance.
|
|
40
|
+
*
|
|
41
|
+
* @param opts
|
|
42
|
+
*/
|
|
43
|
+
export declare const serverSession: <CTX extends RequestCtx = RequestCtx, SESSION extends ServerSession = ServerSession>(opts?: Partial<SessionOpts<CTX, SESSION>>) => SessionInterceptor<CTX, SESSION>;
|
|
44
|
+
//# sourceMappingURL=session.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
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 id = ctx.cookies?.[this.cookieName];
|
|
22
|
+
let session = id ? await this.store.get(id) : void 0;
|
|
23
|
+
if (!session) {
|
|
24
|
+
session = await this.newSession(ctx);
|
|
25
|
+
if (!session) return false;
|
|
26
|
+
}
|
|
27
|
+
ctx.session = session;
|
|
28
|
+
this.withSession(ctx.res, session.id);
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
async delete(ctx, sessionID) {
|
|
32
|
+
if (await this.store.delete(sessionID)) {
|
|
33
|
+
ctx.logger.info("delete session:", sessionID);
|
|
34
|
+
ctx.session = void 0;
|
|
35
|
+
ctx.res.appendHeader(
|
|
36
|
+
"set-cookie",
|
|
37
|
+
`${this.cookieName}=;Expires=Thu, 01 Jan 1970 00:00:00 GMT;${this.cookieOpts}`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
async newSession(ctx) {
|
|
42
|
+
const session = this.factory(ctx);
|
|
43
|
+
ctx.logger.info("new session:", session.id);
|
|
44
|
+
if (!await this.store.set(session)) {
|
|
45
|
+
ctx.logger.warn("could not store session...");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
return session;
|
|
49
|
+
}
|
|
50
|
+
withSession(res, sessionID) {
|
|
51
|
+
return res.appendHeader(
|
|
52
|
+
"set-cookie",
|
|
53
|
+
`${this.cookieName}=${sessionID};Max-Age=${this.store.ttl};${this.cookieOpts}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const serverSession = (opts) => new SessionInterceptor(opts);
|
|
58
|
+
export {
|
|
59
|
+
SessionInterceptor,
|
|
60
|
+
serverSession
|
|
61
|
+
};
|
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
|
|
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) => {
|
|
@@ -52,11 +54,11 @@ const staticFiles = ({
|
|
|
52
54
|
});
|
|
53
55
|
const __fileHeaders = async (path, ctx, filter, etag, headers) => {
|
|
54
56
|
if (!(existsSync(path) && filter(path))) {
|
|
55
|
-
return ctx.
|
|
57
|
+
return ctx.res.missing();
|
|
56
58
|
}
|
|
57
59
|
if (etag) {
|
|
58
60
|
const etagValue = await etag(path);
|
|
59
|
-
return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.
|
|
61
|
+
return isUnmodified(etagValue, ctx.req.headers["if-none-match"]) ? ctx.res.unmodified() : { ...headers, etag: etagValue };
|
|
60
62
|
}
|
|
61
63
|
return { ...headers };
|
|
62
64
|
};
|
package/utils/host.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Takes a hostname or IPv6 address `test` and compares it against `expected`,
|
|
3
|
+
* returns true if matching.
|
|
4
|
+
*
|
|
5
|
+
* @remarks
|
|
6
|
+
* If `test` is an IPv6 address in the format `[...]`, it will be compared
|
|
7
|
+
* without surrounding square brackets. If `expected` is an IPv6 address it MUST
|
|
8
|
+
* have been pre-normalized using {@link normalizeIPv6Address}. For performance
|
|
9
|
+
* reasons only `test` will be automatically normalized, i.e. `::1` will be
|
|
10
|
+
* normalized as `0:0:0:0:0:0:0:1`.
|
|
11
|
+
*
|
|
12
|
+
* @param test
|
|
13
|
+
* @param expected
|
|
14
|
+
*
|
|
15
|
+
* @internal
|
|
16
|
+
*/
|
|
17
|
+
export declare const isMatchingHost: (test: string, expected: string) => boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Parses given IPv6 address into an 8-tuple of 16bit uints. Throws error if
|
|
20
|
+
* address is invalid.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
* https://en.wikipedia.org/wiki/IPv6_address
|
|
24
|
+
*
|
|
25
|
+
* @param addr
|
|
26
|
+
*/
|
|
27
|
+
export declare const parseIPv6Address: (addr: string) => number[];
|
|
28
|
+
/**
|
|
29
|
+
* Returns normalized version of given IPv6 address, i.e. expanding `::`
|
|
30
|
+
* sections, removing leading zeroes and performing other syntactic validations.
|
|
31
|
+
* The returned address always has 8 parts. Throws error if address is invalid.
|
|
32
|
+
*
|
|
33
|
+
* @remarks
|
|
34
|
+
* Internally uses {@link parseIPv6Address}.
|
|
35
|
+
*
|
|
36
|
+
* Reference:
|
|
37
|
+
* https://en.wikipedia.org/wiki/IPv6_address
|
|
38
|
+
*
|
|
39
|
+
* @param addr
|
|
40
|
+
*/
|
|
41
|
+
export declare const normalizeIPv6Address: (addr: string) => string;
|
|
42
|
+
//# sourceMappingURL=host.d.ts.map
|
package/utils/host.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { illegalArgs } from "@thi.ng/errors";
|
|
2
|
+
import { HEX } from "@thi.ng/strings";
|
|
3
|
+
const isMatchingHost = (test, expected) => /^\[[0-9a-f:]+\]$/.test(test) ? normalizeIPv6Address(test.substring(1, test.length - 1)) === expected : test === expected;
|
|
4
|
+
const parseIPv6Address = (addr) => {
|
|
5
|
+
if (addr == "::") return [0, 0, 0, 0, 0, 0, 0, 0];
|
|
6
|
+
if (addr == "::1") return [0, 0, 0, 0, 0, 0, 0, 1];
|
|
7
|
+
const n = addr.length - 1;
|
|
8
|
+
if (n > 38) invalidIPv6(addr);
|
|
9
|
+
const parts = [];
|
|
10
|
+
let curr = 0;
|
|
11
|
+
let zeroes = -1;
|
|
12
|
+
for (let i = 0; i <= n; i++) {
|
|
13
|
+
const ch = addr[i];
|
|
14
|
+
if (i === n && ch == ":") illegalArgs(addr);
|
|
15
|
+
if (i === n || ch === ":") {
|
|
16
|
+
if (parts.length >= (zeroes >= 0 ? 6 : 8)) invalidIPv6(addr);
|
|
17
|
+
const end = i === n ? n + 1 : i > curr ? i : invalidIPv6(addr);
|
|
18
|
+
if (end - curr > 4) invalidIPv6(addr);
|
|
19
|
+
parts.push(parseInt(addr.substring(curr, end), 16));
|
|
20
|
+
if (addr[i + 1] === ":") {
|
|
21
|
+
if (zeroes >= 0) invalidIPv6(addr);
|
|
22
|
+
zeroes = parts.length;
|
|
23
|
+
i++;
|
|
24
|
+
}
|
|
25
|
+
curr = i + 1;
|
|
26
|
+
} else if (!HEX[ch]) invalidIPv6(addr);
|
|
27
|
+
}
|
|
28
|
+
if (zeroes >= 0) {
|
|
29
|
+
parts.splice(zeroes, 0, ...new Array(8 - parts.length).fill(0));
|
|
30
|
+
}
|
|
31
|
+
if (parts.length !== 8) invalidIPv6(addr);
|
|
32
|
+
return parts;
|
|
33
|
+
};
|
|
34
|
+
const normalizeIPv6Address = (addr) => parseIPv6Address(addr).map((x) => x.toString(16)).join(":");
|
|
35
|
+
const invalidIPv6 = (addr) => illegalArgs("invalid IPv6 address: " + addr);
|
|
36
|
+
export {
|
|
37
|
+
isMatchingHost,
|
|
38
|
+
normalizeIPv6Address,
|
|
39
|
+
parseIPv6Address
|
|
40
|
+
};
|
|
@@ -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
|
package/interceptors/session.js
DELETED
|
@@ -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
|
-
};
|