@smuzi/http-server 0.0.2 → 0.0.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 smuzi-ts
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/build/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { HttpProtocol, None, HttpResponse, dump } from "@smuzi/std";
2
2
  function http1ErrorHandler(context, error) {
3
3
  //TODO: write error to log and remove dump()
4
- dump(error);
4
+ dump({ msg: "http1ErrorHandler", error });
5
5
  return HttpResponse.asJson({ error: "Internal Server Error" }, 500);
6
6
  }
7
7
  export function buildHttp1ServerConfig({ host = 'localhost', port = 8080, router, cert = None(), errorHandler = http1ErrorHandler }) {
@@ -3,7 +3,8 @@ import https from 'node:https';
3
3
  import { TLSSocket } from 'node:tls';
4
4
  import fs from 'node:fs';
5
5
  import { methodFromString } from "#lib/router.js";
6
- import { isArray, isObject, isString, matchUnknown, OptionFromNullable, Err, Ok, isNull, transformError, StdError, HttpResponse, HttpRequest, StdMap, isOption, isResult, RequestHttpHeaders, asList, asRecord, asMap, querystring, StdJson } from '@smuzi/std';
6
+ import { isArray, isObject, isString, StdRecord, matchUnknown, OptionFromNullable, Err, Ok, isNull, transformError, StdError, HttpResponse, HttpRequest, isOption, isResult, RequestHttpHeaders, asList, asRecord, asMap, querystring, StdJson } from '@smuzi/std';
7
+ import { log } from 'node:console';
7
8
  export class StdHttp1Server {
8
9
  #server;
9
10
  constructor(server) {
@@ -94,7 +95,7 @@ export async function http1ServerRun(config) {
94
95
  request: new HttpRequest({
95
96
  method: request.method,
96
97
  path: request.path,
97
- query: () => new StdMap(urlObj.searchParams),
98
+ query: () => new StdRecord(Object.fromEntries(urlObj.searchParams.entries())),
98
99
  headers: new RequestHttpHeaders(nativeRequest.headers),
99
100
  buffer: readRequestBodyAsBuffer(nativeRequest),
100
101
  body: readRawBody(nativeRequest),
@@ -171,6 +172,7 @@ export async function http1ServerRun(config) {
171
172
  }
172
173
  });
173
174
  server.once('error', (nativeError) => {
175
+ log("Server started on", nativeError);
174
176
  resolve(Err({
175
177
  errno: OptionFromNullable(nativeError.errno),
176
178
  code: OptionFromNullable(nativeError.code),
@@ -181,6 +183,7 @@ export async function http1ServerRun(config) {
181
183
  }));
182
184
  });
183
185
  server.listen(config.port, () => {
186
+ log("Server started on " + config.protocol + "://" + config.host + ":" + config.port);
184
187
  resolve(Ok(new StdHttp1Server(server)));
185
188
  });
186
189
  });
package/build/router.js CHANGED
@@ -11,7 +11,13 @@ export function contactPaths(path1, path2) {
11
11
  const path1AsRegExp = asRegExp(path1);
12
12
  const path2AsRegExp = asRegExp(path2);
13
13
  if (path1AsRegExp || path2AsRegExp) {
14
- return new RegExp((path1AsRegExp ? path1.source : path1) + (path2AsRegExp ? path2.source : path2));
14
+ const source1 = path1AsRegExp
15
+ ? path1.source
16
+ : path1;
17
+ const source2 = path2AsRegExp
18
+ ? path2.source
19
+ : path2;
20
+ return new RegExp("^" + (source1 + source2).replaceAll('^', ''));
15
21
  }
16
22
  return path1 + path2;
17
23
  }
@@ -47,8 +53,18 @@ function http2NotFoundHandler(context) {
47
53
  }
48
54
  export function CreateHttpRouter(groupRoute, notFound) {
49
55
  const routes = new Map();
56
+ // Every own route/subgroup is registered ONCE at startup. We additionally
57
+ // remember each one's path *relative to this router* so that, if this
58
+ // router later gets attached to a parent via `.group()`, we can recompute
59
+ // every already-registered absolute path exactly once at that moment —
60
+ // never again on every match()/request.
61
+ const ownRoutes = [];
62
+ const ownGroups = [];
63
+ const computeAbsolute = (localPath) => processPath(contactPaths(groupRoute.path, localPath));
50
64
  const add = (route, action) => {
51
- route.path = processPath(contactPaths(groupRoute.path, route.path));
65
+ const localPath = route.path;
66
+ route.path = computeAbsolute(localPath);
67
+ ownRoutes.push({ route, localPath });
52
68
  routes.set(route, (routeData) => {
53
69
  return {
54
70
  action,
@@ -56,11 +72,26 @@ export function CreateHttpRouter(groupRoute, notFound) {
56
72
  };
57
73
  });
58
74
  };
59
- const addGroup = (route, action) => {
60
- route.path = processPath(contactPaths(groupRoute.path, route.path));
75
+ const addGroup = (localGroupPath, childRouter, action) => {
76
+ const route = { path: computeAbsolute(toStartWithPattern(localGroupPath)) };
77
+ ownGroups.push({ route, localGroupPath, childRouter });
61
78
  routes.set(route, action);
62
79
  };
63
- return {
80
+ // Recomputes this router's own base plus every route/subgroup registered
81
+ // on it so far, then recurses into any already-nested group routers so
82
+ // the whole subtree stays consistent — called once, when this router is
83
+ // attached to a parent via `.group()`.
84
+ const rebase = (newBase) => {
85
+ groupRoute.path = newBase;
86
+ for (const entry of ownRoutes) {
87
+ entry.route.path = computeAbsolute(entry.localPath);
88
+ }
89
+ for (const entry of ownGroups) {
90
+ entry.route.path = computeAbsolute(toStartWithPattern(entry.localGroupPath));
91
+ entry.childRouter.__rebase(contactPaths(newBase, entry.localGroupPath));
92
+ }
93
+ };
94
+ const router = {
64
95
  get(path, action) {
65
96
  add({ path, method: HttpMethod.GET }, action);
66
97
  },
@@ -74,11 +105,13 @@ export function CreateHttpRouter(groupRoute, notFound) {
74
105
  add({ path, method: HttpMethod.DELETE }, action);
75
106
  },
76
107
  group(groupRouter) {
77
- const groupPath = groupRouter.getGroupRoute().path;
78
- const startWithPattern = toStartWithPattern(groupPath);
79
- addGroup({ path: startWithPattern }, (routeData) => {
108
+ const localGroupPath = groupRouter.getGroupRoute().path;
109
+ addGroup(localGroupPath, groupRouter, (routeData) => {
80
110
  return groupRouter.match(routeData.val);
81
111
  });
112
+ // Re-derive every path already registered on the nested router
113
+ // (and anything nested under IT) against our current absolute base.
114
+ groupRouter.__rebase(contactPaths(groupRoute.path, localGroupPath));
82
115
  },
83
116
  getMapRoutes() {
84
117
  return routes;
@@ -93,8 +126,10 @@ export function CreateHttpRouter(groupRoute, notFound) {
93
126
  pathParams: routeData.params.flatByKey("path"),
94
127
  };
95
128
  });
96
- }
129
+ },
130
+ __rebase: rebase,
97
131
  };
132
+ return router;
98
133
  }
99
134
  export function CreateHttp1Router(groupRoute, notFound = http1NotFoundHandler) {
100
135
  return CreateHttpRouter(groupRoute, notFound);
package/package.json CHANGED
@@ -1,13 +1,9 @@
1
1
  {
2
2
  "name": "@smuzi/http-server",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "HTTP server and routing for JavaScript and TypeScript",
5
- "scripts": {
6
- "test": "tsx tests/index.ts",
7
- "build": "tsc --project tsconfig.build.json",
8
- "prepublishOnly": "npm run build"
9
- },
10
5
  "type": "module",
6
+ "types": "./build/index.d.ts",
11
7
  "keywords": [
12
8
  "http",
13
9
  "router",
@@ -22,21 +18,30 @@
22
18
  "access": "public"
23
19
  },
24
20
  "files": [
21
+ "./src",
25
22
  "./build"
26
23
  ],
27
24
  "exports": {
28
- ".": "./src/index.js"
25
+ "./package.json": "./package.json",
26
+ ".": "./src/index.ts",
27
+ "./*": "./src/*.ts"
29
28
  },
30
29
  "imports": {
31
30
  "#lib/*": "./src/*"
32
31
  },
32
+ "dependencies": {
33
+ "@smuzi/std": "0.2.4",
34
+ "@smuzi/schema": "0.0.2"
35
+ },
33
36
  "devDependencies": {
34
- "@smuzi/std": "workspace:*",
35
- "@smuzi/faker": "workspace:*",
36
- "@smuzi/schema": "workspace:*",
37
- "@smuzi/tests": "workspace:*",
37
+ "@smuzi/faker": "0.0.2",
38
+ "@smuzi/tests": "0.0.1",
38
39
  "@types/node": "^22.15.21",
39
40
  "tsx": "^4.20.6",
40
41
  "typescript": "^7.0.2"
42
+ },
43
+ "scripts": {
44
+ "test": "tsx --conditions=development tests/index.ts",
45
+ "build": "tsc --project tsconfig.build.json"
41
46
  }
42
47
  }
package/src/config.ts ADDED
@@ -0,0 +1,60 @@
1
+ import {Option, HttpProtocol, None, HttpResponse, transformError, dump} from "@smuzi/std";
2
+ import {ActionErrorHandler, Context, Http1Router} from "#lib/router.js";
3
+ import {ServerResponse} from "node:http";
4
+
5
+ type Cert = Option<{
6
+ key: string,
7
+ cert: string,
8
+ }>
9
+
10
+ export type Http1ServerConfig = {
11
+ host: string;
12
+ port: number,
13
+ router: Http1Router,
14
+ cert: Cert,
15
+ protocol: HttpProtocol,
16
+ errorHandler: ActionErrorHandler<ServerResponse>
17
+ }
18
+
19
+ type InputHttp1ServerConfig = Partial<Http1ServerConfig> & {
20
+ router: Http1Router,
21
+ }
22
+
23
+ function http1ErrorHandler(context: Context<ServerResponse>, error) {
24
+ //TODO: write error to log and remove dump()
25
+ dump({msg: "http1ErrorHandler", error})
26
+ return HttpResponse.asJson({error:"Internal Server Error"}, 500);
27
+ }
28
+
29
+ export function buildHttp1ServerConfig({host = 'localhost', port = 8080, router, cert = None(), errorHandler = http1ErrorHandler}: InputHttp1ServerConfig): Http1ServerConfig {
30
+ return {
31
+ host,
32
+ port,
33
+ router,
34
+ cert,
35
+ protocol: cert.someOrNone(HttpProtocol.HTTPS, HttpProtocol.HTTP),
36
+ errorHandler,
37
+ };
38
+ };
39
+
40
+ type Http2BaseServerConfig = {
41
+ host: string;
42
+ port: number,
43
+ router: Http1Router,
44
+ cert?: Cert
45
+ }
46
+
47
+ export type Http2ServerConfig = Http2BaseServerConfig & {
48
+ cert: Cert,
49
+ protocol: HttpProtocol,
50
+ }
51
+
52
+ export function buildHttp2ServerConfig({host, port, router, cert = None() }: Http2BaseServerConfig): Http2ServerConfig {
53
+ return {
54
+ host,
55
+ port,
56
+ router,
57
+ cert,
58
+ protocol: cert.someOrNone(HttpProtocol.HTTPS, HttpProtocol.HTTP)
59
+ };
60
+ };
@@ -0,0 +1,263 @@
1
+ import http, { IncomingMessage, ServerResponse } from 'node:http';
2
+ import https from 'node:https';
3
+ import { TLSSocket } from 'node:tls';
4
+ import fs from 'node:fs';
5
+
6
+ import { methodFromString } from "#lib/router.js";
7
+ import {
8
+ isArray,
9
+ isObject,
10
+ isString,
11
+ match,
12
+ StdRecord,
13
+ matchUnknown,
14
+ OptionFromNullable,
15
+ Some,
16
+ Result,
17
+ Option,
18
+ Err,
19
+ Ok,
20
+ isNull,
21
+ transformError,
22
+ StdError,
23
+ dump,
24
+ HttpResponse,
25
+ HttpRequest,
26
+ StdMap,
27
+ isSome,
28
+ isOption,
29
+ isResult,
30
+ isIterable,
31
+ ResponseHttpHeaders, RequestHttpHeaders, asList, asRecord, asMap, querystring, QueryParams,
32
+ StdJson, uuid
33
+ } from '@smuzi/std';
34
+ import { HttpServer, HttpServerRunError, Http1ServerConfig } from "#lib/index.js";
35
+ import { log } from 'node:console';
36
+
37
+ type NativeServer = any
38
+
39
+ export class StdHttp1Server implements HttpServer {
40
+ readonly #server: NativeServer
41
+
42
+ constructor(server: NativeServer) {
43
+ this.#server = server;
44
+ }
45
+ async close(): Promise<Result<boolean, StdError>> {
46
+ return new Promise(resolve => {
47
+ this.#server.close((err) => {
48
+ return resolve(isNull(err) ? Ok(true) : Err(transformError(err)));
49
+ })
50
+ })
51
+ }
52
+ }
53
+
54
+ function readRequestBodyAsBuffer(req: IncomingMessage): () => Promise<Result<Buffer, Error>> {
55
+ return async () => {
56
+ return new Promise((resolve, reject) => {
57
+ const chunks: Buffer[] = [];
58
+
59
+ req.on("data", (chunk) => {
60
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
61
+ });
62
+
63
+ req.on("end", () => {
64
+ resolve(Ok(Buffer.concat(chunks)));
65
+ });
66
+
67
+ req.on("error", (err) => reject(Err(err)));
68
+ });
69
+ }
70
+ }
71
+
72
+ function readRequestJson(req: IncomingMessage): <T>() => Promise<Result<Option<T>, StdError>> {
73
+ return async(encoding: BufferEncoding = "utf-8") => {
74
+ return new Promise((resolve) => {
75
+ let body = "";
76
+
77
+ req.setEncoding(encoding);
78
+
79
+ req.on("data", (chunk: string) => {
80
+ body += chunk;
81
+ });
82
+
83
+ req.on("end", () => {
84
+ resolve(StdJson.fromString(body));
85
+ });
86
+
87
+ req.on("error", (err) => resolve(Err(transformError(err))));
88
+ });
89
+ }
90
+ }
91
+
92
+ function readRequestInput(req: IncomingMessage): <T extends StdRecord<QueryParams>>() => Promise<Result<T, StdError>> {
93
+ return async(encoding: BufferEncoding = "utf-8") => {
94
+ return new Promise((resolve) => {
95
+ let body = "";
96
+
97
+ req.setEncoding(encoding);
98
+
99
+ req.on("data", (chunk: string) => {
100
+ body += chunk;
101
+ });
102
+
103
+ req.on("end", () => {
104
+ resolve(querystring.fromString(body));
105
+ });
106
+
107
+ req.on("error", (err) => resolve(Err(transformError(err))));
108
+ });
109
+ }
110
+ }
111
+
112
+ function readRawBody(req: IncomingMessage): <T>() => Promise<Result<string, StdError>> {
113
+ return async(encoding: BufferEncoding = "utf-8") => {
114
+ return new Promise((resolve) => {
115
+ let body = "";
116
+
117
+ req.setEncoding(encoding);
118
+
119
+ req.on("data", (chunk: string) => {
120
+ body += chunk;
121
+ });
122
+
123
+ req.on("end", () => {
124
+ resolve(Ok(body));
125
+ });
126
+
127
+ req.on("error", (err) => resolve(Err(transformError(err))));
128
+ });
129
+ }
130
+ }
131
+
132
+ let COUNTER = 0;
133
+
134
+ export async function http1ServerRun(config: Http1ServerConfig): Promise<Result<StdHttp1Server, HttpServerRunError>> {
135
+ return new Promise((resolve) => {
136
+ async function handler(nativeRequest: IncomingMessage, nativeResponse: ServerResponse) {
137
+ const methodStr = OptionFromNullable(nativeRequest.method).unwrap();
138
+ const fullUrl = nativeRequest.url || "/";
139
+ const isHttps = nativeRequest.socket instanceof TLSSocket;
140
+ const urlObj = new URL(fullUrl, (isHttps ? "http" : "https") + `://${nativeRequest.headers.host}`);
141
+ const path = OptionFromNullable(urlObj.pathname).unwrap();
142
+ const request = {
143
+ path: path.replace(/^\//, '').replace(/\/$/, ''),
144
+ method: methodFromString(methodStr).unwrap(`Error: undefined http method '${methodStr}'`),
145
+ };
146
+
147
+ const routeMatched = config.router.match(request);
148
+
149
+ let context = {
150
+ request: new HttpRequest({
151
+ method: request.method,
152
+ path: request.path,
153
+ query: () => new StdRecord<QueryParams>(Object.fromEntries(urlObj.searchParams.entries())),
154
+ headers: new RequestHttpHeaders(nativeRequest.headers as any),
155
+ buffer: readRequestBodyAsBuffer(nativeRequest),
156
+ body: readRawBody(nativeRequest),
157
+ json: readRequestJson(nativeRequest),
158
+ form: readRequestInput(nativeRequest),
159
+ }),
160
+ response: nativeResponse,
161
+ pathParams: routeMatched.pathParams,
162
+ };
163
+
164
+ let response;
165
+ try {
166
+ response = await routeMatched.action(context)
167
+ } catch (error) {
168
+ response = await config.errorHandler(context, error);
169
+ }
170
+
171
+
172
+ if (isOption(response)) {
173
+ response = response.someOr("");
174
+ } else if(isResult(response)) {
175
+ response = response.unsafeSource();
176
+ }
177
+
178
+ if (isNull(response)) {
179
+ return;
180
+ }
181
+
182
+ const handlers = new Map();
183
+
184
+ handlers.set(resp => isString(resp) || resp instanceof Buffer, (response) => {
185
+ nativeResponse.writeHead(200, {
186
+ "Content-Type": "text/html; charset=utf-8",
187
+ });
188
+ nativeResponse.end(response);
189
+ });
190
+
191
+ handlers.set(resp => resp instanceof HttpResponse, (response: HttpResponse) => {
192
+ nativeResponse.statusCode = response.status;
193
+ nativeResponse.statusMessage = response.statusText;
194
+ nativeResponse.setHeaders(response.headers.unsafeSource());
195
+ nativeResponse.end(response.body.someOr(""));
196
+ });
197
+
198
+ handlers.set(resp => resp instanceof StdError, (error: StdError) => {
199
+ nativeResponse.statusCode = 500;
200
+ nativeResponse.statusMessage = error.message;
201
+ nativeResponse.end(error.message);
202
+ });
203
+
204
+ handlers.set(
205
+ (response) => isObject(response) || isArray(response) || asList(response) || asRecord(response) || asMap(response),
206
+ (response) => {
207
+ //TODO: return respons on top instead of changed nativeResponse inner
208
+ nativeResponse.setHeader("Content-Type", "application/json; charset=utf-8" );
209
+
210
+ try {
211
+ const resp = StdJson.toString(response).match({
212
+ Ok: (jsonStr) => ({status: 200, body: jsonStr }),
213
+ Err: (err) => ({status: 500 , body: '{"error":"Internal Server Error"}' }),
214
+ });
215
+ nativeResponse.statusCode = resp.status;
216
+ nativeResponse.end(resp.body);
217
+ } catch (err) {
218
+ nativeResponse.statusCode = 500;
219
+ nativeResponse.end('{"error":"Internal Server Error"}');
220
+ }
221
+ }
222
+ );
223
+
224
+
225
+ matchUnknown(response, handlers, () => {
226
+ nativeResponse.writeHead(500, { "Content-Type": "text/html; charset=utf-8" });
227
+ nativeResponse.end("Internal Server Error");
228
+ }, false);
229
+ }
230
+
231
+ const server = config.cert.match({
232
+ Some: cert => {
233
+ return https.createServer({
234
+ key: fs.readFileSync(cert.key),
235
+ cert: fs.readFileSync(cert.cert),
236
+ }, handler);
237
+ },
238
+ None: () => {
239
+ return http.createServer(handler);
240
+ }
241
+ })
242
+
243
+
244
+ server.once('error', (nativeError: any) => {
245
+ log("Server started on", nativeError);
246
+ resolve(Err({
247
+ errno: OptionFromNullable(nativeError.errno),
248
+ code: OptionFromNullable(nativeError.code),
249
+ syscall: OptionFromNullable(nativeError.syscall),
250
+ path: OptionFromNullable(nativeError.path),
251
+ port: OptionFromNullable(nativeError.port),
252
+ address: OptionFromNullable(nativeError.address),
253
+ }));
254
+ });
255
+
256
+ server.listen(config.port, () => {
257
+ log("Server started on " + config.protocol + "://" + config.host + ":" + config.port)
258
+
259
+ resolve(Ok(new StdHttp1Server(server)));
260
+ });
261
+
262
+ });
263
+ }
@@ -0,0 +1,125 @@
1
+ import http2, { Http2SecureServer, Http2Server, IncomingHttpHeaders, ServerHttp2Stream } from 'node:http2';
2
+ import fs from 'node:fs';
3
+
4
+ import { methodFromString } from "#lib/router.js";
5
+ import { isArray, isObject, isString, match, matchUnknown, OptionFromNullable, Some, Result, Option, Err, Ok, isNull, transformError, StdError, dump, HttpResponse, StdJson } from '@smuzi/std';
6
+ import { HttpServer, HttpServerRunError, Http2ServerConfig } from "#lib/index.js";
7
+
8
+ type NativeServer = Http2SecureServer | Http2Server;
9
+
10
+ export class StdHttp2Server implements HttpServer {
11
+ readonly #server: NativeServer
12
+
13
+ constructor(server: NativeServer) {
14
+ this.#server = server;
15
+ }
16
+ async close(): Promise<Result<boolean, StdError>> {
17
+ return new Promise(resolve => {
18
+ this.#server.close((err) => {
19
+ return resolve(isNull(err) ? Ok(true) : Err(transformError(err)));
20
+ })
21
+ })
22
+ }
23
+ }
24
+
25
+ export function http2ServerRun(config: Http2ServerConfig): Promise<Result<StdHttp2Server, HttpServerRunError>> {
26
+ return new Promise((resolve) => {
27
+
28
+ const server = config.cert.match({
29
+ Some: cert => {
30
+ return http2.createSecureServer({
31
+ key: fs.readFileSync(cert.key),
32
+ cert: fs.readFileSync(cert.cert),
33
+ });
34
+ },
35
+ None: () => {
36
+ return http2.createServer();
37
+ }
38
+ })
39
+
40
+
41
+ server.once('error', (nativeError) => {
42
+ resolve(Err({
43
+ errno: OptionFromNullable(nativeError.errno),
44
+ code: OptionFromNullable(nativeError.code),
45
+ syscall: OptionFromNullable(nativeError.syscall),
46
+ path: OptionFromNullable(nativeError.path),
47
+ port: OptionFromNullable(nativeError.port),
48
+ address: OptionFromNullable(nativeError.address),
49
+ }));
50
+ });
51
+
52
+ server.on('stream', (stream: ServerHttp2Stream, headers: IncomingHttpHeaders) => {
53
+ const methodStr = OptionFromNullable(headers[':method']).unwrap();
54
+ const path = OptionFromNullable(headers[':path']).unwrap().replace(/^\//, '').replace(/\/$/, '');
55
+ const urlObj = new URL(path, `http://${headers[':authority']}`);
56
+
57
+ const request = {
58
+ path: path,
59
+ method: methodFromString(methodStr).unwrap(`Error: undefined http method '${methodStr}'`),
60
+ query: urlObj.searchParams,
61
+ };
62
+
63
+ const response = config.router.match(request)
64
+ const handlers = new Map();
65
+
66
+
67
+ handlers.set(isString, (response) => {
68
+ stream.respond({
69
+ 'content-type': 'text/html; charset=utf-8',
70
+ ':status': 200,
71
+ });
72
+ stream.end(response)
73
+ });
74
+
75
+ handlers.set(resp => resp instanceof HttpResponse, (response: HttpResponse) => {
76
+ stream.respond({
77
+ 'content-type': 'application/json; charset=utf-8',
78
+ ':status': response.status,
79
+
80
+ });
81
+ });
82
+
83
+
84
+
85
+ handlers.set(response => isObject(response) || isArray(response), (response) => {
86
+ stream.respond({
87
+ 'content-type': 'application/json; charset=utf-8',
88
+ ':status': 200,
89
+ });
90
+
91
+ stream.end(StdJson.toString(response).match({
92
+ Ok: (json) => json,
93
+ Err: (err) => {
94
+ stream.respond({
95
+ 'content-type': 'application/json; charset=utf-8',
96
+ ':status': 500,
97
+ });
98
+
99
+ return `{"error":"Internal Server Error"}`;
100
+ }
101
+ }));
102
+
103
+ });
104
+
105
+
106
+ matchUnknown(
107
+ response,
108
+ handlers,
109
+ _ => {
110
+ stream.respond({
111
+ ':status': 500,
112
+ })
113
+ stream.end('Internal Server Error');
114
+ },
115
+ false
116
+ )
117
+
118
+ });
119
+
120
+ server.listen(config.port, () => {
121
+ resolve(Ok(new StdHttp2Server(server)));
122
+ });
123
+
124
+ });
125
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from "#lib/drivers/http2Server.js";
2
+ export * from "#lib/drivers/http1Server.js";
3
+ export * from "#lib/config.js" ;
4
+ export * from "./router.js" ;
5
+ export * from "#lib/types.js" ;
package/src/router.ts ADDED
@@ -0,0 +1,257 @@
1
+ import {
2
+ asRegExp,
3
+ asString,
4
+ match,
5
+ MatchedData,
6
+ None,
7
+ Option,
8
+ Some,
9
+ HttpMethod,
10
+ HttpRequest,
11
+ Result,
12
+ HttpResponse,
13
+ StdError,
14
+ StdMap, StdRecord, StdList,
15
+ } from "@smuzi/std";
16
+ import { ServerResponse } from "node:http";
17
+ import { ServerHttp2Stream } from "node:http2";
18
+
19
+
20
+ type Request = { path: string, method: HttpMethod };
21
+ type P = any;
22
+ type ActionPrimitiveResponse = P | StdMap<string> | StdRecord<any> | StdList | Record<PropertyKey, P> | Record<PropertyKey, P>[];
23
+ export type ActionResponse = void | ActionPrimitiveResponse | HttpResponse | Option<ActionPrimitiveResponse> | Result<ActionPrimitiveResponse, ActionPrimitiveResponse>;
24
+
25
+ export type Action<Resp extends THttpResponse> = (context: Context<Resp>) => ActionResponse | Promise<ActionResponse>
26
+ export type PathParam = string | RegExp;
27
+ export type ActionErrorHandler<Resp extends THttpResponse> = (context: Context<Resp>, err: any) => ActionResponse | Promise<ActionResponse>
28
+
29
+ type THttpResponse = ServerResponse | ServerHttp2Stream
30
+ type Route = { path: PathParam, method: HttpMethod };
31
+ type GroupRoute = { path: PathParam };
32
+ type RouteMatched = MatchedData<Request, Option<{ path: Record<string, string> }>>
33
+ type RouteMatchResult<Resp extends THttpResponse> = {
34
+ action: Action<Resp>,
35
+ pathParams: Option<Record<string, string | number | boolean>>
36
+ }
37
+
38
+ export type Router<Resp extends THttpResponse, A = Action<Resp>> = {
39
+ group: (groupRouter: Router<Resp>) => void
40
+ getMapRoutes: () => Map<Route, (routeData: RouteMatched) => RouteMatchResult<Resp>>
41
+ getGroupRoute(): GroupRoute
42
+ get: (path: PathParam, action: A) => void
43
+ post: (path: PathParam, action: A) => void
44
+ put: (path: PathParam, action: A) => void
45
+ delete: (path: PathParam, action: A) => void
46
+ match: (request: Request) => RouteMatchResult<Resp>
47
+ }
48
+
49
+ export type Http1Router = Router<ServerResponse>;
50
+ export type Http2Router = Router<ServerHttp2Stream>;
51
+
52
+ export type Context<Resp extends THttpResponse, Params = unknown,> = {
53
+ request: HttpRequest,
54
+ response: Resp,
55
+ pathParams: Params,
56
+ }
57
+
58
+ export function processPath(path: PathParam): PathParam {
59
+ if (!asString(path)) return path;
60
+ if (! /\{[a-zA-Z0-9_]+\}/g.test(path)) return path;
61
+
62
+ const pattern = `^${path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_, name) => `(?<${name}>[^/]+)`).replace(/\//g, '\\/')}$`;
63
+ return new RegExp(pattern);
64
+
65
+ }
66
+
67
+ export function contactPaths(path1: PathParam, path2: PathParam): PathParam | never {
68
+ const path1AsRegExp = asRegExp(path1);
69
+ const path2AsRegExp = asRegExp(path2);
70
+
71
+ if (path1AsRegExp || path2AsRegExp) {
72
+ const source1 = path1AsRegExp
73
+ ? path1.source
74
+ : path1;
75
+
76
+ const source2 = path2AsRegExp
77
+ ? path2.source
78
+ : path2;
79
+
80
+ return new RegExp(
81
+ "^"+(source1 + source2).replaceAll('^', '')
82
+ );
83
+ }
84
+
85
+ return path1 + path2;
86
+ }
87
+
88
+
89
+ export function toStartWithPattern(input: PathParam): RegExp {
90
+ if (asString(input)) {
91
+ const escaped = input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
92
+ return new RegExp(`^${escaped}.*`);
93
+ }
94
+
95
+ let pattern = input.source;
96
+
97
+ if (pattern.endsWith('$')) {
98
+ pattern = pattern.slice(0, -1);
99
+ }
100
+
101
+ return new RegExp(`${pattern}.*`, input.flags);
102
+ }
103
+
104
+
105
+ export function methodFromString(method: string): Option<HttpMethod> {
106
+ const handers = new Map<string, Option<HttpMethod>>([
107
+ ['GET', Some(HttpMethod.GET)],
108
+ ['POST', Some(HttpMethod.POST)],
109
+ ['PUT', Some(HttpMethod.PUT)],
110
+ ['DELETE', Some(HttpMethod.DELETE)],
111
+ ]);
112
+
113
+ return match(
114
+ method,
115
+ handers,
116
+ None(),
117
+ false
118
+ );
119
+ }
120
+
121
+ function http1NotFoundHandler(context: Context<ServerResponse>) {
122
+ return HttpResponse.asJson({error:"Not Found"}, 404);
123
+ }
124
+
125
+
126
+ function http2NotFoundHandler(context: Context<ServerHttp2Stream>) {
127
+ context.response.respond({
128
+ 'content-type': 'application/json; charset=utf-8',
129
+ ':status': 404,
130
+ });
131
+ context.response.end();
132
+
133
+ }
134
+
135
+
136
+
137
+ // Internal-only surface: lets a parent router recompute a child router's
138
+ // (and, recursively, its own nested groups') absolute paths after nesting.
139
+ // Not part of the public `Router` type — accessed via a narrow cast.
140
+ type Rebasable = { __rebase: (newBase: PathParam) => void };
141
+
142
+ export function CreateHttpRouter<Resp extends THttpResponse, GR extends Router<Resp>>(
143
+ groupRoute: GroupRoute,
144
+ notFound: Action<Resp>,
145
+ ): Router<Resp> {
146
+ const routes = new Map()
147
+
148
+ // Every own route/subgroup is registered ONCE at startup. We additionally
149
+ // remember each one's path *relative to this router* so that, if this
150
+ // router later gets attached to a parent via `.group()`, we can recompute
151
+ // every already-registered absolute path exactly once at that moment —
152
+ // never again on every match()/request.
153
+ const ownRoutes: { route: Route; localPath: PathParam }[] = [];
154
+ const ownGroups: { route: GroupRoute; localGroupPath: PathParam; childRouter: Router<any> }[] = [];
155
+
156
+ const computeAbsolute = (localPath: PathParam) =>
157
+ processPath(contactPaths(groupRoute.path, localPath));
158
+
159
+ const add = (route: Route, action: any) => {
160
+ const localPath = route.path;
161
+ route.path = computeAbsolute(localPath);
162
+ ownRoutes.push({ route, localPath });
163
+
164
+ routes.set(route, (routeData: RouteMatched) => {
165
+ return {
166
+ action,
167
+ pathParams: routeData.params.flatByKey("path"),
168
+ };
169
+ })
170
+ };
171
+
172
+ const addGroup = (localGroupPath: PathParam, childRouter: Router<any>, action: any) => {
173
+ const route: GroupRoute = { path: computeAbsolute(toStartWithPattern(localGroupPath)) };
174
+ ownGroups.push({ route, localGroupPath, childRouter });
175
+ routes.set(route, action)
176
+ };
177
+
178
+ // Recomputes this router's own base plus every route/subgroup registered
179
+ // on it so far, then recurses into any already-nested group routers so
180
+ // the whole subtree stays consistent — called once, when this router is
181
+ // attached to a parent via `.group()`.
182
+ const rebase = (newBase: PathParam) => {
183
+ groupRoute.path = newBase;
184
+
185
+ for (const entry of ownRoutes) {
186
+ entry.route.path = computeAbsolute(entry.localPath);
187
+ }
188
+
189
+ for (const entry of ownGroups) {
190
+ entry.route.path = computeAbsolute(toStartWithPattern(entry.localGroupPath));
191
+ (entry.childRouter as unknown as Rebasable).__rebase(
192
+ contactPaths(newBase, entry.localGroupPath)
193
+ );
194
+ }
195
+ };
196
+
197
+ const router: Router<Resp> & Rebasable = {
198
+ get(path, action) {
199
+ add({ path, method: HttpMethod.GET }, action)
200
+ },
201
+ post(path, action) {
202
+ add({ path, method: HttpMethod.POST }, action)
203
+ },
204
+ put(path, action) {
205
+ add({ path, method: HttpMethod.PUT }, action)
206
+ },
207
+ delete(path, action) {
208
+ add({ path, method: HttpMethod.DELETE }, action)
209
+ },
210
+ group(groupRouter: GR) {
211
+ const localGroupPath = groupRouter.getGroupRoute().path;
212
+
213
+ addGroup(localGroupPath, groupRouter, (routeData: RouteMatched) => {
214
+ return groupRouter.match(routeData.val);
215
+ });
216
+
217
+ // Re-derive every path already registered on the nested router
218
+ // (and anything nested under IT) against our current absolute base.
219
+ (groupRouter as unknown as Rebasable).__rebase(
220
+ contactPaths(groupRoute.path, localGroupPath)
221
+ );
222
+ },
223
+ getMapRoutes() {
224
+ return routes;
225
+ },
226
+ getGroupRoute(): GroupRoute {
227
+ return groupRoute;
228
+ },
229
+ match(request: Request) {
230
+ return match(request, this.getMapRoutes(), (routeData: RouteMatched) => {
231
+ return {
232
+ action: notFound,
233
+ pathParams: routeData.params.flatByKey("path"),
234
+ } as RouteMatchResult<Resp>;
235
+ })
236
+ },
237
+ __rebase: rebase,
238
+ };
239
+
240
+ return router;
241
+ }
242
+
243
+
244
+
245
+ export function CreateHttp1Router(
246
+ groupRoute: GroupRoute,
247
+ notFound: Action<ServerResponse> = http1NotFoundHandler,
248
+ ): Http1Router {
249
+ return CreateHttpRouter<ServerResponse, Http1Router>(groupRoute, notFound);
250
+ }
251
+
252
+ // export function CreateHttp2Router(
253
+ // groupRoute: GroupRoute,
254
+ // notFound: Action<ServerHttp2Stream> = http2NotFoundHandler
255
+ // ): Http2Router {
256
+ // return CreateHttpRouter<ServerHttp2Stream, Http2Router>(groupRoute, notFound);
257
+ // }
package/src/types.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { Option, Result, StdError } from "@smuzi/std";
2
+
3
+ export interface HttpServer {
4
+ close(): Promise<Result<boolean, StdError>>
5
+ }
6
+
7
+ export type HttpServerRunError = {
8
+ errno: Option<number>,
9
+ code: Option<string>,
10
+ syscall: Option<string>,
11
+ path: Option<string>,
12
+ port: Option<string>,
13
+ address: Option<string>,
14
+ };