@thetally/httptree 0.1.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/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/router.d.ts +129 -0
- package/dist/router.js +574 -0
- package/package.json +43 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./router.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./router.js";
|
package/dist/router.d.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import { type WebSocket } from "ws";
|
|
3
|
+
type Flat<T> = {
|
|
4
|
+
[K in keyof T]: T[K];
|
|
5
|
+
} & {};
|
|
6
|
+
type Params<Path extends string> = ParamsInner<Path>;
|
|
7
|
+
type ParamsInner<S extends string> = S extends `[[...${infer P}]]/${infer Rest}` ? {
|
|
8
|
+
[K in P]?: string[];
|
|
9
|
+
} : S extends `[[...${infer P}]]/` ? {
|
|
10
|
+
[K in P]?: string[];
|
|
11
|
+
} : S extends `[[...${infer P}]]` ? {
|
|
12
|
+
[K in P]?: string[];
|
|
13
|
+
} : S extends `[...${infer P}]/${infer Rest}` ? {
|
|
14
|
+
[K in P]: string[];
|
|
15
|
+
} : S extends `[...${infer P}]/` ? {
|
|
16
|
+
[K in P]: string[];
|
|
17
|
+
} : S extends `[...${infer P}]` ? {
|
|
18
|
+
[K in P]: string[];
|
|
19
|
+
} : S extends `[[${infer P}]]/${infer Rest}` ? {
|
|
20
|
+
[K in P]?: string;
|
|
21
|
+
} & ParamsInner<Rest> : S extends `[[${infer P}]]/` ? {
|
|
22
|
+
[K in P]?: string;
|
|
23
|
+
} : S extends `[[${infer P}]]` ? {
|
|
24
|
+
[K in P]?: string;
|
|
25
|
+
} : S extends `[${infer P}]/${infer Rest}` ? {
|
|
26
|
+
[K in P]: string;
|
|
27
|
+
} & ParamsInner<Rest> : S extends `[${infer P}]/` ? {
|
|
28
|
+
[K in P]: string;
|
|
29
|
+
} : S extends `[${infer P}]` ? {
|
|
30
|
+
[K in P]: string;
|
|
31
|
+
} : S extends `${infer _Segment}/${infer Rest}` ? ParamsInner<Rest> : {};
|
|
32
|
+
type Middleware = (req: http.IncomingMessage & {
|
|
33
|
+
params: Record<string, string | undefined>;
|
|
34
|
+
}, res: http.ServerResponse, next: (err?: Error) => void) => void | Promise<void>;
|
|
35
|
+
type PatternInfo = {
|
|
36
|
+
regex: RegExp;
|
|
37
|
+
params: string[];
|
|
38
|
+
score: number;
|
|
39
|
+
};
|
|
40
|
+
type HandlerReturn = TreeResponse | string | Buffer | void | Promise<TreeResponse | string | Buffer | void>;
|
|
41
|
+
type Handler<P extends Record<string, any>> = (req: http.IncomingMessage & {
|
|
42
|
+
params: Flat<P>;
|
|
43
|
+
}, res: http.ServerResponse) => HandlerReturn;
|
|
44
|
+
type WSHandler<P extends Record<string, any> = Record<string, any>> = (ws: WebSocket, req: http.IncomingMessage & {
|
|
45
|
+
params: Flat<P>;
|
|
46
|
+
}) => void | Promise<void>;
|
|
47
|
+
export type UpgradeHandler = (req: http.IncomingMessage, socket: any, head: Buffer) => boolean | void;
|
|
48
|
+
type Precheck = (req: http.IncomingMessage, res: http.ServerResponse) => boolean | Promise<boolean>;
|
|
49
|
+
type Route = {
|
|
50
|
+
method: string;
|
|
51
|
+
info: PatternInfo;
|
|
52
|
+
handler: Handler<any>;
|
|
53
|
+
prechecks: Precheck[];
|
|
54
|
+
};
|
|
55
|
+
type WSRoute = {
|
|
56
|
+
info: PatternInfo;
|
|
57
|
+
handler: WSHandler;
|
|
58
|
+
prechecks: Precheck[];
|
|
59
|
+
};
|
|
60
|
+
declare class Router<P extends Record<string, string | undefined> = {}> {
|
|
61
|
+
protected base: string;
|
|
62
|
+
protected routes: Route[];
|
|
63
|
+
protected wsRoutes: WSRoute[];
|
|
64
|
+
protected inheritedChecks: Precheck[];
|
|
65
|
+
protected root: Router<any>;
|
|
66
|
+
protected errorHandlers: Map<string, (err: any, req: http.IncomingMessage, res: http.ServerResponse) => void>;
|
|
67
|
+
constructor(base?: string, root?: Router<any>, inheritedChecks?: Precheck[]);
|
|
68
|
+
handleError(type: "NotFound" | "InternalError" | "*", handler: (err: any, req: http.IncomingMessage, res: http.ServerResponse) => void): this;
|
|
69
|
+
branch<Path extends string>(path: Path, check?: Precheck): Router<P & Params<Path>>;
|
|
70
|
+
get<Path extends string>(path: Path, h: Handler<Flat<P & Params<Path>>>): this;
|
|
71
|
+
post<Path extends string>(path: Path, h: Handler<Flat<P & Params<Path>>>): this;
|
|
72
|
+
put<Path extends string>(path: Path, h: Handler<Flat<P & Params<Path>>>): this;
|
|
73
|
+
delete<Path extends string>(path: Path, h: Handler<Flat<P & Params<Path>>>): this;
|
|
74
|
+
all<Path extends string>(path: Path, h: Handler<Flat<P & Params<Path>>>): this;
|
|
75
|
+
ws<Path extends string>(path: Path, h: WSHandler<Flat<P & Params<Path>>>): this;
|
|
76
|
+
handle: (req: http.IncomingMessage & {
|
|
77
|
+
params: Record<string, string | string[] | undefined>;
|
|
78
|
+
}, res: http.ServerResponse, next: (err?: Error) => void) => Promise<void>;
|
|
79
|
+
handleWS: (ws: WebSocket, req: http.IncomingMessage) => Promise<void>;
|
|
80
|
+
protected add(method: string, path: string, handler: Handler<any>): this;
|
|
81
|
+
}
|
|
82
|
+
export declare class BaseRouter extends Router {
|
|
83
|
+
#private;
|
|
84
|
+
handleError(type: string, handler: (err: any, req: http.IncomingMessage, res: http.ServerResponse) => void): this;
|
|
85
|
+
listen(port: number, cb?: () => void): Promise<void> | import('http').Server;
|
|
86
|
+
stop(): Promise<void>;
|
|
87
|
+
use(fn: Middleware, priority?: number): void;
|
|
88
|
+
useUpgrade(fn: UpgradeHandler, priority?: number): void;
|
|
89
|
+
}
|
|
90
|
+
export declare function root(base?: string): BaseRouter;
|
|
91
|
+
export declare const responseSymbol: unique symbol;
|
|
92
|
+
export interface TreeResponse {
|
|
93
|
+
[responseSymbol]: true;
|
|
94
|
+
body: string | Buffer;
|
|
95
|
+
status: number;
|
|
96
|
+
headers: Record<string, string>;
|
|
97
|
+
}
|
|
98
|
+
export declare function json(data: unknown, opts?: {
|
|
99
|
+
status?: number;
|
|
100
|
+
headers?: Record<string, string>;
|
|
101
|
+
}): TreeResponse;
|
|
102
|
+
export declare function text(body: string, opts?: {
|
|
103
|
+
status?: number;
|
|
104
|
+
headers?: Record<string, string>;
|
|
105
|
+
}): TreeResponse;
|
|
106
|
+
export declare function redirect(location: string, status?: 301 | 302 | 303 | 307 | 308): never;
|
|
107
|
+
declare function _error(status: number, body?: string | object, headers?: Record<string, string>): never;
|
|
108
|
+
declare const error: typeof _error & {
|
|
109
|
+
badRequest: (body?: string | object, headers?: Record<string, string>) => never;
|
|
110
|
+
unauthorized: (body?: string | object, headers?: Record<string, string>) => never;
|
|
111
|
+
forbidden: (body?: string | object, headers?: Record<string, string>) => never;
|
|
112
|
+
notFound: (body?: string | object, headers?: Record<string, string>) => never;
|
|
113
|
+
conflict: (body?: string | object, headers?: Record<string, string>) => never;
|
|
114
|
+
unprocessableEntity: (body?: string | object, headers?: Record<string, string>) => never;
|
|
115
|
+
tooManyRequests: (body?: string | object, headers?: Record<string, string>) => never;
|
|
116
|
+
internalServerError: (body?: string | object, headers?: Record<string, string>) => never;
|
|
117
|
+
badGateway: (body?: string | object, headers?: Record<string, string>) => never;
|
|
118
|
+
serviceUnavailable: (body?: string | object, headers?: Record<string, string>) => never;
|
|
119
|
+
};
|
|
120
|
+
export { error };
|
|
121
|
+
export declare function raw(body: Buffer, opts?: {
|
|
122
|
+
status?: number;
|
|
123
|
+
headers?: Record<string, string>;
|
|
124
|
+
}): TreeResponse;
|
|
125
|
+
export declare function file(body: Buffer, filename: string, contentType?: string): TreeResponse;
|
|
126
|
+
export declare function html(body: string, opts?: {
|
|
127
|
+
status?: number;
|
|
128
|
+
headers?: Record<string, string>;
|
|
129
|
+
}): TreeResponse;
|
package/dist/router.js
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
import http from "http";
|
|
2
|
+
import url from "url";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
const debug = (...args) => {
|
|
5
|
+
if (typeof process !== "undefined")
|
|
6
|
+
if (process.env?.HTTPTREE_DEBUG) {
|
|
7
|
+
console.log("[httptree]", ...args);
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
const _require = (typeof require !== "undefined")
|
|
11
|
+
? require
|
|
12
|
+
: createRequire(import.meta.url);
|
|
13
|
+
let WebSocketServer;
|
|
14
|
+
function getWebSocketServer() {
|
|
15
|
+
if (WebSocketServer)
|
|
16
|
+
return WebSocketServer;
|
|
17
|
+
try {
|
|
18
|
+
const mod = _require("ws");
|
|
19
|
+
WebSocketServer = mod.WebSocketServer;
|
|
20
|
+
return WebSocketServer;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
const middlewares = [];
|
|
27
|
+
let _regIdx = 0;
|
|
28
|
+
function use(fn, priority = 0) {
|
|
29
|
+
debug(`Registering middleware with priority=${priority}, idx=${_regIdx}`);
|
|
30
|
+
middlewares.push({ fn, priority, idx: _regIdx++ });
|
|
31
|
+
}
|
|
32
|
+
function runMiddlewares(req, res, done) {
|
|
33
|
+
const reqWithParams = req;
|
|
34
|
+
if (!reqWithParams.params)
|
|
35
|
+
reqWithParams.params = {};
|
|
36
|
+
const list = [...middlewares].sort((a, b) => b.priority - a.priority || a.idx - b.idx);
|
|
37
|
+
let idx = 0;
|
|
38
|
+
function next(err) {
|
|
39
|
+
if (err)
|
|
40
|
+
return done(err);
|
|
41
|
+
const entry = list[idx++];
|
|
42
|
+
if (!entry)
|
|
43
|
+
return done();
|
|
44
|
+
debug(`Running middleware idx=${entry.idx}, priority=${entry.priority}`);
|
|
45
|
+
try {
|
|
46
|
+
const ret = entry.fn(reqWithParams, res, next);
|
|
47
|
+
if (ret && typeof ret.then === "function") {
|
|
48
|
+
ret.catch((e) => next(e instanceof Error ? e : new Error(String(e))));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
next(e instanceof Error ? e : new Error(String(e)));
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
next();
|
|
56
|
+
}
|
|
57
|
+
const upgradeHandlers = [];
|
|
58
|
+
let _upgradeIdx = 0;
|
|
59
|
+
function useUpgrade(fn, priority = 0) {
|
|
60
|
+
upgradeHandlers.push({ fn, priority, idx: _upgradeIdx++ });
|
|
61
|
+
}
|
|
62
|
+
function escapeRE(s) {
|
|
63
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
64
|
+
}
|
|
65
|
+
function compilePattern(path) {
|
|
66
|
+
if (!path.startsWith("/"))
|
|
67
|
+
path = "/" + path;
|
|
68
|
+
if (path !== "/" && path.endsWith("/"))
|
|
69
|
+
path = path.slice(0, -1);
|
|
70
|
+
const parts = path.split("/").slice(1).filter(Boolean);
|
|
71
|
+
const params = [];
|
|
72
|
+
let re = "^";
|
|
73
|
+
let score = 0;
|
|
74
|
+
for (const seg of parts) {
|
|
75
|
+
if (seg.startsWith("[...") && seg.endsWith("]")) {
|
|
76
|
+
params.push("..." + seg.slice(4, -1));
|
|
77
|
+
re += "(?:/(.*))?";
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (seg.startsWith("[[") && seg.endsWith("]]")) {
|
|
81
|
+
params.push(seg.slice(2, -2));
|
|
82
|
+
re += "(?:/([^/]+))?";
|
|
83
|
+
score += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (seg.startsWith("[") && seg.endsWith("]")) {
|
|
87
|
+
params.push(seg.slice(1, -1));
|
|
88
|
+
re += "/([^/]+)";
|
|
89
|
+
score += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
re += "/" + escapeRE(seg);
|
|
93
|
+
score += 10;
|
|
94
|
+
}
|
|
95
|
+
if (parts.length === 0)
|
|
96
|
+
re += "/?";
|
|
97
|
+
re += "/?$";
|
|
98
|
+
return { regex: new RegExp(re), params, score };
|
|
99
|
+
}
|
|
100
|
+
function finishResponse(res, result) {
|
|
101
|
+
if (res.writableEnded)
|
|
102
|
+
return;
|
|
103
|
+
if (!result) {
|
|
104
|
+
res.statusCode = 204;
|
|
105
|
+
res.end();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (result[responseSymbol]) {
|
|
109
|
+
const r = result;
|
|
110
|
+
res.statusCode = r.status;
|
|
111
|
+
for (const [k, v] of Object.entries(r.headers || {})) {
|
|
112
|
+
try {
|
|
113
|
+
res.setHeader(k, v);
|
|
114
|
+
}
|
|
115
|
+
catch { }
|
|
116
|
+
}
|
|
117
|
+
res.end(r.body);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (typeof result === "string" || Buffer.isBuffer(result)) {
|
|
121
|
+
res.statusCode = 200;
|
|
122
|
+
res.end(result);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
res.statusCode = 204;
|
|
126
|
+
res.end();
|
|
127
|
+
}
|
|
128
|
+
class Router {
|
|
129
|
+
base;
|
|
130
|
+
routes;
|
|
131
|
+
wsRoutes;
|
|
132
|
+
inheritedChecks;
|
|
133
|
+
root;
|
|
134
|
+
errorHandlers;
|
|
135
|
+
constructor(base = "/", root, inheritedChecks = []) {
|
|
136
|
+
this.base = normalize(base);
|
|
137
|
+
this.root = root ?? this;
|
|
138
|
+
this.routes = root ? root.routes : [];
|
|
139
|
+
this.wsRoutes = root ? root.wsRoutes : [];
|
|
140
|
+
this.inheritedChecks = inheritedChecks;
|
|
141
|
+
this.errorHandlers = new Map();
|
|
142
|
+
this.parent = undefined;
|
|
143
|
+
if (!root) {
|
|
144
|
+
this.branches = [];
|
|
145
|
+
use(this.handle);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
handleError(type, handler) {
|
|
149
|
+
debug(`Registering error handler for type '${type}' on base '${this.base}'`);
|
|
150
|
+
this.errorHandlers.set(type, handler);
|
|
151
|
+
return this;
|
|
152
|
+
}
|
|
153
|
+
branch(path, check) {
|
|
154
|
+
let full = join(this.base, path);
|
|
155
|
+
debug(`Creating branch: parent base='${this.base}', requested path='${path}', full before normalization='${full}'`);
|
|
156
|
+
const normalizedFull = normalize(full);
|
|
157
|
+
debug(`Normalized full path for branch: '${normalizedFull}'`);
|
|
158
|
+
const checks = [...this.inheritedChecks];
|
|
159
|
+
if (check)
|
|
160
|
+
checks.push(check);
|
|
161
|
+
const branch = new Router(normalizedFull, this.root, checks);
|
|
162
|
+
branch.parent = this;
|
|
163
|
+
if (!this.branches)
|
|
164
|
+
this.branches = [];
|
|
165
|
+
this.branches.push(branch);
|
|
166
|
+
debug(`Created branch with base '${branch.base}' under parent base '${this.base}'`);
|
|
167
|
+
return branch;
|
|
168
|
+
}
|
|
169
|
+
get(path, h) {
|
|
170
|
+
debug(`Registering GET route: base='${this.base}', path='${path}'`);
|
|
171
|
+
return this.add("GET", path, h);
|
|
172
|
+
}
|
|
173
|
+
post(path, h) {
|
|
174
|
+
debug(`Registering POST route: base='${this.base}', path='${path}'`);
|
|
175
|
+
return this.add("POST", path, h);
|
|
176
|
+
}
|
|
177
|
+
put(path, h) {
|
|
178
|
+
debug(`Registering PUT route: base='${this.base}', path='${path}'`);
|
|
179
|
+
return this.add("PUT", path, h);
|
|
180
|
+
}
|
|
181
|
+
delete(path, h) {
|
|
182
|
+
debug(`Registering DELETE route: base='${this.base}', path='${path}'`);
|
|
183
|
+
return this.add("DELETE", path, h);
|
|
184
|
+
}
|
|
185
|
+
all(path, h) {
|
|
186
|
+
debug(`Registering ALL route: base='${this.base}', path='${path}'`);
|
|
187
|
+
return this.add("*", path, h);
|
|
188
|
+
}
|
|
189
|
+
ws(path, h) {
|
|
190
|
+
debug(`Registering WS route: base='${this.base}', path='${path}'`);
|
|
191
|
+
const WSSCtor = getWebSocketServer();
|
|
192
|
+
if (!WSSCtor) {
|
|
193
|
+
throw new Error("WebSocket support requires the 'ws' package. Please install it first.");
|
|
194
|
+
}
|
|
195
|
+
const full = join(this.base, path);
|
|
196
|
+
const info = compilePattern(full);
|
|
197
|
+
this.wsRoutes.push({
|
|
198
|
+
info,
|
|
199
|
+
handler: h,
|
|
200
|
+
prechecks: [...this.inheritedChecks],
|
|
201
|
+
});
|
|
202
|
+
this.wsRoutes.sort((a, b) => b.info.score - a.info.score);
|
|
203
|
+
return this;
|
|
204
|
+
}
|
|
205
|
+
handle = async (req, res, next) => {
|
|
206
|
+
const pathname = url.parse(req.url || "/").pathname || "/";
|
|
207
|
+
const method = (req.method || "GET").toUpperCase();
|
|
208
|
+
debug(`Handling request: method='${method}', path='${pathname}'`);
|
|
209
|
+
let matchedRoute = null;
|
|
210
|
+
for (const r of this.routes) {
|
|
211
|
+
if (r.method !== "*" && r.method !== method)
|
|
212
|
+
continue;
|
|
213
|
+
const m = r.info.regex.exec(pathname);
|
|
214
|
+
if (!m)
|
|
215
|
+
continue;
|
|
216
|
+
debug(`Route matched: method='${r.method}', regex='${r.info.regex}', params=[${r.info.params.join(", ")}]`);
|
|
217
|
+
matchedRoute = r;
|
|
218
|
+
const params = {};
|
|
219
|
+
r.info.params.forEach((p, i) => {
|
|
220
|
+
debug(`Extracting param '${p}' from segment '${m[i + 1]}'`);
|
|
221
|
+
if (p.startsWith("...")) {
|
|
222
|
+
const val = m[i + 1] ? decodeURIComponent(m[i + 1]) : undefined;
|
|
223
|
+
params[p.replace(/^\.\.\./, "")] = val !== undefined ? val.split("/").filter(Boolean) : [];
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
params[p] = m[i + 1] ? decodeURIComponent(m[i + 1]) : undefined;
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
for (const chk of r.prechecks) {
|
|
230
|
+
debug(`Running precheck for route: method='${r.method}', path regex='${r.info.regex}'`);
|
|
231
|
+
const ok = await chk(req, res);
|
|
232
|
+
if (!ok) {
|
|
233
|
+
debug(`Precheck failed for route: method='${r.method}', path regex='${r.info.regex}'`);
|
|
234
|
+
res.statusCode = 403;
|
|
235
|
+
res.end("Forbidden");
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
req.params = params;
|
|
241
|
+
debug(`Invoking handler for route: method='${r.method}', path regex='${r.info.regex}'`);
|
|
242
|
+
const result = await r.handler(req, res);
|
|
243
|
+
if (result && typeof result === "object" && result[responseSymbol]) {
|
|
244
|
+
finishResponse(res, result);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (!res.writableEnded) {
|
|
248
|
+
res.end();
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
catch (e) {
|
|
253
|
+
if (e && typeof e === "object" && e[responseSymbol]) {
|
|
254
|
+
finishResponse(res, e);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
let errType = (e && typeof e === "object" && e.name) || (e && typeof e === "object" && e.constructor && e.constructor.name) || typeof e;
|
|
258
|
+
let lastError = e;
|
|
259
|
+
let current = this;
|
|
260
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
261
|
+
debug(`Error thrown: type='${errType}' on base='${this.base}'`);
|
|
262
|
+
}
|
|
263
|
+
while (current) {
|
|
264
|
+
const handler = current.errorHandlers.get(String(errType)) || current.errorHandlers.get("*");
|
|
265
|
+
if (handler) {
|
|
266
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
267
|
+
debug(`Using error handler for type='${errType}' on base='${current.base}'`);
|
|
268
|
+
}
|
|
269
|
+
try {
|
|
270
|
+
handler(lastError, req, res);
|
|
271
|
+
}
|
|
272
|
+
catch (err) {
|
|
273
|
+
lastError = err;
|
|
274
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
275
|
+
debug(`Handler on base='${current.base}' threw, trying parent`);
|
|
276
|
+
}
|
|
277
|
+
current = current.parent;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
else if (process.env.HTTPTREE_DEBUG) {
|
|
283
|
+
debug(`No handler for type='${errType}' on base='${current.base}', moving to parent`);
|
|
284
|
+
}
|
|
285
|
+
current = current.parent;
|
|
286
|
+
}
|
|
287
|
+
if (!res.writableEnded) {
|
|
288
|
+
res.statusCode = 500;
|
|
289
|
+
res.end("Internal Server Error");
|
|
290
|
+
}
|
|
291
|
+
if (lastError &&
|
|
292
|
+
((typeof lastError !== "object" || !('suppressLog' in lastError)) &&
|
|
293
|
+
!(lastError && typeof lastError === "object" && lastError.name === "NotFound"))) {
|
|
294
|
+
console.error("Uncaught handler error:", lastError);
|
|
295
|
+
}
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (!res.writableEnded) {
|
|
300
|
+
debug(`No route matched for method='${method}', path='${pathname}'. Resolving most specific branch.`);
|
|
301
|
+
let mostSpecific = this;
|
|
302
|
+
let current = this;
|
|
303
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
304
|
+
debug(`NotFound: resolving most specific branch for path='${pathname}'`);
|
|
305
|
+
}
|
|
306
|
+
while (true) {
|
|
307
|
+
let found = false;
|
|
308
|
+
if (current.branches) {
|
|
309
|
+
let bestBranch = null;
|
|
310
|
+
let bestLen = -1;
|
|
311
|
+
for (const branch of current.branches) {
|
|
312
|
+
if (pathname.startsWith(branch.base) && branch.base.length > bestLen) {
|
|
313
|
+
bestBranch = branch;
|
|
314
|
+
bestLen = branch.base.length;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (bestBranch) {
|
|
318
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
319
|
+
debug(`Path '${pathname}' matches branch base='${bestBranch.base}'`);
|
|
320
|
+
}
|
|
321
|
+
mostSpecific = bestBranch;
|
|
322
|
+
current = bestBranch;
|
|
323
|
+
found = true;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!found)
|
|
327
|
+
break;
|
|
328
|
+
}
|
|
329
|
+
let errType = "NotFound";
|
|
330
|
+
let lastError = Object.assign(new Error("Not Found"), { name: "NotFound" });
|
|
331
|
+
current = mostSpecific;
|
|
332
|
+
while (current) {
|
|
333
|
+
const handler = current.errorHandlers.get(errType) || current.errorHandlers.get("*");
|
|
334
|
+
if (handler) {
|
|
335
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
336
|
+
debug(`Using NotFound handler on base='${current.base}' for path='${pathname}'`);
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
handler(lastError, req, res);
|
|
340
|
+
}
|
|
341
|
+
catch (err) {
|
|
342
|
+
lastError = err;
|
|
343
|
+
if (process.env.HTTPTREE_DEBUG) {
|
|
344
|
+
debug(`NotFound handler on base='${current.base}' threw, trying parent`);
|
|
345
|
+
}
|
|
346
|
+
current = current.parent;
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
else if (process.env.HTTPTREE_DEBUG) {
|
|
352
|
+
debug(`No NotFound handler on base='${current.base}', moving to parent`);
|
|
353
|
+
}
|
|
354
|
+
current = current.parent;
|
|
355
|
+
}
|
|
356
|
+
if (!res.writableEnded) {
|
|
357
|
+
res.statusCode = 404;
|
|
358
|
+
res.end("Not Found");
|
|
359
|
+
}
|
|
360
|
+
if (lastError && (typeof lastError !== "object" || !('suppressLog' in lastError))) {
|
|
361
|
+
console.error("Uncaught handler error:", lastError);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
};
|
|
365
|
+
handleWS = async (ws, req) => {
|
|
366
|
+
const pathname = url.parse(req.url || "/").pathname || "/";
|
|
367
|
+
for (const r of this.wsRoutes) {
|
|
368
|
+
const m = r.info.regex.exec(pathname);
|
|
369
|
+
if (!m)
|
|
370
|
+
continue;
|
|
371
|
+
const params = {};
|
|
372
|
+
r.info.params.forEach((p, i) => {
|
|
373
|
+
params[p] = m[i + 1] ? decodeURIComponent(m[i + 1]) : undefined;
|
|
374
|
+
});
|
|
375
|
+
// ensure req.params is available for prechecks and handler
|
|
376
|
+
const reqWithParams = req;
|
|
377
|
+
reqWithParams.params = params;
|
|
378
|
+
for (const chk of r.prechecks) {
|
|
379
|
+
const ok = await chk(reqWithParams, {});
|
|
380
|
+
if (!ok) {
|
|
381
|
+
ws.close(1008, "Forbidden");
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
await r.handler(ws, reqWithParams);
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
ws.close(1002, "No WS route");
|
|
389
|
+
};
|
|
390
|
+
add(method, path, handler) {
|
|
391
|
+
const full = join(this.base, path);
|
|
392
|
+
const info = compilePattern(full);
|
|
393
|
+
debug(`Adding route: method='${method}', full path='${full}', regex='${info.regex}', params=[${info.params.join(", ")}]`);
|
|
394
|
+
this.routes.push({
|
|
395
|
+
method,
|
|
396
|
+
info,
|
|
397
|
+
handler,
|
|
398
|
+
prechecks: [...this.inheritedChecks],
|
|
399
|
+
});
|
|
400
|
+
this.routes.sort((a, b) => b.info.score - a.info.score ||
|
|
401
|
+
b.info.regex.source.length - a.info.regex.source.length);
|
|
402
|
+
return this;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
export class BaseRouter extends Router {
|
|
406
|
+
#server;
|
|
407
|
+
handleError(type, handler) {
|
|
408
|
+
super.handleError(type, handler);
|
|
409
|
+
return this;
|
|
410
|
+
}
|
|
411
|
+
listen(port, cb) {
|
|
412
|
+
const server = http.createServer((req, res) => {
|
|
413
|
+
runMiddlewares(req, res, (err) => {
|
|
414
|
+
if (err) {
|
|
415
|
+
res.statusCode = 500;
|
|
416
|
+
res.end("Internal Error");
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
this.#server = server;
|
|
421
|
+
const WSSCtor = getWebSocketServer();
|
|
422
|
+
if (WSSCtor) {
|
|
423
|
+
const wss = new WSSCtor({ noServer: true });
|
|
424
|
+
useUpgrade((req, socket, head) => {
|
|
425
|
+
// only accept websocket upgrades
|
|
426
|
+
if (req.headers.upgrade !== "websocket")
|
|
427
|
+
return false;
|
|
428
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
429
|
+
this.handleWS(ws, req);
|
|
430
|
+
});
|
|
431
|
+
return true;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
server.on("upgrade", (req, socket, head) => {
|
|
435
|
+
const list = [...upgradeHandlers].sort((a, b) => b.priority - a.priority || a.idx - b.idx);
|
|
436
|
+
for (const h of list) {
|
|
437
|
+
try {
|
|
438
|
+
const handled = h.fn(req, socket, head);
|
|
439
|
+
if (handled)
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
catch (e) {
|
|
443
|
+
socket.destroy();
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// nothing handled it
|
|
448
|
+
socket.destroy();
|
|
449
|
+
});
|
|
450
|
+
if (cb) {
|
|
451
|
+
server.listen(port, cb);
|
|
452
|
+
return server;
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
return new Promise((resolve, reject) => {
|
|
456
|
+
server.listen(port, () => resolve());
|
|
457
|
+
server.on('error', reject);
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
stop() {
|
|
462
|
+
return new Promise((resolve, reject) => {
|
|
463
|
+
if (this.#server) {
|
|
464
|
+
this.#server.close((err) => {
|
|
465
|
+
if (err)
|
|
466
|
+
reject(err);
|
|
467
|
+
else
|
|
468
|
+
resolve();
|
|
469
|
+
});
|
|
470
|
+
this.#server = undefined;
|
|
471
|
+
}
|
|
472
|
+
else {
|
|
473
|
+
resolve();
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
use(fn, priority = 0) {
|
|
478
|
+
use(fn, priority);
|
|
479
|
+
}
|
|
480
|
+
useUpgrade(fn, priority = 0) {
|
|
481
|
+
useUpgrade(fn, priority);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function normalize(p) {
|
|
485
|
+
if (!p.startsWith("/"))
|
|
486
|
+
p = "/" + p;
|
|
487
|
+
p = p.replace(/\/+/g, "/");
|
|
488
|
+
if (p !== "/" && p.endsWith("/"))
|
|
489
|
+
p = p.slice(0, -1);
|
|
490
|
+
return p;
|
|
491
|
+
}
|
|
492
|
+
function join(a, b) {
|
|
493
|
+
if (a !== "/" && a.endsWith("/"))
|
|
494
|
+
a = a.slice(0, -1);
|
|
495
|
+
b = b.replace(/^\/+/, "");
|
|
496
|
+
if (!b)
|
|
497
|
+
return a;
|
|
498
|
+
return a + "/" + b;
|
|
499
|
+
}
|
|
500
|
+
export function root(base = "/") {
|
|
501
|
+
return new BaseRouter(base);
|
|
502
|
+
}
|
|
503
|
+
export const responseSymbol = Symbol("httptree.response");
|
|
504
|
+
function createResponse(body, status = 200, headers = {}) {
|
|
505
|
+
return {
|
|
506
|
+
[responseSymbol]: true,
|
|
507
|
+
body,
|
|
508
|
+
status,
|
|
509
|
+
headers,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
export function json(data, opts = {}) {
|
|
513
|
+
return createResponse(JSON.stringify(data), opts.status ?? 200, {
|
|
514
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
515
|
+
...opts.headers,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
export function text(body, opts = {}) {
|
|
519
|
+
return createResponse(body, opts.status ?? 200, {
|
|
520
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
521
|
+
...opts.headers,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
export function redirect(location, status = 302) {
|
|
525
|
+
throw {
|
|
526
|
+
[responseSymbol]: true,
|
|
527
|
+
body: "",
|
|
528
|
+
status,
|
|
529
|
+
headers: {
|
|
530
|
+
Location: location,
|
|
531
|
+
},
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
function _error(status, body = http.STATUS_CODES[status] ?? "Error", headers = {}) {
|
|
535
|
+
const isJSON = typeof body === "object";
|
|
536
|
+
throw {
|
|
537
|
+
[responseSymbol]: true,
|
|
538
|
+
body: isJSON ? JSON.stringify(body) : String(body),
|
|
539
|
+
status,
|
|
540
|
+
headers: {
|
|
541
|
+
"Content-Type": isJSON
|
|
542
|
+
? "application/json; charset=utf-8"
|
|
543
|
+
: "text/plain; charset=utf-8",
|
|
544
|
+
...headers,
|
|
545
|
+
},
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
const error = _error;
|
|
549
|
+
error.badRequest = (body = "Bad Request", headers = {}) => _error(400, body, headers);
|
|
550
|
+
error.unauthorized = (body = "Unauthorized", headers = {}) => _error(401, body, headers);
|
|
551
|
+
error.forbidden = (body = "Forbidden", headers = {}) => _error(403, body, headers);
|
|
552
|
+
error.notFound = (body = "Not Found", headers = {}) => _error(404, body, headers);
|
|
553
|
+
error.conflict = (body = "Conflict", headers = {}) => _error(409, body, headers);
|
|
554
|
+
error.unprocessableEntity = (body = "Unprocessable Entity", headers = {}) => _error(422, body, headers);
|
|
555
|
+
error.tooManyRequests = (body = "Too Many Requests", headers = {}) => _error(429, body, headers);
|
|
556
|
+
error.internalServerError = (body = "Internal Server Error", headers = {}) => _error(500, body, headers);
|
|
557
|
+
error.badGateway = (body = "Bad Gateway", headers = {}) => _error(502, body, headers);
|
|
558
|
+
error.serviceUnavailable = (body = "Service Unavailable", headers = {}) => _error(503, body, headers);
|
|
559
|
+
export { error };
|
|
560
|
+
export function raw(body, opts = {}) {
|
|
561
|
+
return createResponse(body, opts.status ?? 200, opts.headers ?? {});
|
|
562
|
+
}
|
|
563
|
+
export function file(body, filename, contentType = "application/octet-stream") {
|
|
564
|
+
return createResponse(body, 200, {
|
|
565
|
+
"Content-Type": contentType,
|
|
566
|
+
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
567
|
+
});
|
|
568
|
+
}
|
|
569
|
+
export function html(body, opts = {}) {
|
|
570
|
+
return createResponse(body, opts.status ?? 200, {
|
|
571
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
572
|
+
...opts.headers,
|
|
573
|
+
});
|
|
574
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@thetally/httptree",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A TypeScript library for creating and managing HTTP request trees.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"lint": "eslint src --ext .ts",
|
|
10
|
+
"test": "node dist/test/test.js"
|
|
11
|
+
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": ""
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"http",
|
|
18
|
+
"tree",
|
|
19
|
+
"typescript"
|
|
20
|
+
],
|
|
21
|
+
"author": "",
|
|
22
|
+
"type": "module",
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"files": [
|
|
25
|
+
"dist/**/*",
|
|
26
|
+
"!dist/test/**/*"
|
|
27
|
+
],
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@types/node": "^25.2.3",
|
|
30
|
+
"@types/ws": "^8.18.1",
|
|
31
|
+
"eslint": "^8.0.0",
|
|
32
|
+
"typescript": "^5.0.0",
|
|
33
|
+
"ws": "^8.19.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"ws": "^8.0.0"
|
|
37
|
+
},
|
|
38
|
+
"peerDependenciesMeta": {
|
|
39
|
+
"ws": {
|
|
40
|
+
"optional": true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|