yedra 0.20.11 → 0.20.13
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/routing/rest.d.ts +2 -0
- package/dist/routing/rest.js +2 -0
- package/dist/routing/websocket.d.ts +1 -0
- package/dist/routing/websocket.js +1 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +7 -0
- package/dist/src/lib.d.ts +10 -0
- package/dist/src/lib.js +16 -0
- package/dist/src/routing/app.d.ts +165 -0
- package/dist/src/routing/app.js +537 -0
- package/dist/src/routing/env.d.ts +4 -0
- package/dist/src/routing/env.js +13 -0
- package/dist/src/routing/errors.d.ts +50 -0
- package/dist/src/routing/errors.js +64 -0
- package/dist/src/routing/log.d.ts +22 -0
- package/dist/src/routing/log.js +30 -0
- package/dist/src/routing/path.d.ts +44 -0
- package/dist/src/routing/path.js +110 -0
- package/dist/src/routing/rest.d.ts +103 -0
- package/dist/src/routing/rest.js +181 -0
- package/dist/src/routing/websocket.d.ts +60 -0
- package/dist/src/routing/websocket.js +132 -0
- package/dist/src/schema-lib.d.ts +17 -0
- package/dist/src/schema-lib.js +20 -0
- package/dist/src/schema.d.ts +2 -0
- package/dist/src/schema.js +4 -0
- package/dist/src/util/counter.d.ts +7 -0
- package/dist/src/util/counter.js +24 -0
- package/dist/src/util/docs.d.ts +9 -0
- package/dist/src/util/docs.js +57 -0
- package/dist/src/util/security.d.ts +14 -0
- package/dist/src/util/security.js +6 -0
- package/dist/src/util/stream.d.ts +2 -0
- package/dist/src/util/stream.js +7 -0
- package/dist/src/validation/body.d.ts +46 -0
- package/dist/src/validation/body.js +15 -0
- package/dist/src/validation/boolean.d.ts +10 -0
- package/dist/src/validation/boolean.js +27 -0
- package/dist/src/validation/date.d.ts +11 -0
- package/dist/src/validation/date.js +29 -0
- package/dist/src/validation/doc.d.ts +10 -0
- package/dist/src/validation/doc.js +22 -0
- package/dist/src/validation/either.d.ts +15 -0
- package/dist/src/validation/either.js +38 -0
- package/dist/src/validation/enum.d.ts +19 -0
- package/dist/src/validation/enum.js +44 -0
- package/dist/src/validation/error.d.ts +21 -0
- package/dist/src/validation/error.js +32 -0
- package/dist/src/validation/integer.d.ts +23 -0
- package/dist/src/validation/integer.js +64 -0
- package/dist/src/validation/json.d.ts +12 -0
- package/dist/src/validation/json.js +33 -0
- package/dist/src/validation/lazy.d.ts +48 -0
- package/dist/src/validation/lazy.js +78 -0
- package/dist/src/validation/modifiable.d.ts +105 -0
- package/dist/src/validation/modifiable.js +223 -0
- package/dist/src/validation/none.d.ts +10 -0
- package/dist/src/validation/none.js +14 -0
- package/dist/src/validation/null.d.ts +10 -0
- package/dist/src/validation/null.js +21 -0
- package/dist/src/validation/number.d.ts +23 -0
- package/dist/src/validation/number.js +58 -0
- package/dist/src/validation/object.d.ts +38 -0
- package/dist/src/validation/object.js +87 -0
- package/dist/src/validation/raw.d.ts +13 -0
- package/dist/src/validation/raw.js +21 -0
- package/dist/src/validation/record.d.ts +16 -0
- package/dist/src/validation/record.js +51 -0
- package/dist/src/validation/schema.d.ts +35 -0
- package/dist/src/validation/schema.js +76 -0
- package/dist/src/validation/stream.d.ts +13 -0
- package/dist/src/validation/stream.js +26 -0
- package/dist/src/validation/string.d.ts +29 -0
- package/dist/src/validation/string.js +51 -0
- package/dist/src/validation/union.d.ts +16 -0
- package/dist/src/validation/union.js +36 -0
- package/dist/src/validation/unknown.d.ts +10 -0
- package/dist/src/validation/unknown.js +13 -0
- package/dist/src/validation/uuid.d.ts +11 -0
- package/dist/src/validation/uuid.js +23 -0
- package/package.json +3 -4
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
4
|
+
import { createServer as createHttpsServer } from 'node:https';
|
|
5
|
+
import { extname, join } from 'node:path';
|
|
6
|
+
import { URL } from 'node:url';
|
|
7
|
+
import { isUint8Array } from 'node:util/types';
|
|
8
|
+
import { context, propagation, SpanKind, trace } from '@opentelemetry/api';
|
|
9
|
+
import mime from 'mime';
|
|
10
|
+
import { WebSocketServer } from 'ws';
|
|
11
|
+
import { Counter } from '../util/counter.js';
|
|
12
|
+
import { collectLazySchemas } from '../validation/lazy.js';
|
|
13
|
+
import { HttpError } from './errors.js';
|
|
14
|
+
import { Path } from './path.js';
|
|
15
|
+
import { RestEndpoint } from './rest.js';
|
|
16
|
+
import { WsEndpoint } from './websocket.js';
|
|
17
|
+
class Context {
|
|
18
|
+
constructor(server, wss, counter, docs) {
|
|
19
|
+
this.server = server;
|
|
20
|
+
this.wss = wss;
|
|
21
|
+
this.counter = counter;
|
|
22
|
+
this.docs = docs;
|
|
23
|
+
}
|
|
24
|
+
stop() {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
// don't accept any new connections
|
|
27
|
+
this.wss.close();
|
|
28
|
+
this.server.close();
|
|
29
|
+
for (const client of this.wss.clients) {
|
|
30
|
+
// send shutdown message to all WebSocket clients
|
|
31
|
+
client.close(1000, 'Server Shutdown');
|
|
32
|
+
}
|
|
33
|
+
// wait until all connections are done
|
|
34
|
+
this.counter.wait().then(() => {
|
|
35
|
+
resolve();
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
class BuiltApp {
|
|
41
|
+
constructor(options) {
|
|
42
|
+
this.restRoutes = [];
|
|
43
|
+
this.wsRoutes = [];
|
|
44
|
+
this.requestData = {};
|
|
45
|
+
this.serveData = options.serveData;
|
|
46
|
+
this.docs = options.docs;
|
|
47
|
+
this.generatedDocs = JSON.stringify(options.docs);
|
|
48
|
+
this.connectMiddlewares = options.connectMiddlewares;
|
|
49
|
+
this.quiet = options.quiet;
|
|
50
|
+
this.restRoutes = options.restRoutes;
|
|
51
|
+
this.wsRoutes = options.wsRoutes;
|
|
52
|
+
}
|
|
53
|
+
static middlewareNext(req, res, middlewares, last) {
|
|
54
|
+
if (middlewares.length > 0) {
|
|
55
|
+
// apply the next middleware
|
|
56
|
+
middlewares[0](req, res, () => BuiltApp.middlewareNext(req, res, middlewares.slice(1), last));
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
// no middlewares left, invoke yedra
|
|
60
|
+
last();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
handle(req, res) {
|
|
64
|
+
// first, invoke the middleware chain
|
|
65
|
+
BuiltApp.middlewareNext(req, res, this.connectMiddlewares, async () => {
|
|
66
|
+
// this will be called after all middlewares are done
|
|
67
|
+
const url = new URL(req.url, 'http://localhost');
|
|
68
|
+
const begin = Date.now();
|
|
69
|
+
const response = await this.performRequest({
|
|
70
|
+
method: req.method ?? 'GET',
|
|
71
|
+
url,
|
|
72
|
+
body: req,
|
|
73
|
+
headers: req.headers,
|
|
74
|
+
});
|
|
75
|
+
const status = response.status ?? 200;
|
|
76
|
+
if (response.body instanceof ReadableStream) {
|
|
77
|
+
res.writeHead(status, response.headers);
|
|
78
|
+
try {
|
|
79
|
+
for await (const chunk of response.body) {
|
|
80
|
+
const canContinue = res.write(chunk);
|
|
81
|
+
if (!canContinue) {
|
|
82
|
+
// Buffer full - wait for drain before continuing
|
|
83
|
+
await new Promise((resolve) => res.once('drain', resolve));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (_error) {
|
|
88
|
+
res.destroy();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else if (response.body instanceof Uint8Array) {
|
|
92
|
+
res.writeHead(status, response.headers);
|
|
93
|
+
res.write(response.body);
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
res.writeHead(status, {
|
|
97
|
+
'content-type': 'application/json',
|
|
98
|
+
...response.headers,
|
|
99
|
+
});
|
|
100
|
+
res.write(JSON.stringify(response.body));
|
|
101
|
+
}
|
|
102
|
+
res.end();
|
|
103
|
+
const duration = Date.now() - begin;
|
|
104
|
+
if (this.quiet !== true) {
|
|
105
|
+
console.log(`${req.method} ${url.pathname} -> ${status} (${duration}ms)`);
|
|
106
|
+
}
|
|
107
|
+
this.track(req.method, status, duration / 1000);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async performRequest(req) {
|
|
111
|
+
if (req.method !== 'GET' &&
|
|
112
|
+
req.method !== 'POST' &&
|
|
113
|
+
req.method !== 'PUT' &&
|
|
114
|
+
req.method !== 'PATCH' &&
|
|
115
|
+
req.method !== 'DELETE') {
|
|
116
|
+
return BuiltApp.errorResponse(405, `Method \`${req.method}\` not allowed.`);
|
|
117
|
+
}
|
|
118
|
+
if (req.method === 'GET' && req.url.pathname === '/openapi.json') {
|
|
119
|
+
if (this.generatedDocs === undefined) {
|
|
120
|
+
console.error('Docs were not generated correctly.');
|
|
121
|
+
return BuiltApp.errorResponse(500, 'Internal Server Error');
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
status: 200,
|
|
125
|
+
body: Buffer.from(this.generatedDocs ?? '{}', 'utf-8'),
|
|
126
|
+
headers: {
|
|
127
|
+
'content-type': 'application/json',
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
const match = this.matchRestRoute(req.url.pathname, req.method);
|
|
132
|
+
if (!match.result) {
|
|
133
|
+
// no matching route found
|
|
134
|
+
if (req.method === 'GET') {
|
|
135
|
+
// look for a static file
|
|
136
|
+
const staticFile = this.serveData.files.get(req.url.pathname) ??
|
|
137
|
+
this.serveData.files.get('__fallback');
|
|
138
|
+
if (staticFile !== undefined) {
|
|
139
|
+
const ifNoneMatch = req.headers['if-none-match'];
|
|
140
|
+
const clientEtag = Array.isArray(ifNoneMatch)
|
|
141
|
+
? ifNoneMatch[0]
|
|
142
|
+
: ifNoneMatch;
|
|
143
|
+
if (clientEtag === staticFile.etag) {
|
|
144
|
+
return {
|
|
145
|
+
status: 304,
|
|
146
|
+
body: Buffer.alloc(0),
|
|
147
|
+
headers: {
|
|
148
|
+
etag: staticFile.etag,
|
|
149
|
+
'cache-control': staticFile.cacheControl,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
status: 200,
|
|
155
|
+
body: staticFile.data,
|
|
156
|
+
headers: {
|
|
157
|
+
'content-type': staticFile.mime,
|
|
158
|
+
etag: staticFile.etag,
|
|
159
|
+
'cache-control': staticFile.cacheControl,
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (this.serveData.fallback !== undefined) {
|
|
164
|
+
try {
|
|
165
|
+
const response = await this.serveData.fallback({
|
|
166
|
+
href: req.url.href,
|
|
167
|
+
});
|
|
168
|
+
return {
|
|
169
|
+
status: response.status ?? 200,
|
|
170
|
+
body: isUint8Array(response.body)
|
|
171
|
+
? response.body
|
|
172
|
+
: Buffer.from(response.body, 'utf-8'),
|
|
173
|
+
headers: response.headers,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (error instanceof HttpError) {
|
|
178
|
+
return BuiltApp.errorResponse(error.status, error.message, error.code);
|
|
179
|
+
}
|
|
180
|
+
console.error(error);
|
|
181
|
+
return BuiltApp.errorResponse(500, 'Internal Server Error.');
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (match.invalidMethod) {
|
|
186
|
+
// we found a route, but it did not match the request method
|
|
187
|
+
return BuiltApp.errorResponse(405, `Method ${req.method} not allowed for path \`${req.url.pathname}\`.`);
|
|
188
|
+
}
|
|
189
|
+
return BuiltApp.errorResponse(404, `Path \`${req.url.pathname}\` not found.`);
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
return await match.result.endpoint.handle({
|
|
193
|
+
url: req.url.pathname,
|
|
194
|
+
body: req.body,
|
|
195
|
+
params: match.result.params,
|
|
196
|
+
query: Object.fromEntries(req.url.searchParams),
|
|
197
|
+
headers: Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [
|
|
198
|
+
key,
|
|
199
|
+
Array.isArray(value) ? value.join(',') : (value ?? ''),
|
|
200
|
+
])),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (error instanceof HttpError) {
|
|
205
|
+
return BuiltApp.errorResponse(error.status, error.message, error.code);
|
|
206
|
+
}
|
|
207
|
+
console.error(error);
|
|
208
|
+
return BuiltApp.errorResponse(500, 'Internal Server Error.');
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
static errorResponse(status, errorMessage, code) {
|
|
212
|
+
const defaultCodes = new Map([
|
|
213
|
+
[400, 'bad_request'],
|
|
214
|
+
[401, 'unauthorized'],
|
|
215
|
+
[402, 'payment_required'],
|
|
216
|
+
[403, 'forbidden'],
|
|
217
|
+
[404, 'not_found'],
|
|
218
|
+
[405, 'method_not_allowed'],
|
|
219
|
+
[409, 'conflict'],
|
|
220
|
+
[500, 'internal_server_error'],
|
|
221
|
+
]);
|
|
222
|
+
return {
|
|
223
|
+
status,
|
|
224
|
+
body: {
|
|
225
|
+
status,
|
|
226
|
+
errorMessage,
|
|
227
|
+
code: code ?? defaultCodes.get(status) ?? 'unknown_error',
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
matchRestRoute(url, method) {
|
|
232
|
+
let invalidMethod = false;
|
|
233
|
+
let result;
|
|
234
|
+
for (const route of this.restRoutes) {
|
|
235
|
+
const match = route.path.match(url);
|
|
236
|
+
if (match === undefined) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const { params, score } = match;
|
|
240
|
+
if (route.endpoint.method !== method) {
|
|
241
|
+
invalidMethod = true;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const previous = result?.score;
|
|
245
|
+
if (previous === undefined || score < previous) {
|
|
246
|
+
// if there was no previous match or this one is better, use it
|
|
247
|
+
result = { endpoint: route.endpoint, params, score };
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return { invalidMethod, result };
|
|
251
|
+
}
|
|
252
|
+
track(method, status, duration) {
|
|
253
|
+
const id = `${method}-${status}`;
|
|
254
|
+
const data = this.requestData[id] ?? { count: 0, duration: 0 };
|
|
255
|
+
this.requestData[id] = {
|
|
256
|
+
count: data.count + 1,
|
|
257
|
+
duration: data.duration + duration,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
metrics() {
|
|
261
|
+
return Object.entries(this.requestData)
|
|
262
|
+
.map(([key, data]) => {
|
|
263
|
+
const [method, status] = key.split('-');
|
|
264
|
+
return `yedra_requests_total{method="${method}",status="${status}"} ${data?.count}
|
|
265
|
+
yedra_request_duration_sum{method="${method}",status="${status}"} ${data?.duration}
|
|
266
|
+
`;
|
|
267
|
+
})
|
|
268
|
+
.join('');
|
|
269
|
+
}
|
|
270
|
+
listen(port, options) {
|
|
271
|
+
const server = options?.tls === undefined
|
|
272
|
+
? createHttpServer()
|
|
273
|
+
: createHttpsServer({
|
|
274
|
+
key: options.tls.key,
|
|
275
|
+
cert: options.tls.cert,
|
|
276
|
+
});
|
|
277
|
+
const counter = new Counter();
|
|
278
|
+
server.on('request', (req, res) => {
|
|
279
|
+
counter.increment();
|
|
280
|
+
const extractedContext = propagation.extract(context.active(), req.headers);
|
|
281
|
+
context.with(extractedContext, () => trace.getTracer('yedra').startActiveSpan('incoming_request', {
|
|
282
|
+
kind: SpanKind.SERVER,
|
|
283
|
+
}, (span) => {
|
|
284
|
+
this.handle(req, res);
|
|
285
|
+
res.on('close', () => {
|
|
286
|
+
span.setAttribute('http.method', req.method ?? 'UNKNOWN');
|
|
287
|
+
span.setAttribute('http.url', req.url ?? 'UNKNOWN');
|
|
288
|
+
span.setAttribute('http.status_code', res.statusCode);
|
|
289
|
+
span.end();
|
|
290
|
+
counter.decrement();
|
|
291
|
+
});
|
|
292
|
+
}));
|
|
293
|
+
});
|
|
294
|
+
const wss = new WebSocketServer({ server });
|
|
295
|
+
wss.on('connection', (ws, req) => {
|
|
296
|
+
const extractedContext = propagation.extract(context.active(), req.headers);
|
|
297
|
+
context.with(extractedContext, () => trace.getTracer('yedra').startActiveSpan('incoming_ws_connection', {
|
|
298
|
+
kind: SpanKind.SERVER,
|
|
299
|
+
}, async (span) => {
|
|
300
|
+
span.setAttribute('http.url', req.url ?? 'UNKNOWN');
|
|
301
|
+
let ended = false;
|
|
302
|
+
const end = () => {
|
|
303
|
+
if (ended) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
ended = true;
|
|
307
|
+
span.end();
|
|
308
|
+
};
|
|
309
|
+
ws.once('close', end);
|
|
310
|
+
const url = new URL(req.url, 'http://localhost');
|
|
311
|
+
const { result } = this.matchWsRoute(url.pathname);
|
|
312
|
+
if (!result) {
|
|
313
|
+
ws.close(4404);
|
|
314
|
+
end();
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([key, value]) => [
|
|
319
|
+
key,
|
|
320
|
+
Array.isArray(value) ? value.join(',') : (value ?? ''),
|
|
321
|
+
]));
|
|
322
|
+
await result.endpoint.handle(url, result.params, headers, ws);
|
|
323
|
+
}
|
|
324
|
+
catch (error) {
|
|
325
|
+
if (error instanceof HttpError) {
|
|
326
|
+
ws.close(4000 + error.status, error.message);
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
console.error(error);
|
|
330
|
+
ws.close(1011, 'Internal Error');
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}));
|
|
334
|
+
});
|
|
335
|
+
server.listen(port, () => {
|
|
336
|
+
if (this.quiet !== true) {
|
|
337
|
+
console.log(`yedra listening on http://localhost:${port}`);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
if (options?.metrics !== undefined) {
|
|
341
|
+
const metricsEndpoint = options.metrics;
|
|
342
|
+
const metricsServer = createHttpServer();
|
|
343
|
+
metricsServer.on('request', async (req, res) => {
|
|
344
|
+
if (req.method === 'GET' && req.url === metricsEndpoint.path) {
|
|
345
|
+
res.writeHead(200, {
|
|
346
|
+
'content-type': 'text/plain; version=0.0.4; charset=utf-8; escaping=underscores',
|
|
347
|
+
});
|
|
348
|
+
res.write(this.metrics());
|
|
349
|
+
if (metricsEndpoint.get !== undefined) {
|
|
350
|
+
res.write(await metricsEndpoint.get());
|
|
351
|
+
}
|
|
352
|
+
res.end();
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
res.writeHead(404);
|
|
356
|
+
res.end('Not found');
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
metricsServer.listen(metricsEndpoint.port, () => {
|
|
360
|
+
if (this.quiet !== true) {
|
|
361
|
+
console.log(`yedra metrics on http://localhost:${metricsEndpoint.port}${metricsEndpoint.path}`);
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
return new Context(server, wss, counter, this.docs);
|
|
366
|
+
}
|
|
367
|
+
matchWsRoute(url) {
|
|
368
|
+
let result;
|
|
369
|
+
for (const route of this.wsRoutes) {
|
|
370
|
+
const match = route.path.match(url);
|
|
371
|
+
if (match === undefined) {
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
const { params, score } = match;
|
|
375
|
+
const previous = result?.score;
|
|
376
|
+
if (previous === undefined || score < previous) {
|
|
377
|
+
// if there was no previous match or this one is better, use it
|
|
378
|
+
result = { endpoint: route.endpoint, params, score };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return { result };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
export class Yedra {
|
|
385
|
+
constructor() {
|
|
386
|
+
this.restRoutes = [];
|
|
387
|
+
this.wsRoutes = [];
|
|
388
|
+
}
|
|
389
|
+
use(path, endpoint) {
|
|
390
|
+
if (endpoint instanceof Yedra) {
|
|
391
|
+
for (const route of endpoint.restRoutes) {
|
|
392
|
+
const newPath = route.path.withPrefix(path);
|
|
393
|
+
this.restRoutes.push({
|
|
394
|
+
path: newPath,
|
|
395
|
+
endpoint: route.endpoint,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
for (const route of endpoint.wsRoutes) {
|
|
399
|
+
const newPath = route.path.withPrefix(path);
|
|
400
|
+
this.wsRoutes.push({ path: newPath, endpoint: route.endpoint });
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
else if (endpoint instanceof RestEndpoint) {
|
|
404
|
+
this.restRoutes.push({ path: new Path(path), endpoint });
|
|
405
|
+
}
|
|
406
|
+
else if (endpoint instanceof WsEndpoint) {
|
|
407
|
+
this.wsRoutes.push({ path: new Path(path), endpoint });
|
|
408
|
+
}
|
|
409
|
+
else {
|
|
410
|
+
throw new Error('Invalid endpoint argument.');
|
|
411
|
+
}
|
|
412
|
+
return this;
|
|
413
|
+
}
|
|
414
|
+
generateDocs(options) {
|
|
415
|
+
// this set will be filled with the security schemes from all endpoints
|
|
416
|
+
const securitySchemes = new Set();
|
|
417
|
+
// Wrap path generation in collectLazySchemas so that any LazySchema
|
|
418
|
+
// whose documentation() is called will register its full definition.
|
|
419
|
+
const { result: paths, schemas } = collectLazySchemas(() => {
|
|
420
|
+
const paths = {};
|
|
421
|
+
for (const route of this.restRoutes) {
|
|
422
|
+
if (route.endpoint.isHidden()) {
|
|
423
|
+
// do not include hidden endpoints in the documentation
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
const path = route.path.toString();
|
|
427
|
+
const methods = paths[path] ?? {};
|
|
428
|
+
methods[route.endpoint.method.toLowerCase()] =
|
|
429
|
+
route.endpoint.documentation(path, securitySchemes);
|
|
430
|
+
paths[path] = methods;
|
|
431
|
+
}
|
|
432
|
+
return paths;
|
|
433
|
+
});
|
|
434
|
+
return {
|
|
435
|
+
openapi: '3.0.2',
|
|
436
|
+
info: {
|
|
437
|
+
title: options?.title ?? 'Yedra API',
|
|
438
|
+
description: options?.description ??
|
|
439
|
+
'This is an OpenAPI documentation generated automatically by Yedra.',
|
|
440
|
+
version: options?.version ?? '0.1.0',
|
|
441
|
+
},
|
|
442
|
+
components: {
|
|
443
|
+
securitySchemes: Object.fromEntries(securitySchemes
|
|
444
|
+
.values()
|
|
445
|
+
.map((scheme) => [scheme.name, scheme.scheme])),
|
|
446
|
+
schemas: Object.fromEntries(schemas),
|
|
447
|
+
},
|
|
448
|
+
servers: options?.servers ?? [],
|
|
449
|
+
paths,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
static async loadServe(config) {
|
|
453
|
+
if (config === undefined) {
|
|
454
|
+
return {
|
|
455
|
+
files: new Map(),
|
|
456
|
+
fallback: undefined,
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const staticFiles = new Map();
|
|
460
|
+
let files;
|
|
461
|
+
try {
|
|
462
|
+
files = await readdir(config.dir, { recursive: true });
|
|
463
|
+
}
|
|
464
|
+
catch (_error) {
|
|
465
|
+
files = [];
|
|
466
|
+
}
|
|
467
|
+
const revalidate = 'public, max-age=0, must-revalidate';
|
|
468
|
+
await Promise.all(files.map(async (file) => {
|
|
469
|
+
const absolute = join(config.dir, file);
|
|
470
|
+
if (!(await stat(absolute)).isFile()) {
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const data = await readFile(absolute);
|
|
474
|
+
const urlPath = `/${file}`;
|
|
475
|
+
const cacheControl = config.immutable !== undefined && config.immutable.pattern.test(file)
|
|
476
|
+
? `public, max-age=${config.immutable.maxAge}, immutable`
|
|
477
|
+
: revalidate;
|
|
478
|
+
staticFiles.set(urlPath, {
|
|
479
|
+
data,
|
|
480
|
+
mime: mime.getType(extname(file)) ?? 'application/octet-stream',
|
|
481
|
+
etag: `"${createHash('sha1').update(data).digest('hex')}"`,
|
|
482
|
+
cacheControl,
|
|
483
|
+
});
|
|
484
|
+
}));
|
|
485
|
+
if (config.fallback) {
|
|
486
|
+
if (typeof config.fallback === 'string') {
|
|
487
|
+
const data = await readFile(config.fallback);
|
|
488
|
+
staticFiles.set('__fallback', {
|
|
489
|
+
data,
|
|
490
|
+
mime: mime.getType(extname(config.fallback)) ??
|
|
491
|
+
'application/octet-stream',
|
|
492
|
+
etag: `"${createHash('sha1').update(data).digest('hex')}"`,
|
|
493
|
+
cacheControl: revalidate,
|
|
494
|
+
});
|
|
495
|
+
return {
|
|
496
|
+
files: staticFiles,
|
|
497
|
+
fallback: undefined,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
return {
|
|
501
|
+
files: staticFiles,
|
|
502
|
+
fallback: config.fallback,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
return {
|
|
506
|
+
files: staticFiles,
|
|
507
|
+
fallback: undefined,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
async build(options) {
|
|
511
|
+
const serveData = await Yedra.loadServe(options?.serve);
|
|
512
|
+
const docs = this.generateDocs(options?.docs);
|
|
513
|
+
return new BuiltApp({
|
|
514
|
+
serveData,
|
|
515
|
+
docs,
|
|
516
|
+
connectMiddlewares: options?.connectMiddlewares ?? [],
|
|
517
|
+
quiet: options?.quiet ?? false,
|
|
518
|
+
restRoutes: this.restRoutes,
|
|
519
|
+
wsRoutes: this.wsRoutes,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
async listen(port, options) {
|
|
523
|
+
const app = await this.build({
|
|
524
|
+
docs: options?.docs,
|
|
525
|
+
serve: options?.serve,
|
|
526
|
+
quiet: options?.quiet,
|
|
527
|
+
connectMiddlewares: options?.connectMiddlewares,
|
|
528
|
+
});
|
|
529
|
+
return app.listen(port, options);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* TODO: how do we add OpenTelemetry instrumentation here?
|
|
534
|
+
* We need two things:
|
|
535
|
+
* 1. Start active span for each incoming request, and end after response is sent.
|
|
536
|
+
* 2. Extract context from incoming requests to propagate traces.
|
|
537
|
+
*/
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Typeof } from '../validation/body.js';
|
|
2
|
+
import { type ObjectSchema } from '../validation/object.js';
|
|
3
|
+
import type { Schema } from '../validation/schema.js';
|
|
4
|
+
export declare const parseEnv: <T extends Record<string, Schema<unknown>>>(shape: T) => Typeof<ObjectSchema<T>>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { ValidationError } from '../validation/error.js';
|
|
2
|
+
import { laxObject } from '../validation/object.js';
|
|
3
|
+
export const parseEnv = (shape) => {
|
|
4
|
+
try {
|
|
5
|
+
return laxObject(shape).parse(process.env);
|
|
6
|
+
}
|
|
7
|
+
catch (error) {
|
|
8
|
+
if (error instanceof ValidationError) {
|
|
9
|
+
console.error(`error: env validation failed: ${error.format()}`);
|
|
10
|
+
}
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for errors that will be handled as HTTP status codes.
|
|
3
|
+
*/
|
|
4
|
+
export declare class HttpError extends Error {
|
|
5
|
+
readonly status: number;
|
|
6
|
+
readonly code: string | undefined;
|
|
7
|
+
constructor(status: number, message: string, code?: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Indicates a malformed request.
|
|
11
|
+
* Corresponds to HTTP status code 400 Bad Request.
|
|
12
|
+
*/
|
|
13
|
+
export declare class BadRequestError extends HttpError {
|
|
14
|
+
constructor(message: string, code?: string);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Indicates missing or invalid credentials.
|
|
18
|
+
* Corresponds to HTTP status code 401 Unauthorized.
|
|
19
|
+
*/
|
|
20
|
+
export declare class UnauthorizedError extends HttpError {
|
|
21
|
+
constructor(message: string, code?: string);
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Indicates that some kind of payment is required.
|
|
25
|
+
* Corresponds to HTTP status code 402 Payment Required.
|
|
26
|
+
*/
|
|
27
|
+
export declare class PaymentRequiredError extends HttpError {
|
|
28
|
+
constructor(message: string, code?: string);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Indicates that the user is not allowed to do something.
|
|
32
|
+
* Corresponds to HTTP status code 403 Forbidden.
|
|
33
|
+
*/
|
|
34
|
+
export declare class ForbiddenError extends HttpError {
|
|
35
|
+
constructor(message: string, code?: string);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Indicates that the requested resource does not exist.
|
|
39
|
+
* Corresponds to HTTP status code 404 Not Found.
|
|
40
|
+
*/
|
|
41
|
+
export declare class NotFoundError extends HttpError {
|
|
42
|
+
constructor(message: string, code?: string);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Indicates that the action conflicts with the current state.
|
|
46
|
+
* Corresponds to HTTP status code 409 Conflict.
|
|
47
|
+
*/
|
|
48
|
+
export declare class ConflictError extends HttpError {
|
|
49
|
+
constructor(message: string, code?: string);
|
|
50
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base class for errors that will be handled as HTTP status codes.
|
|
3
|
+
*/
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
constructor(status, message, code) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Indicates a malformed request.
|
|
13
|
+
* Corresponds to HTTP status code 400 Bad Request.
|
|
14
|
+
*/
|
|
15
|
+
export class BadRequestError extends HttpError {
|
|
16
|
+
constructor(message, code) {
|
|
17
|
+
super(400, message, code);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Indicates missing or invalid credentials.
|
|
22
|
+
* Corresponds to HTTP status code 401 Unauthorized.
|
|
23
|
+
*/
|
|
24
|
+
export class UnauthorizedError extends HttpError {
|
|
25
|
+
constructor(message, code) {
|
|
26
|
+
super(401, message, code);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Indicates that some kind of payment is required.
|
|
31
|
+
* Corresponds to HTTP status code 402 Payment Required.
|
|
32
|
+
*/
|
|
33
|
+
export class PaymentRequiredError extends HttpError {
|
|
34
|
+
constructor(message, code) {
|
|
35
|
+
super(402, message, code);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Indicates that the user is not allowed to do something.
|
|
40
|
+
* Corresponds to HTTP status code 403 Forbidden.
|
|
41
|
+
*/
|
|
42
|
+
export class ForbiddenError extends HttpError {
|
|
43
|
+
constructor(message, code) {
|
|
44
|
+
super(403, message, code);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Indicates that the requested resource does not exist.
|
|
49
|
+
* Corresponds to HTTP status code 404 Not Found.
|
|
50
|
+
*/
|
|
51
|
+
export class NotFoundError extends HttpError {
|
|
52
|
+
constructor(message, code) {
|
|
53
|
+
super(404, message, code);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Indicates that the action conflicts with the current state.
|
|
58
|
+
* Corresponds to HTTP status code 409 Conflict.
|
|
59
|
+
*/
|
|
60
|
+
export class ConflictError extends HttpError {
|
|
61
|
+
constructor(message, code) {
|
|
62
|
+
super(409, message, code);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export declare class Log {
|
|
2
|
+
/**
|
|
3
|
+
* Output a debug message.
|
|
4
|
+
* @param args - The message.
|
|
5
|
+
*/
|
|
6
|
+
debug(...args: unknown[]): void;
|
|
7
|
+
/**
|
|
8
|
+
* Output an informational message.
|
|
9
|
+
* @param args - The message.
|
|
10
|
+
*/
|
|
11
|
+
info(...args: unknown[]): void;
|
|
12
|
+
/**
|
|
13
|
+
* Output a warning message.
|
|
14
|
+
* @param args - The message.
|
|
15
|
+
*/
|
|
16
|
+
warning(...args: unknown[]): void;
|
|
17
|
+
/**
|
|
18
|
+
* Output an error message.
|
|
19
|
+
* @param args - The message.
|
|
20
|
+
*/
|
|
21
|
+
error(...args: unknown[]): void;
|
|
22
|
+
}
|