shokupan 0.7.0 → 0.9.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/README.md +53 -0
- package/dist/context.d.ts +50 -15
- package/dist/{http-server-DFhwlK8e.cjs → http-server-BEMPIs33.cjs} +4 -2
- package/dist/http-server-BEMPIs33.cjs.map +1 -0
- package/dist/{http-server-0xH174zz.js → http-server-CCeagTyU.js} +4 -2
- package/dist/http-server-CCeagTyU.js.map +1 -0
- package/dist/index.cjs +998 -136
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +996 -135
- package/dist/index.js.map +1 -1
- package/dist/plugins/application/dashboard/metrics-collector.d.ts +12 -0
- package/dist/plugins/application/dashboard/plugin.d.ts +14 -8
- package/dist/plugins/application/dashboard/static/charts.js +328 -0
- package/dist/plugins/application/dashboard/static/failures.js +85 -0
- package/dist/plugins/application/dashboard/static/graph.mjs +523 -0
- package/dist/plugins/application/dashboard/static/poll.js +146 -0
- package/dist/plugins/application/dashboard/static/reactflow.css +18 -0
- package/dist/plugins/application/dashboard/static/registry.css +131 -0
- package/dist/plugins/application/dashboard/static/registry.js +269 -0
- package/dist/plugins/application/dashboard/static/requests.js +118 -0
- package/dist/plugins/application/dashboard/static/scrollbar.css +24 -0
- package/dist/plugins/application/dashboard/static/styles.css +175 -0
- package/dist/plugins/application/dashboard/static/tables.js +92 -0
- package/dist/plugins/application/dashboard/static/tabs.js +113 -0
- package/dist/plugins/application/dashboard/static/tabulator.css +66 -0
- package/dist/plugins/application/dashboard/template.eta +246 -0
- package/dist/plugins/application/socket-io.d.ts +14 -0
- package/dist/router.d.ts +12 -0
- package/dist/shokupan.d.ts +21 -1
- package/dist/util/datastore.d.ts +4 -3
- package/dist/util/decorators.d.ts +5 -0
- package/dist/util/http-error.d.ts +38 -0
- package/dist/util/http-status.d.ts +30 -0
- package/dist/util/request.d.ts +1 -1
- package/dist/util/symbol.d.ts +19 -0
- package/dist/util/types.d.ts +30 -1
- package/package.json +6 -3
- package/dist/http-server-0xH174zz.js.map +0 -1
- package/dist/http-server-DFhwlK8e.cjs.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { nanoid } from "nanoid";
|
|
1
2
|
import { readFile } from "node:fs/promises";
|
|
2
3
|
import { trace, SpanKind, SpanStatusCode, context } from "@opentelemetry/api";
|
|
3
4
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
import { Surreal, RecordId } from "surrealdb";
|
|
4
6
|
import { Eta } from "eta";
|
|
5
7
|
import { stat, readdir, readFile as readFile$1 } from "fs/promises";
|
|
6
8
|
import { resolve, join, sep, basename } from "path";
|
|
@@ -10,12 +12,45 @@ import { OAuth2Client, Okta, Auth0, Apple, MicrosoftEntraId, Google, GitHub, gen
|
|
|
10
12
|
import * as jose from "jose";
|
|
11
13
|
import cluster from "node:cluster";
|
|
12
14
|
import net from "node:net";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import { monitorEventLoopDelay } from "node:perf_hooks";
|
|
13
18
|
import { OpenAPIAnalyzer } from "./analyzer-Ce_7JxZh.js";
|
|
14
19
|
import * as zlib from "node:zlib";
|
|
15
20
|
import Ajv from "ajv";
|
|
16
21
|
import addFormats from "ajv-formats";
|
|
17
22
|
import { randomUUID, createHmac } from "crypto";
|
|
18
23
|
import { EventEmitter } from "events";
|
|
24
|
+
const HTTP_STATUS = {
|
|
25
|
+
// 2xx Success
|
|
26
|
+
OK: 200,
|
|
27
|
+
CREATED: 201,
|
|
28
|
+
ACCEPTED: 202,
|
|
29
|
+
NO_CONTENT: 204,
|
|
30
|
+
// 3xx Redirection
|
|
31
|
+
MOVED_PERMANENTLY: 301,
|
|
32
|
+
FOUND: 302,
|
|
33
|
+
SEE_OTHER: 303,
|
|
34
|
+
NOT_MODIFIED: 304,
|
|
35
|
+
TEMPORARY_REDIRECT: 307,
|
|
36
|
+
PERMANENT_REDIRECT: 308,
|
|
37
|
+
// 4xx Client Errors
|
|
38
|
+
BAD_REQUEST: 400,
|
|
39
|
+
UNAUTHORIZED: 401,
|
|
40
|
+
FORBIDDEN: 403,
|
|
41
|
+
NOT_FOUND: 404,
|
|
42
|
+
METHOD_NOT_ALLOWED: 405,
|
|
43
|
+
REQUEST_TIMEOUT: 408,
|
|
44
|
+
CONFLICT: 409,
|
|
45
|
+
UNPROCESSABLE_ENTITY: 422,
|
|
46
|
+
TOO_MANY_REQUESTS: 429,
|
|
47
|
+
// 5xx Server Errors
|
|
48
|
+
INTERNAL_SERVER_ERROR: 500,
|
|
49
|
+
NOT_IMPLEMENTED: 501,
|
|
50
|
+
BAD_GATEWAY: 502,
|
|
51
|
+
SERVICE_UNAVAILABLE: 503,
|
|
52
|
+
GATEWAY_TIMEOUT: 504
|
|
53
|
+
};
|
|
19
54
|
const VALID_HTTP_STATUSES = /* @__PURE__ */ new Set([
|
|
20
55
|
100,
|
|
21
56
|
101,
|
|
@@ -145,6 +180,40 @@ class ShokupanResponse {
|
|
|
145
180
|
return this._headers !== null;
|
|
146
181
|
}
|
|
147
182
|
}
|
|
183
|
+
const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
|
|
184
|
+
const $appRoot = /* @__PURE__ */ Symbol.for("Shokupan.app-root");
|
|
185
|
+
const $isMounted = /* @__PURE__ */ Symbol("Shokupan.isMounted");
|
|
186
|
+
const $routeMethods = /* @__PURE__ */ Symbol("Shokupan.routeMethods");
|
|
187
|
+
const $eventMethods = /* @__PURE__ */ Symbol("Shokupan.eventMethods");
|
|
188
|
+
const $routeArgs = /* @__PURE__ */ Symbol("Shokupan.routeArgs");
|
|
189
|
+
const $controllerPath = /* @__PURE__ */ Symbol("Shokupan.controllerPath");
|
|
190
|
+
const $middleware = /* @__PURE__ */ Symbol("Shokupan.middleware");
|
|
191
|
+
const $isRouter = /* @__PURE__ */ Symbol.for("Shokupan.router");
|
|
192
|
+
const $parent = /* @__PURE__ */ Symbol.for("Shokupan.parent");
|
|
193
|
+
const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
|
|
194
|
+
const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
|
|
195
|
+
const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
|
|
196
|
+
const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
|
|
197
|
+
const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
|
|
198
|
+
const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
|
|
199
|
+
const $url = /* @__PURE__ */ Symbol.for("Shokupan.ctx.url");
|
|
200
|
+
const $requestId = /* @__PURE__ */ Symbol.for("Shokupan.ctx.requestId");
|
|
201
|
+
const $debug = /* @__PURE__ */ Symbol.for("Shokupan.ctx.debug");
|
|
202
|
+
const $finalResponse = /* @__PURE__ */ Symbol.for("Shokupan.ctx.finalResponse");
|
|
203
|
+
const $rawBody = /* @__PURE__ */ Symbol.for("Shokupan.ctx.rawBody");
|
|
204
|
+
const $cachedBody = /* @__PURE__ */ Symbol.for("Shokupan.ctx.cachedBody");
|
|
205
|
+
const $bodyType = /* @__PURE__ */ Symbol.for("Shokupan.ctx.bodyType");
|
|
206
|
+
const $bodyParsed = /* @__PURE__ */ Symbol.for("Shokupan.ctx.bodyParsed");
|
|
207
|
+
const $bodyParseError = /* @__PURE__ */ Symbol.for("Shokupan.ctx.bodyParseError");
|
|
208
|
+
const $routeMatched = /* @__PURE__ */ Symbol.for("Shokupan.ctx.routeMatched");
|
|
209
|
+
const $cachedHostname = /* @__PURE__ */ Symbol.for("Shokupan.ctx.cachedHostname");
|
|
210
|
+
const $cachedProtocol = /* @__PURE__ */ Symbol.for("Shokupan.ctx.cachedProtocol");
|
|
211
|
+
const $cachedHost = /* @__PURE__ */ Symbol.for("Shokupan.ctx.cachedHost");
|
|
212
|
+
const $cachedOrigin = /* @__PURE__ */ Symbol.for("Shokupan.ctx.cachedOrigin");
|
|
213
|
+
const $cachedQuery = /* @__PURE__ */ Symbol.for("Shokupan.ctx.cachedQuery");
|
|
214
|
+
const $ws = /* @__PURE__ */ Symbol.for("Shokupan.ctx.ws");
|
|
215
|
+
const $socket = /* @__PURE__ */ Symbol.for("Shokupan.ctx.socket");
|
|
216
|
+
const $io = /* @__PURE__ */ Symbol.for("Shokupan.ctx.io");
|
|
148
217
|
function isValidCookieDomain(domain, currentHost) {
|
|
149
218
|
const hostWithoutPort = currentHost.split(":")[0];
|
|
150
219
|
if (domain === hostWithoutPort) return true;
|
|
@@ -182,23 +251,26 @@ class ShokupanContext {
|
|
|
182
251
|
state;
|
|
183
252
|
handlerStack = [];
|
|
184
253
|
response;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
254
|
+
[$debug];
|
|
255
|
+
[$finalResponse];
|
|
256
|
+
[$rawBody];
|
|
188
257
|
// Raw body for compression optimization
|
|
189
258
|
// Body caching to avoid double parsing
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
259
|
+
[$url];
|
|
260
|
+
[$cachedBody];
|
|
261
|
+
[$bodyType];
|
|
262
|
+
[$bodyParsed] = false;
|
|
263
|
+
[$bodyParseError];
|
|
264
|
+
[$routeMatched] = false;
|
|
196
265
|
// Cached URL properties to avoid repeated parsing
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
266
|
+
[$cachedHostname];
|
|
267
|
+
[$cachedProtocol];
|
|
268
|
+
[$cachedHost];
|
|
269
|
+
[$cachedOrigin];
|
|
270
|
+
[$cachedQuery];
|
|
271
|
+
[$ws];
|
|
272
|
+
[$socket];
|
|
273
|
+
[$io];
|
|
202
274
|
/**
|
|
203
275
|
* JSX Rendering Function
|
|
204
276
|
*/
|
|
@@ -206,12 +278,16 @@ class ShokupanContext {
|
|
|
206
278
|
setRenderer(renderer) {
|
|
207
279
|
this.renderer = renderer;
|
|
208
280
|
}
|
|
281
|
+
[$requestId];
|
|
282
|
+
get requestId() {
|
|
283
|
+
return this[$requestId] ??= this.app?.applicationConfig?.idGenerator?.() ?? nanoid();
|
|
284
|
+
}
|
|
209
285
|
get url() {
|
|
210
|
-
if (!this
|
|
286
|
+
if (!this[$url]) {
|
|
211
287
|
const urlString = this.request.url || "http://localhost/";
|
|
212
|
-
this
|
|
288
|
+
this[$url] = new URL(urlString);
|
|
213
289
|
}
|
|
214
|
-
return this
|
|
290
|
+
return this[$url];
|
|
215
291
|
}
|
|
216
292
|
/**
|
|
217
293
|
* Base request
|
|
@@ -229,7 +305,7 @@ class ShokupanContext {
|
|
|
229
305
|
* Request path
|
|
230
306
|
*/
|
|
231
307
|
get path() {
|
|
232
|
-
if (this
|
|
308
|
+
if (this[$url]) return this[$url].pathname;
|
|
233
309
|
const url = this.request.url;
|
|
234
310
|
let queryIndex = url.indexOf("?");
|
|
235
311
|
const end = queryIndex === -1 ? url.length : queryIndex;
|
|
@@ -254,7 +330,7 @@ class ShokupanContext {
|
|
|
254
330
|
* Request query params
|
|
255
331
|
*/
|
|
256
332
|
get query() {
|
|
257
|
-
if (this
|
|
333
|
+
if (this[$cachedQuery]) return this[$cachedQuery];
|
|
258
334
|
const q = /* @__PURE__ */ Object.create(null);
|
|
259
335
|
const blocklist = ["__proto__", "constructor", "prototype"];
|
|
260
336
|
const entries = Object.entries(this.url.searchParams);
|
|
@@ -271,7 +347,7 @@ class ShokupanContext {
|
|
|
271
347
|
q[key] = value;
|
|
272
348
|
}
|
|
273
349
|
}
|
|
274
|
-
this
|
|
350
|
+
this[$cachedQuery] = q;
|
|
275
351
|
return q;
|
|
276
352
|
}
|
|
277
353
|
/**
|
|
@@ -284,19 +360,19 @@ class ShokupanContext {
|
|
|
284
360
|
* Request hostname (e.g. "localhost")
|
|
285
361
|
*/
|
|
286
362
|
get hostname() {
|
|
287
|
-
return this
|
|
363
|
+
return this[$cachedHostname] ??= this.url.hostname;
|
|
288
364
|
}
|
|
289
365
|
/**
|
|
290
366
|
* Request host (e.g. "localhost:3000")
|
|
291
367
|
*/
|
|
292
368
|
get host() {
|
|
293
|
-
return this
|
|
369
|
+
return this[$cachedHost] ??= this.url.host;
|
|
294
370
|
}
|
|
295
371
|
/**
|
|
296
372
|
* Request protocol (e.g. "http:", "https:")
|
|
297
373
|
*/
|
|
298
374
|
get protocol() {
|
|
299
|
-
return this
|
|
375
|
+
return this[$cachedProtocol] ??= this.url.protocol;
|
|
300
376
|
}
|
|
301
377
|
/**
|
|
302
378
|
* Whether request is secure (https)
|
|
@@ -308,7 +384,7 @@ class ShokupanContext {
|
|
|
308
384
|
* Request origin (e.g. "http://localhost:3000")
|
|
309
385
|
*/
|
|
310
386
|
get origin() {
|
|
311
|
-
return this
|
|
387
|
+
return this[$cachedOrigin] ??= this.url.origin;
|
|
312
388
|
}
|
|
313
389
|
/**
|
|
314
390
|
* Request headers
|
|
@@ -329,6 +405,24 @@ class ShokupanContext {
|
|
|
329
405
|
get res() {
|
|
330
406
|
return this.response;
|
|
331
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Raw WebSocket connection
|
|
410
|
+
*/
|
|
411
|
+
get ws() {
|
|
412
|
+
return this[$ws];
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Socket.io socket
|
|
416
|
+
*/
|
|
417
|
+
get socket() {
|
|
418
|
+
return this[$socket];
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Socket.io server
|
|
422
|
+
*/
|
|
423
|
+
get io() {
|
|
424
|
+
return this[$io];
|
|
425
|
+
}
|
|
332
426
|
/**
|
|
333
427
|
* Helper to set a header on the response
|
|
334
428
|
* @param key Header key
|
|
@@ -338,6 +432,20 @@ class ShokupanContext {
|
|
|
338
432
|
this.response.set(key, value);
|
|
339
433
|
return this;
|
|
340
434
|
}
|
|
435
|
+
isUpgraded = false;
|
|
436
|
+
/**
|
|
437
|
+
* Upgrades the request to a WebSocket connection.
|
|
438
|
+
* @param options Upgrade options
|
|
439
|
+
* @returns true if upgraded, false otherwise
|
|
440
|
+
*/
|
|
441
|
+
upgrade(options) {
|
|
442
|
+
if (!this.server) return false;
|
|
443
|
+
const success = this.server.upgrade(this.req, options);
|
|
444
|
+
if (success) {
|
|
445
|
+
this.isUpgraded = true;
|
|
446
|
+
}
|
|
447
|
+
return success;
|
|
448
|
+
}
|
|
341
449
|
/**
|
|
342
450
|
* Set a cookie
|
|
343
451
|
* @param name Cookie name
|
|
@@ -415,18 +523,18 @@ class ShokupanContext {
|
|
|
415
523
|
* The body is only parsed once and cached for subsequent reads.
|
|
416
524
|
*/
|
|
417
525
|
async body() {
|
|
418
|
-
if (this
|
|
419
|
-
throw this
|
|
526
|
+
if (this[$bodyParseError]) {
|
|
527
|
+
throw this[$bodyParseError];
|
|
420
528
|
}
|
|
421
|
-
if (this
|
|
422
|
-
return this
|
|
529
|
+
if (this[$bodyParsed]) {
|
|
530
|
+
return this[$cachedBody];
|
|
423
531
|
}
|
|
424
532
|
const contentType = this.request.headers.get("content-type") || "";
|
|
425
533
|
if (contentType.includes("application/json") || contentType.includes("+json")) {
|
|
426
534
|
const parserType = this.app?.applicationConfig?.jsonParser || "native";
|
|
427
535
|
if (parserType === "native") {
|
|
428
536
|
try {
|
|
429
|
-
this
|
|
537
|
+
this[$cachedBody] = await this.request.json();
|
|
430
538
|
} catch (e) {
|
|
431
539
|
throw e;
|
|
432
540
|
}
|
|
@@ -434,18 +542,18 @@ class ShokupanContext {
|
|
|
434
542
|
const rawText = await this.request.text();
|
|
435
543
|
const { getJSONParser } = await import("./json-parser-B3dnQmCC.js");
|
|
436
544
|
const parser = getJSONParser(parserType);
|
|
437
|
-
this
|
|
545
|
+
this[$cachedBody] = parser(rawText);
|
|
438
546
|
}
|
|
439
|
-
this
|
|
547
|
+
this[$bodyType] = "json";
|
|
440
548
|
} else if (contentType.includes("multipart/form-data") || contentType.includes("application/x-www-form-urlencoded")) {
|
|
441
|
-
this
|
|
442
|
-
this
|
|
549
|
+
this[$cachedBody] = await this.request.formData();
|
|
550
|
+
this[$bodyType] = "formData";
|
|
443
551
|
} else {
|
|
444
|
-
this
|
|
445
|
-
this
|
|
552
|
+
this[$cachedBody] = await this.request.text();
|
|
553
|
+
this[$bodyType] = "text";
|
|
446
554
|
}
|
|
447
|
-
this
|
|
448
|
-
return this
|
|
555
|
+
this[$bodyParsed] = true;
|
|
556
|
+
return this[$cachedBody];
|
|
449
557
|
}
|
|
450
558
|
/**
|
|
451
559
|
* Pre-parse the request body before handler execution.
|
|
@@ -453,7 +561,7 @@ class ShokupanContext {
|
|
|
453
561
|
* Errors are deferred until the body is actually accessed in the handler.
|
|
454
562
|
*/
|
|
455
563
|
async parseBody() {
|
|
456
|
-
if (this
|
|
564
|
+
if (this[$bodyParsed]) {
|
|
457
565
|
return;
|
|
458
566
|
}
|
|
459
567
|
if (this.request.method === "GET" || this.request.method === "HEAD") {
|
|
@@ -462,7 +570,7 @@ class ShokupanContext {
|
|
|
462
570
|
try {
|
|
463
571
|
await this.body();
|
|
464
572
|
} catch (error) {
|
|
465
|
-
this
|
|
573
|
+
this[$bodyParseError] = error;
|
|
466
574
|
}
|
|
467
575
|
}
|
|
468
576
|
/**
|
|
@@ -512,10 +620,21 @@ class ShokupanContext {
|
|
|
512
620
|
throw new Error(`Invalid HTTP status code: ${status}`);
|
|
513
621
|
}
|
|
514
622
|
if (typeof body === "string" || body instanceof ArrayBuffer || body instanceof Uint8Array) {
|
|
515
|
-
this
|
|
623
|
+
this[$rawBody] = body;
|
|
624
|
+
}
|
|
625
|
+
return this[$finalResponse] ??= new Response(body, { status, headers });
|
|
626
|
+
}
|
|
627
|
+
/**
|
|
628
|
+
* Emit an event to the client (WebSocket only)
|
|
629
|
+
* @param event Event name
|
|
630
|
+
* @param data Event data (Must be JSON serializable)
|
|
631
|
+
*/
|
|
632
|
+
emit(event, data) {
|
|
633
|
+
if (this[$ws]) {
|
|
634
|
+
this[$ws].send(JSON.stringify({ event, data }));
|
|
635
|
+
} else if (this[$socket]) {
|
|
636
|
+
this[$socket].emit(event, data);
|
|
516
637
|
}
|
|
517
|
-
this._finalResponse = new Response(body, { status, headers });
|
|
518
|
-
return this._finalResponse;
|
|
519
638
|
}
|
|
520
639
|
/**
|
|
521
640
|
* Respond with a JSON object
|
|
@@ -526,18 +645,18 @@ class ShokupanContext {
|
|
|
526
645
|
throw new Error(`Invalid HTTP status code: ${finalStatus}`);
|
|
527
646
|
}
|
|
528
647
|
const jsonString = JSON.stringify(data);
|
|
529
|
-
this
|
|
648
|
+
this[$rawBody] = jsonString;
|
|
530
649
|
if (!headers && !this.response.hasPopulatedHeaders) {
|
|
531
|
-
this
|
|
650
|
+
this[$finalResponse] = new Response(jsonString, {
|
|
532
651
|
status: finalStatus,
|
|
533
652
|
headers: { "content-type": "application/json" }
|
|
534
653
|
});
|
|
535
|
-
return this
|
|
654
|
+
return this[$finalResponse];
|
|
536
655
|
}
|
|
537
656
|
const finalHeaders = this.mergeHeaders(headers);
|
|
538
657
|
finalHeaders.set("content-type", "application/json");
|
|
539
|
-
this
|
|
540
|
-
return this
|
|
658
|
+
this[$finalResponse] = new Response(jsonString, { status: finalStatus, headers: finalHeaders });
|
|
659
|
+
return this[$finalResponse];
|
|
541
660
|
}
|
|
542
661
|
/**
|
|
543
662
|
* Respond with a text string
|
|
@@ -547,18 +666,18 @@ class ShokupanContext {
|
|
|
547
666
|
if (this.app.applicationConfig.validateStatusCodes && !VALID_HTTP_STATUSES.has(finalStatus)) {
|
|
548
667
|
throw new Error(`Invalid HTTP status code: ${finalStatus}`);
|
|
549
668
|
}
|
|
550
|
-
this
|
|
669
|
+
this[$rawBody] = data;
|
|
551
670
|
if (!headers && !this.response.hasPopulatedHeaders) {
|
|
552
|
-
this
|
|
671
|
+
this[$finalResponse] = new Response(data, {
|
|
553
672
|
status: finalStatus,
|
|
554
673
|
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
555
674
|
});
|
|
556
|
-
return this
|
|
675
|
+
return this[$finalResponse];
|
|
557
676
|
}
|
|
558
677
|
const finalHeaders = this.mergeHeaders(headers);
|
|
559
678
|
finalHeaders.set("content-type", "text/plain; charset=utf-8");
|
|
560
|
-
this
|
|
561
|
-
return this
|
|
679
|
+
this[$finalResponse] = new Response(data, { status: finalStatus, headers: finalHeaders });
|
|
680
|
+
return this[$finalResponse];
|
|
562
681
|
}
|
|
563
682
|
/**
|
|
564
683
|
* Respond with HTML content
|
|
@@ -570,9 +689,9 @@ class ShokupanContext {
|
|
|
570
689
|
}
|
|
571
690
|
const finalHeaders = this.mergeHeaders(headers);
|
|
572
691
|
finalHeaders.set("content-type", "text/html; charset=utf-8");
|
|
573
|
-
this
|
|
574
|
-
this
|
|
575
|
-
return this
|
|
692
|
+
this[$rawBody] = html;
|
|
693
|
+
this[$finalResponse] = new Response(html, { status: finalStatus, headers: finalHeaders });
|
|
694
|
+
return this[$finalResponse];
|
|
576
695
|
}
|
|
577
696
|
/**
|
|
578
697
|
* Respond with a redirect
|
|
@@ -583,8 +702,8 @@ class ShokupanContext {
|
|
|
583
702
|
}
|
|
584
703
|
const headers = this.mergeHeaders();
|
|
585
704
|
headers.set("Location", url);
|
|
586
|
-
this
|
|
587
|
-
return this
|
|
705
|
+
this[$finalResponse] = new Response(null, { status, headers });
|
|
706
|
+
return this[$finalResponse];
|
|
588
707
|
}
|
|
589
708
|
/**
|
|
590
709
|
* Respond with a status code
|
|
@@ -595,8 +714,8 @@ class ShokupanContext {
|
|
|
595
714
|
throw new Error(`Invalid HTTP status code: ${status}`);
|
|
596
715
|
}
|
|
597
716
|
const headers = this.mergeHeaders();
|
|
598
|
-
this
|
|
599
|
-
return this
|
|
717
|
+
this[$finalResponse] = new Response(null, { status, headers });
|
|
718
|
+
return this[$finalResponse];
|
|
600
719
|
}
|
|
601
720
|
/**
|
|
602
721
|
* Respond with a file
|
|
@@ -608,15 +727,15 @@ class ShokupanContext {
|
|
|
608
727
|
throw new Error(`Invalid HTTP status code: ${status}`);
|
|
609
728
|
}
|
|
610
729
|
if (typeof Bun !== "undefined") {
|
|
611
|
-
this
|
|
612
|
-
return this
|
|
730
|
+
this[$finalResponse] = new Response(Bun.file(path, fileOptions), { status, headers });
|
|
731
|
+
return this[$finalResponse];
|
|
613
732
|
} else {
|
|
614
733
|
const fileBuffer = await readFile(path);
|
|
615
734
|
if (fileOptions?.type) {
|
|
616
735
|
headers.set("content-type", fileOptions.type);
|
|
617
736
|
}
|
|
618
|
-
this
|
|
619
|
-
return this
|
|
737
|
+
this[$finalResponse] = new Response(fileBuffer, { status, headers });
|
|
738
|
+
return this[$finalResponse];
|
|
620
739
|
}
|
|
621
740
|
}
|
|
622
741
|
/**
|
|
@@ -652,10 +771,10 @@ const compose = (middleware) => {
|
|
|
652
771
|
return next ? next() : Promise.resolve();
|
|
653
772
|
}
|
|
654
773
|
const fn = middleware[i];
|
|
655
|
-
if (!context2
|
|
774
|
+
if (!context2[$debug]) {
|
|
656
775
|
return fn(context2, () => runner(i + 1));
|
|
657
776
|
}
|
|
658
|
-
const debug = context2
|
|
777
|
+
const debug = context2[$debug];
|
|
659
778
|
const debugId = fn._debugId || fn.name || "anonymous";
|
|
660
779
|
const previousNode = debug.getCurrentNode();
|
|
661
780
|
debug.trackEdge(previousNode, debugId);
|
|
@@ -731,21 +850,6 @@ function deepMerge(target, ...sources) {
|
|
|
731
850
|
}
|
|
732
851
|
return deepMerge(target, ...sources);
|
|
733
852
|
}
|
|
734
|
-
const $isApplication = /* @__PURE__ */ Symbol.for("Shokupan.app");
|
|
735
|
-
const $appRoot = /* @__PURE__ */ Symbol.for("Shokupan.app-root");
|
|
736
|
-
const $isMounted = /* @__PURE__ */ Symbol("Shokupan.isMounted");
|
|
737
|
-
const $routeMethods = /* @__PURE__ */ Symbol("Shokupan.routeMethods");
|
|
738
|
-
const $routeArgs = /* @__PURE__ */ Symbol("Shokupan.routeArgs");
|
|
739
|
-
const $controllerPath = /* @__PURE__ */ Symbol("Shokupan.controllerPath");
|
|
740
|
-
const $middleware = /* @__PURE__ */ Symbol("Shokupan.middleware");
|
|
741
|
-
const $isRouter = /* @__PURE__ */ Symbol.for("Shokupan.router");
|
|
742
|
-
const $parent = /* @__PURE__ */ Symbol.for("Shokupan.parent");
|
|
743
|
-
const $childRouters = /* @__PURE__ */ Symbol.for("Shokupan.child-routers");
|
|
744
|
-
const $childControllers = /* @__PURE__ */ Symbol.for("Shokupan.child-controllers");
|
|
745
|
-
const $mountPath = /* @__PURE__ */ Symbol.for("Shokupan.mount-path");
|
|
746
|
-
const $dispatch = /* @__PURE__ */ Symbol.for("Shokupan.dispatch");
|
|
747
|
-
const $routes = /* @__PURE__ */ Symbol.for("Shokupan.routes");
|
|
748
|
-
const $routeSpec = /* @__PURE__ */ Symbol.for("Shokupan.routeSpec");
|
|
749
853
|
const REGEX_PATTERNS = {
|
|
750
854
|
QUERY_INT: /parseInt\(ctx\.query\.(\w+)\)/g,
|
|
751
855
|
QUERY_FLOAT: /parseFloat\(ctx\.query\.(\w+)\)/g,
|
|
@@ -1132,6 +1236,35 @@ class RequestContextStore {
|
|
|
1132
1236
|
span;
|
|
1133
1237
|
}
|
|
1134
1238
|
const asyncContext = new AsyncLocalStorage();
|
|
1239
|
+
class HttpError extends Error {
|
|
1240
|
+
status;
|
|
1241
|
+
constructor(message, status) {
|
|
1242
|
+
super(message);
|
|
1243
|
+
this.name = "HttpError";
|
|
1244
|
+
this.status = status;
|
|
1245
|
+
if (Error.captureStackTrace) {
|
|
1246
|
+
Error.captureStackTrace(this, HttpError);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
function getErrorStatus(err) {
|
|
1251
|
+
if (!err || typeof err !== "object") {
|
|
1252
|
+
return 500;
|
|
1253
|
+
}
|
|
1254
|
+
if (typeof err.status === "number") {
|
|
1255
|
+
return err.status;
|
|
1256
|
+
}
|
|
1257
|
+
if (typeof err.statusCode === "number") {
|
|
1258
|
+
return err.statusCode;
|
|
1259
|
+
}
|
|
1260
|
+
return 500;
|
|
1261
|
+
}
|
|
1262
|
+
class EventError extends HttpError {
|
|
1263
|
+
constructor(message = "Event Error") {
|
|
1264
|
+
super(message, 500);
|
|
1265
|
+
this.name = "EventError";
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1135
1268
|
const eta$1 = new Eta();
|
|
1136
1269
|
function serveStatic(config, prefix) {
|
|
1137
1270
|
const rootPath = resolve(config.root || ".");
|
|
@@ -1284,18 +1417,16 @@ function serveStatic(config, prefix) {
|
|
|
1284
1417
|
serveStaticMiddleware.pluginName = "ServeStatic";
|
|
1285
1418
|
return serveStaticMiddleware;
|
|
1286
1419
|
}
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1420
|
+
const G = globalThis;
|
|
1421
|
+
G.__shokupan_db = G.__shokupan_db || null;
|
|
1422
|
+
G.__shokupan_db_promise = G.__shokupan_db_promise || null;
|
|
1290
1423
|
async function ensureDb() {
|
|
1291
|
-
if (
|
|
1292
|
-
if (
|
|
1293
|
-
|
|
1424
|
+
if (G.__shokupan_db) return G.__shokupan_db;
|
|
1425
|
+
if (G.__shokupan_db_promise) return G.__shokupan_db_promise;
|
|
1426
|
+
G.__shokupan_db_promise = (async () => {
|
|
1294
1427
|
try {
|
|
1295
1428
|
const { createNodeEngines } = await import("@surrealdb/node");
|
|
1296
1429
|
const surreal = await import("surrealdb");
|
|
1297
|
-
const Surreal = surreal.Surreal;
|
|
1298
|
-
RecordId = surreal.RecordId;
|
|
1299
1430
|
const engine = process.env["SHOKUPAN_DB_ENGINE"] === "memory" ? "mem://" : "rocksdb://database";
|
|
1300
1431
|
const _db = new Surreal({
|
|
1301
1432
|
engines: createNodeEngines()
|
|
@@ -1308,33 +1439,33 @@ async function ensureDb() {
|
|
|
1308
1439
|
DEFINE TABLE OVERWRITE idempotency_keys SCHEMALESS COMMENT "Created by Shokupan";
|
|
1309
1440
|
DEFINE TABLE OVERWRITE middleware_tracking SCHEMALESS COMMENT "Created by Shokupan";
|
|
1310
1441
|
DEFINE TABLE OVERWRITE requests SCHEMALESS COMMENT "Created by Shokupan";
|
|
1442
|
+
DEFINE TABLE OVERWRITE metrics SCHEMALESS COMMENT "Created by Shokupan";
|
|
1311
1443
|
`);
|
|
1312
|
-
|
|
1313
|
-
return
|
|
1444
|
+
G.__shokupan_db = _db;
|
|
1445
|
+
return _db;
|
|
1314
1446
|
} catch (e) {
|
|
1315
|
-
|
|
1447
|
+
G.__shokupan_db_promise = null;
|
|
1316
1448
|
if (e.code === "ERR_MODULE_NOT_FOUND" || e.message.includes("Cannot find module")) {
|
|
1317
1449
|
throw new Error("SurrealDB dependencies not found. To use the datastore, please install 'surrealdb' and '@surrealdb/node'.");
|
|
1318
1450
|
}
|
|
1319
1451
|
throw e;
|
|
1320
1452
|
}
|
|
1321
1453
|
})();
|
|
1322
|
-
return
|
|
1454
|
+
return G.__shokupan_db_promise;
|
|
1323
1455
|
}
|
|
1324
1456
|
const datastore = {
|
|
1325
|
-
async get(
|
|
1457
|
+
async get(recordId) {
|
|
1326
1458
|
await ensureDb();
|
|
1327
|
-
return
|
|
1459
|
+
return G.__shokupan_db.select(recordId);
|
|
1328
1460
|
},
|
|
1329
|
-
async set(
|
|
1461
|
+
async set(recordId, value) {
|
|
1330
1462
|
await ensureDb();
|
|
1331
|
-
return
|
|
1463
|
+
return G.__shokupan_db.upsert(recordId).content(value);
|
|
1332
1464
|
},
|
|
1333
1465
|
async query(query, vars) {
|
|
1334
1466
|
await ensureDb();
|
|
1335
1467
|
try {
|
|
1336
|
-
|
|
1337
|
-
return Array.isArray(r) ? r : r?.collect ? await r.collect() : r;
|
|
1468
|
+
return G.__shokupan_db.query(query, vars).collect();
|
|
1338
1469
|
} catch (e) {
|
|
1339
1470
|
console.error("DS ERROR:", e);
|
|
1340
1471
|
throw e;
|
|
@@ -1345,7 +1476,7 @@ const datastore = {
|
|
|
1345
1476
|
}
|
|
1346
1477
|
};
|
|
1347
1478
|
process.on("exit", async () => {
|
|
1348
|
-
if (
|
|
1479
|
+
if (G.__shokupan_db) await G.__shokupan_db.close();
|
|
1349
1480
|
});
|
|
1350
1481
|
class Container {
|
|
1351
1482
|
static services = /* @__PURE__ */ new Map();
|
|
@@ -1576,6 +1707,7 @@ class ShokupanRouter {
|
|
|
1576
1707
|
metadata;
|
|
1577
1708
|
// Metadata for the router itself
|
|
1578
1709
|
currentGuards = [];
|
|
1710
|
+
eventHandlers = /* @__PURE__ */ new Map();
|
|
1579
1711
|
// Registry Accessor
|
|
1580
1712
|
getComponentRegistry() {
|
|
1581
1713
|
const controllerRoutesMap = /* @__PURE__ */ new Map();
|
|
@@ -1636,6 +1768,34 @@ class ShokupanRouter {
|
|
|
1636
1768
|
isRouterInstance(target) {
|
|
1637
1769
|
return typeof target === "object" && target !== null && $isRouter in target;
|
|
1638
1770
|
}
|
|
1771
|
+
/**
|
|
1772
|
+
* Registers an event handler for WebSocket.
|
|
1773
|
+
*/
|
|
1774
|
+
event(name, handler) {
|
|
1775
|
+
if (this.eventHandlers.has(name)) {
|
|
1776
|
+
const err = new EventError(`Event handler \`${name}\` already exists.`);
|
|
1777
|
+
console.warn(err);
|
|
1778
|
+
const handlers = this.eventHandlers.get(name);
|
|
1779
|
+
handlers.push(handler);
|
|
1780
|
+
this.eventHandlers.set(name, handlers);
|
|
1781
|
+
} else {
|
|
1782
|
+
this.eventHandlers.set(name, [handler]);
|
|
1783
|
+
}
|
|
1784
|
+
return this;
|
|
1785
|
+
}
|
|
1786
|
+
/**
|
|
1787
|
+
* Finds an event handler(s) by name.
|
|
1788
|
+
*/
|
|
1789
|
+
findEvent(name) {
|
|
1790
|
+
if (this.eventHandlers.has(name)) {
|
|
1791
|
+
return this.eventHandlers.get(name);
|
|
1792
|
+
}
|
|
1793
|
+
for (const child of this[$childRouters]) {
|
|
1794
|
+
const handler = child.findEvent(name);
|
|
1795
|
+
if (handler) return handler;
|
|
1796
|
+
}
|
|
1797
|
+
return null;
|
|
1798
|
+
}
|
|
1639
1799
|
/**
|
|
1640
1800
|
* Mounts a controller instance to a path prefix.
|
|
1641
1801
|
*
|
|
@@ -1736,7 +1896,7 @@ class ShokupanRouter {
|
|
|
1736
1896
|
});
|
|
1737
1897
|
const ctx = new ShokupanContext(req);
|
|
1738
1898
|
let result = null;
|
|
1739
|
-
let status =
|
|
1899
|
+
let status = HTTP_STATUS.OK;
|
|
1740
1900
|
const headers = {};
|
|
1741
1901
|
const match = this.find(req.method, ctx.path);
|
|
1742
1902
|
if (match) {
|
|
@@ -1745,12 +1905,12 @@ class ShokupanRouter {
|
|
|
1745
1905
|
result = await match.handler(ctx);
|
|
1746
1906
|
} catch (err) {
|
|
1747
1907
|
console.error(err);
|
|
1748
|
-
status = err
|
|
1908
|
+
status = getErrorStatus(err);
|
|
1749
1909
|
result = { error: err.message || "Internal Server Error" };
|
|
1750
1910
|
if (err.errors) result.errors = err.errors;
|
|
1751
1911
|
}
|
|
1752
1912
|
} else {
|
|
1753
|
-
status =
|
|
1913
|
+
status = HTTP_STATUS.NOT_FOUND;
|
|
1754
1914
|
result = "Not Found";
|
|
1755
1915
|
}
|
|
1756
1916
|
if (result instanceof Response) {
|
|
@@ -1779,7 +1939,7 @@ class ShokupanRouter {
|
|
|
1779
1939
|
const originalHandler = handler;
|
|
1780
1940
|
const wrapped = async (ctx) => {
|
|
1781
1941
|
await this.runHooks("onRequestStart", ctx);
|
|
1782
|
-
const debug = ctx
|
|
1942
|
+
const debug = ctx[$debug];
|
|
1783
1943
|
let debugId;
|
|
1784
1944
|
let previousNode;
|
|
1785
1945
|
if (debug) {
|
|
@@ -1869,6 +2029,7 @@ class ShokupanRouter {
|
|
|
1869
2029
|
const decoratedRoutes = instance[$routeMethods] || proto && proto[$routeMethods];
|
|
1870
2030
|
const decoratedArgs = instance[$routeArgs] || proto && proto[$routeArgs];
|
|
1871
2031
|
const methodMiddlewareMap = instance[$middleware] || proto && proto[$middleware];
|
|
2032
|
+
const decoratedEvents = instance[$eventMethods] || proto && proto[$eventMethods];
|
|
1872
2033
|
let routesAttached = 0;
|
|
1873
2034
|
for (let i = 0; i < Array.from(methods).length; i++) {
|
|
1874
2035
|
const name = Array.from(methods)[i];
|
|
@@ -2013,6 +2174,39 @@ class ShokupanRouter {
|
|
|
2013
2174
|
const spec = { tags: [tagName], ...userSpec };
|
|
2014
2175
|
this.add({ method, path: normalizedPath, handler: finalHandler, spec, controller: instance });
|
|
2015
2176
|
}
|
|
2177
|
+
if (decoratedEvents?.has(name)) {
|
|
2178
|
+
routesAttached++;
|
|
2179
|
+
const config = decoratedEvents.get(name);
|
|
2180
|
+
const routeArgs = decoratedArgs?.get(name);
|
|
2181
|
+
const wrappedHandler = async (ctx) => {
|
|
2182
|
+
let args = [ctx];
|
|
2183
|
+
if (routeArgs?.length > 0) {
|
|
2184
|
+
args = [];
|
|
2185
|
+
const sortedArgs = [...routeArgs].sort((a, b) => a.index - b.index);
|
|
2186
|
+
for (let k = 0; k < sortedArgs.length; k++) {
|
|
2187
|
+
const arg = sortedArgs[k];
|
|
2188
|
+
switch (arg.type) {
|
|
2189
|
+
case RouteParamType.BODY:
|
|
2190
|
+
args[arg.index] = await ctx.body();
|
|
2191
|
+
break;
|
|
2192
|
+
case RouteParamType.CONTEXT:
|
|
2193
|
+
args[arg.index] = ctx;
|
|
2194
|
+
break;
|
|
2195
|
+
case RouteParamType.REQUEST:
|
|
2196
|
+
args[arg.index] = ctx.req;
|
|
2197
|
+
break;
|
|
2198
|
+
case RouteParamType.HEADER:
|
|
2199
|
+
args[arg.index] = arg.name ? ctx.req.headers.get(arg.name) : ctx.req.headers;
|
|
2200
|
+
break;
|
|
2201
|
+
default:
|
|
2202
|
+
args[arg.index] = void 0;
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
}
|
|
2206
|
+
return originalHandler.apply(instance, args);
|
|
2207
|
+
};
|
|
2208
|
+
this.event(config.eventName, wrappedHandler);
|
|
2209
|
+
}
|
|
2016
2210
|
}
|
|
2017
2211
|
if (routesAttached === 0) {
|
|
2018
2212
|
console.warn(`No routes attached to controller ${instance.constructor.name}`);
|
|
@@ -2176,8 +2370,10 @@ class ShokupanRouter {
|
|
|
2176
2370
|
Promise.resolve().then(async () => {
|
|
2177
2371
|
try {
|
|
2178
2372
|
const timestamp = Date.now();
|
|
2179
|
-
|
|
2180
|
-
|
|
2373
|
+
await datastore.set(new RecordId("middleware_tracking", {
|
|
2374
|
+
timestamp,
|
|
2375
|
+
name: handler.name || "anonymous"
|
|
2376
|
+
}), {
|
|
2181
2377
|
name: handler.name || "anonymous",
|
|
2182
2378
|
path: ctx.path,
|
|
2183
2379
|
timestamp,
|
|
@@ -2195,7 +2391,7 @@ class ShokupanRouter {
|
|
|
2195
2391
|
const cutoff = Date.now() - ttl;
|
|
2196
2392
|
await datastore.query(`DELETE middleware_tracking WHERE timestamp < ${cutoff}`);
|
|
2197
2393
|
const results = await datastore.query("SELECT count() FROM middleware_tracking GROUP ALL");
|
|
2198
|
-
if (results
|
|
2394
|
+
if (results?.[0]?.count > maxCapacity) {
|
|
2199
2395
|
const toDelete = results[0].count - maxCapacity;
|
|
2200
2396
|
await datastore.query(`DELETE middleware_tracking ORDER BY timestamp ASC LIMIT ${toDelete}`);
|
|
2201
2397
|
}
|
|
@@ -2272,7 +2468,7 @@ class ShokupanRouter {
|
|
|
2272
2468
|
(l) => l.includes(":") && !l.includes("router.ts") && !l.includes("shokupan.ts") && !l.includes("node_modules") && !l.includes("bun:main")
|
|
2273
2469
|
);
|
|
2274
2470
|
if (callerLine) {
|
|
2275
|
-
const match = callerLine.match(/\((
|
|
2471
|
+
const match = callerLine.match(/\((.{0,1000}):(\d{1,10}):(?:\d{1,10})\)/) || callerLine.match(/at (.{0,1000}):(\d{1,10}):(?:\d{1,10})/);
|
|
2276
2472
|
if (match) {
|
|
2277
2473
|
file = match[1];
|
|
2278
2474
|
line = parseInt(match[2], 10);
|
|
@@ -2290,7 +2486,7 @@ class ShokupanRouter {
|
|
|
2290
2486
|
}
|
|
2291
2487
|
return guardHandler(ctx, next);
|
|
2292
2488
|
};
|
|
2293
|
-
trackedGuard.originalHandler = guardHandler.originalHandler
|
|
2489
|
+
trackedGuard.originalHandler = guardHandler.originalHandler ?? guardHandler;
|
|
2294
2490
|
this.currentGuards.push({ handler: trackedGuard, spec });
|
|
2295
2491
|
return this;
|
|
2296
2492
|
}
|
|
@@ -2403,7 +2599,7 @@ class ShokupanRouter {
|
|
|
2403
2599
|
const fns = this.hookCache.get(name);
|
|
2404
2600
|
if (!fns) return;
|
|
2405
2601
|
const ctx = args?.[0] instanceof ShokupanContext ? args[0] : void 0;
|
|
2406
|
-
const debug = ctx?.
|
|
2602
|
+
const debug = ctx?.[$debug];
|
|
2407
2603
|
if (debug) {
|
|
2408
2604
|
await Promise.all(fns.map(async (fn, index) => {
|
|
2409
2605
|
const hookId = `hook_${name}_${fn.name || index}`;
|
|
@@ -2476,6 +2672,7 @@ const defaults = {
|
|
|
2476
2672
|
hostname: "localhost",
|
|
2477
2673
|
development: process.env.NODE_ENV !== "production",
|
|
2478
2674
|
enableAsyncLocalStorage: false,
|
|
2675
|
+
enableHttpBridge: false,
|
|
2479
2676
|
reusePort: false
|
|
2480
2677
|
};
|
|
2481
2678
|
trace.getTracer("shokupan.application");
|
|
@@ -2484,6 +2681,7 @@ class Shokupan extends ShokupanRouter {
|
|
|
2484
2681
|
openApiSpec;
|
|
2485
2682
|
composedMiddleware;
|
|
2486
2683
|
cpuMonitor;
|
|
2684
|
+
server;
|
|
2487
2685
|
get logger() {
|
|
2488
2686
|
return this.applicationConfig.logger;
|
|
2489
2687
|
}
|
|
@@ -2592,6 +2790,7 @@ class Shokupan extends ShokupanRouter {
|
|
|
2592
2790
|
this.cpuMonitor = new SystemCpuMonitor();
|
|
2593
2791
|
this.cpuMonitor.start();
|
|
2594
2792
|
}
|
|
2793
|
+
const self = this;
|
|
2595
2794
|
const serveOptions = {
|
|
2596
2795
|
port: finalPort,
|
|
2597
2796
|
hostname: this.applicationConfig.hostname,
|
|
@@ -2603,8 +2802,61 @@ class Shokupan extends ShokupanRouter {
|
|
|
2603
2802
|
open(ws) {
|
|
2604
2803
|
ws.data?.handler?.open?.(ws);
|
|
2605
2804
|
},
|
|
2606
|
-
message(ws, message) {
|
|
2607
|
-
ws.data?.handler?.message
|
|
2805
|
+
async message(ws, message) {
|
|
2806
|
+
if (ws.data?.handler?.message) {
|
|
2807
|
+
return ws.data.handler.message(ws, message);
|
|
2808
|
+
}
|
|
2809
|
+
if (typeof message !== "string") return;
|
|
2810
|
+
try {
|
|
2811
|
+
const payload = JSON.parse(message);
|
|
2812
|
+
if (self.applicationConfig["enableHttpBridge"] && payload.type === "HTTP") {
|
|
2813
|
+
const { id, method, path, headers, body } = payload;
|
|
2814
|
+
const url = new URL(path, `http://${self.applicationConfig.hostname || "localhost"}:${finalPort}`);
|
|
2815
|
+
const req = new Request(url.toString(), {
|
|
2816
|
+
method,
|
|
2817
|
+
headers,
|
|
2818
|
+
body: typeof body === "object" ? JSON.stringify(body) : body
|
|
2819
|
+
});
|
|
2820
|
+
const res = await self.fetch(req);
|
|
2821
|
+
const resBody = await res.json().catch((err) => res.text());
|
|
2822
|
+
const resHeaders = {};
|
|
2823
|
+
res.headers.forEach((v, k) => resHeaders[k] = v);
|
|
2824
|
+
ws.send(JSON.stringify({
|
|
2825
|
+
type: "RESPONSE",
|
|
2826
|
+
id,
|
|
2827
|
+
status: res.status,
|
|
2828
|
+
headers: resHeaders,
|
|
2829
|
+
body: resBody
|
|
2830
|
+
}));
|
|
2831
|
+
return;
|
|
2832
|
+
}
|
|
2833
|
+
const eventName = payload.event || (payload.type === "EVENT" ? payload.name : void 0);
|
|
2834
|
+
if (eventName) {
|
|
2835
|
+
const handlers = self.findEvent(eventName);
|
|
2836
|
+
const handler = handlers?.length == 1 ? handlers[0] : compose(handlers);
|
|
2837
|
+
if (handler) {
|
|
2838
|
+
const data = payload.data || payload.payload;
|
|
2839
|
+
const req = new ShokupanRequest({
|
|
2840
|
+
url: `http://${self.applicationConfig.hostname || "localhost"}/event/${eventName}`,
|
|
2841
|
+
method: "POST",
|
|
2842
|
+
headers: new Headers({ "content-type": "application/json" }),
|
|
2843
|
+
body: JSON.stringify(data)
|
|
2844
|
+
});
|
|
2845
|
+
const ctx = new ShokupanContext(req, self.server);
|
|
2846
|
+
ctx[$ws] = ws;
|
|
2847
|
+
try {
|
|
2848
|
+
await handler(ctx);
|
|
2849
|
+
} catch (err) {
|
|
2850
|
+
if (self.applicationConfig["websocketErrorHandler"]) {
|
|
2851
|
+
await self.applicationConfig["websocketErrorHandler"](err, ctx);
|
|
2852
|
+
} else {
|
|
2853
|
+
console.error(`Error in event ${eventName}:`, err);
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
} catch (e) {
|
|
2859
|
+
}
|
|
2608
2860
|
},
|
|
2609
2861
|
drain(ws) {
|
|
2610
2862
|
ws.data?.handler?.drain?.(ws);
|
|
@@ -2616,12 +2868,40 @@ class Shokupan extends ShokupanRouter {
|
|
|
2616
2868
|
};
|
|
2617
2869
|
let factory = this.applicationConfig.serverFactory;
|
|
2618
2870
|
if (!factory && typeof Bun === "undefined") {
|
|
2619
|
-
const { createHttpServer } = await import("./http-server-
|
|
2871
|
+
const { createHttpServer } = await import("./http-server-CCeagTyU.js");
|
|
2620
2872
|
factory = createHttpServer();
|
|
2621
2873
|
}
|
|
2622
|
-
|
|
2874
|
+
this.server = factory ? await factory(serveOptions) : Bun.serve(serveOptions);
|
|
2623
2875
|
console.log(`Shokupan server listening on http://${serveOptions.hostname}:${serveOptions.port}`);
|
|
2624
|
-
return server;
|
|
2876
|
+
return this.server;
|
|
2877
|
+
}
|
|
2878
|
+
/**
|
|
2879
|
+
* Stops the application server.
|
|
2880
|
+
*
|
|
2881
|
+
* This method gracefully shuts down the server and stops any running monitors.
|
|
2882
|
+
* Works transparently in both Bun and Node.js runtimes.
|
|
2883
|
+
*
|
|
2884
|
+
* @returns A promise that resolves when the server has been stopped.
|
|
2885
|
+
*
|
|
2886
|
+
* @example
|
|
2887
|
+
* ```typescript
|
|
2888
|
+
* const app = new Shokupan();
|
|
2889
|
+
* const server = await app.listen(3000);
|
|
2890
|
+
*
|
|
2891
|
+
* // Later, when you want to stop the server
|
|
2892
|
+
* await app.stop();
|
|
2893
|
+
* ```
|
|
2894
|
+
* @param closeActiveConnections — Immediately terminate in-flight requests, websockets, and stop accepting new connections.
|
|
2895
|
+
*/
|
|
2896
|
+
async stop(closeActiveConnections) {
|
|
2897
|
+
if (this.cpuMonitor) {
|
|
2898
|
+
this.cpuMonitor.stop();
|
|
2899
|
+
this.cpuMonitor = void 0;
|
|
2900
|
+
}
|
|
2901
|
+
if (this.server) {
|
|
2902
|
+
await this.server.stop(closeActiveConnections);
|
|
2903
|
+
this.server = void 0;
|
|
2904
|
+
}
|
|
2625
2905
|
}
|
|
2626
2906
|
[$dispatch](req) {
|
|
2627
2907
|
return this.fetch(req);
|
|
@@ -2719,28 +2999,33 @@ class Shokupan extends ShokupanRouter {
|
|
|
2719
2999
|
const bodyParsing = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method) ? ctx.parseBody() : Promise.resolve();
|
|
2720
3000
|
const match = this.find(req.method, ctx.path);
|
|
2721
3001
|
if (match) {
|
|
2722
|
-
ctx
|
|
3002
|
+
ctx[$routeMatched] = true;
|
|
2723
3003
|
ctx.params = match.params;
|
|
2724
3004
|
await bodyParsing;
|
|
2725
3005
|
return match.handler(ctx);
|
|
2726
3006
|
}
|
|
3007
|
+
if (ctx.upgrade()) {
|
|
3008
|
+
return void 0;
|
|
3009
|
+
}
|
|
2727
3010
|
return null;
|
|
2728
3011
|
});
|
|
2729
3012
|
let response;
|
|
2730
3013
|
if (result instanceof Response) {
|
|
2731
3014
|
response = result;
|
|
2732
|
-
} else if ((result === null || result === void 0) && ctx
|
|
2733
|
-
response = ctx
|
|
3015
|
+
} else if ((result === null || result === void 0) && ctx[$finalResponse] instanceof Response) {
|
|
3016
|
+
response = ctx[$finalResponse];
|
|
2734
3017
|
} else if (result === null || result === void 0) {
|
|
2735
|
-
if (ctx
|
|
2736
|
-
response = ctx
|
|
2737
|
-
} else if (ctx.
|
|
3018
|
+
if (ctx[$finalResponse] instanceof Response) {
|
|
3019
|
+
response = ctx[$finalResponse];
|
|
3020
|
+
} else if (ctx.isUpgraded) {
|
|
3021
|
+
return void 0;
|
|
3022
|
+
} else if (ctx[$routeMatched]) {
|
|
2738
3023
|
response = ctx.send(null, { status: ctx.response.status, headers: ctx.response.headers });
|
|
2739
3024
|
} else {
|
|
2740
|
-
if (ctx.response.status !==
|
|
3025
|
+
if (ctx.response.status !== HTTP_STATUS.OK) {
|
|
2741
3026
|
response = ctx.send(null, { status: ctx.response.status, headers: ctx.response.headers });
|
|
2742
3027
|
} else {
|
|
2743
|
-
response = ctx.text("Not Found",
|
|
3028
|
+
response = ctx.text("Not Found", HTTP_STATUS.NOT_FOUND);
|
|
2744
3029
|
}
|
|
2745
3030
|
}
|
|
2746
3031
|
} else if (typeof result === "object") {
|
|
@@ -2752,10 +3037,9 @@ class Shokupan extends ShokupanRouter {
|
|
|
2752
3037
|
await this.runHooks("onResponseStart", ctx, response);
|
|
2753
3038
|
return response;
|
|
2754
3039
|
} catch (err) {
|
|
2755
|
-
console.error(err);
|
|
2756
3040
|
const span = asyncContext.getStore()?.span;
|
|
2757
3041
|
if (span) span.setStatus({ code: 2 });
|
|
2758
|
-
const status = err
|
|
3042
|
+
const status = getErrorStatus(err);
|
|
2759
3043
|
const body = { error: err.message || "Internal Server Error" };
|
|
2760
3044
|
if (err.errors) body.errors = err.errors;
|
|
2761
3045
|
await this.runHooks("onError", ctx, err);
|
|
@@ -2777,10 +3061,10 @@ class Shokupan extends ShokupanRouter {
|
|
|
2777
3061
|
}
|
|
2778
3062
|
return executionPromise.catch((err) => {
|
|
2779
3063
|
if (err.message === "Request Timeout") {
|
|
2780
|
-
return ctx.text("Request Timeout",
|
|
3064
|
+
return ctx.text("Request Timeout", HTTP_STATUS.REQUEST_TIMEOUT);
|
|
2781
3065
|
}
|
|
2782
3066
|
console.error("Unexpected error in request execution:", err);
|
|
2783
|
-
return ctx.text("Internal Server Error",
|
|
3067
|
+
return ctx.text("Internal Server Error", HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
|
2784
3068
|
}).then(async (res) => {
|
|
2785
3069
|
await this.runHooks("onResponseEnd", ctx, res);
|
|
2786
3070
|
return res;
|
|
@@ -2948,6 +3232,14 @@ const Patch = createMethodDecorator("PATCH");
|
|
|
2948
3232
|
const Options = createMethodDecorator("OPTIONS");
|
|
2949
3233
|
const Head = createMethodDecorator("HEAD");
|
|
2950
3234
|
const All = createMethodDecorator("ALL");
|
|
3235
|
+
function Event(eventName) {
|
|
3236
|
+
return (target, propertyKey, descriptor) => {
|
|
3237
|
+
target[$eventMethods] ??= /* @__PURE__ */ new Map();
|
|
3238
|
+
target[$eventMethods].set(propertyKey, {
|
|
3239
|
+
eventName
|
|
3240
|
+
});
|
|
3241
|
+
};
|
|
3242
|
+
}
|
|
2951
3243
|
function RateLimit(options) {
|
|
2952
3244
|
return Use(RateLimitMiddleware(options));
|
|
2953
3245
|
}
|
|
@@ -3291,6 +3583,554 @@ class ClusterPlugin {
|
|
|
3291
3583
|
}
|
|
3292
3584
|
}
|
|
3293
3585
|
}
|
|
3586
|
+
const INTERVALS = [
|
|
3587
|
+
{ label: "10s", ms: 10 * 1e3 },
|
|
3588
|
+
{ label: "1m", ms: 60 * 1e3 },
|
|
3589
|
+
{ label: "5m", ms: 5 * 60 * 1e3 },
|
|
3590
|
+
{ label: "1h", ms: 60 * 60 * 1e3 },
|
|
3591
|
+
{ label: "2h", ms: 2 * 60 * 60 * 1e3 },
|
|
3592
|
+
{ label: "6h", ms: 6 * 60 * 60 * 1e3 },
|
|
3593
|
+
{ label: "12h", ms: 12 * 60 * 60 * 1e3 },
|
|
3594
|
+
{ label: "1d", ms: 24 * 60 * 60 * 1e3 },
|
|
3595
|
+
{ label: "3d", ms: 3 * 24 * 60 * 60 * 1e3 },
|
|
3596
|
+
{ label: "7d", ms: 7 * 24 * 60 * 60 * 1e3 },
|
|
3597
|
+
{ label: "30d", ms: 30 * 24 * 60 * 60 * 1e3 }
|
|
3598
|
+
];
|
|
3599
|
+
class MetricsCollector {
|
|
3600
|
+
currentIntervalStart = {};
|
|
3601
|
+
pendingDetails = {};
|
|
3602
|
+
eventLoopHistogram = monitorEventLoopDelay({ resolution: 10 });
|
|
3603
|
+
timer = null;
|
|
3604
|
+
constructor() {
|
|
3605
|
+
this.eventLoopHistogram.enable();
|
|
3606
|
+
const now = Date.now();
|
|
3607
|
+
INTERVALS.forEach((int) => {
|
|
3608
|
+
this.currentIntervalStart[int.label] = this.alignTimestamp(now, int.ms);
|
|
3609
|
+
this.pendingDetails[int.label] = [];
|
|
3610
|
+
});
|
|
3611
|
+
this.timer = setInterval(() => this.collect(), 1e4);
|
|
3612
|
+
}
|
|
3613
|
+
recordRequest(duration, isError) {
|
|
3614
|
+
INTERVALS.forEach((int) => {
|
|
3615
|
+
this.pendingDetails[int.label].push({ duration, isError });
|
|
3616
|
+
});
|
|
3617
|
+
}
|
|
3618
|
+
alignTimestamp(ts, intervalMs) {
|
|
3619
|
+
return Math.floor(ts / intervalMs) * intervalMs;
|
|
3620
|
+
}
|
|
3621
|
+
async collect() {
|
|
3622
|
+
try {
|
|
3623
|
+
const now = Date.now();
|
|
3624
|
+
console.log("[MetricsCollector] collect() called at", new Date(now).toISOString());
|
|
3625
|
+
for (const int of INTERVALS) {
|
|
3626
|
+
const start = this.currentIntervalStart[int.label];
|
|
3627
|
+
if (now >= start + int.ms) {
|
|
3628
|
+
console.log(`[MetricsCollector] Flushing ${int.label} interval (boundary crossed)`);
|
|
3629
|
+
await this.flushInterval(int.label, start, int.ms);
|
|
3630
|
+
this.currentIntervalStart[int.label] = this.alignTimestamp(now, int.ms);
|
|
3631
|
+
}
|
|
3632
|
+
}
|
|
3633
|
+
} catch (error) {
|
|
3634
|
+
console.error("[MetricsCollector] Error in collect():", error);
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
async flushInterval(label, timestamp, durationMs) {
|
|
3638
|
+
const reqs = this.pendingDetails[label];
|
|
3639
|
+
console.log(`[MetricsCollector] flushInterval(${label}) - ${reqs.length} requests pending`);
|
|
3640
|
+
this.pendingDetails[label] = [];
|
|
3641
|
+
if (reqs.length === 0) {
|
|
3642
|
+
console.log(`[MetricsCollector] No requests for ${label}, skipping persist`);
|
|
3643
|
+
return;
|
|
3644
|
+
}
|
|
3645
|
+
const totalReqs = reqs.length;
|
|
3646
|
+
const errorReqs = reqs.filter((r) => r.isError).length;
|
|
3647
|
+
const successReqs = totalReqs - errorReqs;
|
|
3648
|
+
const duratons = reqs.map((r) => r.duration).sort((a, b) => a - b);
|
|
3649
|
+
const rps = totalReqs / (durationMs / 1e3);
|
|
3650
|
+
const sum = duratons.reduce((a, b) => a + b, 0);
|
|
3651
|
+
const avg = totalReqs > 0 ? sum / totalReqs : 0;
|
|
3652
|
+
const getP = (p) => {
|
|
3653
|
+
if (duratons.length === 0) return 0;
|
|
3654
|
+
const idx = Math.floor(duratons.length * p);
|
|
3655
|
+
return duratons[idx];
|
|
3656
|
+
};
|
|
3657
|
+
const metric = {
|
|
3658
|
+
timestamp,
|
|
3659
|
+
interval: label,
|
|
3660
|
+
cpu: os.loadavg()[0],
|
|
3661
|
+
// Using load avg for simplicity as per requirements (Load)
|
|
3662
|
+
load: os.loadavg(),
|
|
3663
|
+
memory: {
|
|
3664
|
+
used: process.memoryUsage().rss,
|
|
3665
|
+
total: os.totalmem(),
|
|
3666
|
+
heapUsed: process.memoryUsage().heapUsed,
|
|
3667
|
+
heapTotal: process.memoryUsage().heapTotal
|
|
3668
|
+
},
|
|
3669
|
+
eventLoopLatency: {
|
|
3670
|
+
min: this.eventLoopHistogram.min / 1e6,
|
|
3671
|
+
max: this.eventLoopHistogram.max / 1e6,
|
|
3672
|
+
mean: this.eventLoopHistogram.mean / 1e6,
|
|
3673
|
+
p50: this.eventLoopHistogram.percentile(50) / 1e6,
|
|
3674
|
+
p95: this.eventLoopHistogram.percentile(95) / 1e6,
|
|
3675
|
+
p99: this.eventLoopHistogram.percentile(99) / 1e6
|
|
3676
|
+
},
|
|
3677
|
+
requests: {
|
|
3678
|
+
total: totalReqs,
|
|
3679
|
+
rps,
|
|
3680
|
+
success: successReqs,
|
|
3681
|
+
error: errorReqs
|
|
3682
|
+
},
|
|
3683
|
+
responseTime: {
|
|
3684
|
+
min: duratons[0] || 0,
|
|
3685
|
+
max: duratons[duratons.length - 1] || 0,
|
|
3686
|
+
avg,
|
|
3687
|
+
p50: getP(0.5),
|
|
3688
|
+
p95: getP(0.95),
|
|
3689
|
+
p99: getP(0.99)
|
|
3690
|
+
}
|
|
3691
|
+
};
|
|
3692
|
+
console.log(`[MetricsCollector] Persisting ${label} metric at timestamp ${timestamp}`);
|
|
3693
|
+
try {
|
|
3694
|
+
const recordId = new RecordId("metrics", timestamp);
|
|
3695
|
+
await datastore.set(recordId, metric);
|
|
3696
|
+
console.log(`[MetricsCollector] ✓ Successfully saved ${label} metric to datastore`);
|
|
3697
|
+
const test = await datastore.get(recordId);
|
|
3698
|
+
console.log(`[MetricsCollector] DEBUG: Immediate .get() returned:`, test ? "DATA" : "NULL");
|
|
3699
|
+
const queryTest = await datastore.query("SELECT * FROM metrics WHERE id = $id", { id: recordId });
|
|
3700
|
+
console.log(`[MetricsCollector] DEBUG: Query by id returned ${queryTest[0]?.length || 0} records`);
|
|
3701
|
+
} catch (e) {
|
|
3702
|
+
console.error(`[MetricsCollector] ✗ Failed to save metrics for ${label}:`, e);
|
|
3703
|
+
}
|
|
3704
|
+
}
|
|
3705
|
+
// Cleanup if needed
|
|
3706
|
+
stop() {
|
|
3707
|
+
if (this.timer) clearInterval(this.timer);
|
|
3708
|
+
this.eventLoopHistogram.disable();
|
|
3709
|
+
}
|
|
3710
|
+
}
|
|
3711
|
+
class Collector {
|
|
3712
|
+
constructor(dashboard) {
|
|
3713
|
+
this.dashboard = dashboard;
|
|
3714
|
+
}
|
|
3715
|
+
currentNode;
|
|
3716
|
+
trackStep(id, type, duration, status, error) {
|
|
3717
|
+
if (!id) return;
|
|
3718
|
+
this.dashboard.recordNodeMetric(id, type, duration, status === "error");
|
|
3719
|
+
}
|
|
3720
|
+
trackEdge(fromId, toId) {
|
|
3721
|
+
if (!fromId || !toId) return;
|
|
3722
|
+
this.dashboard.recordEdgeMetric(fromId, toId);
|
|
3723
|
+
}
|
|
3724
|
+
setNode(id) {
|
|
3725
|
+
this.currentNode = id;
|
|
3726
|
+
}
|
|
3727
|
+
getCurrentNode() {
|
|
3728
|
+
return this.currentNode;
|
|
3729
|
+
}
|
|
3730
|
+
}
|
|
3731
|
+
class Dashboard {
|
|
3732
|
+
constructor(dashboardConfig = {}) {
|
|
3733
|
+
this.dashboardConfig = dashboardConfig;
|
|
3734
|
+
}
|
|
3735
|
+
static __dirname = dirname(fileURLToPath(import.meta.url));
|
|
3736
|
+
// Get base path for dashboard files - works in both dev (src/) and production (dist/)
|
|
3737
|
+
static getBasePath() {
|
|
3738
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
3739
|
+
if (dir.endsWith("dist")) {
|
|
3740
|
+
return dir + "/plugins/application/dashboard";
|
|
3741
|
+
}
|
|
3742
|
+
return dir;
|
|
3743
|
+
}
|
|
3744
|
+
router = new ShokupanRouter();
|
|
3745
|
+
metrics = {
|
|
3746
|
+
totalRequests: 0,
|
|
3747
|
+
successfulRequests: 0,
|
|
3748
|
+
failedRequests: 0,
|
|
3749
|
+
activeRequests: 0,
|
|
3750
|
+
averageTotalTime_ms: 0,
|
|
3751
|
+
recentTimings: [],
|
|
3752
|
+
logs: [],
|
|
3753
|
+
rateLimitedCounts: {},
|
|
3754
|
+
nodeMetrics: {},
|
|
3755
|
+
edgeMetrics: {}
|
|
3756
|
+
};
|
|
3757
|
+
eta = new Eta({
|
|
3758
|
+
views: Dashboard.getBasePath() + "/static",
|
|
3759
|
+
cache: false
|
|
3760
|
+
});
|
|
3761
|
+
startTime = Date.now();
|
|
3762
|
+
instrumented = false;
|
|
3763
|
+
metricsCollector = new MetricsCollector();
|
|
3764
|
+
// ShokupanPlugin interface implementation
|
|
3765
|
+
onInit(app, options) {
|
|
3766
|
+
this[$appRoot] = app;
|
|
3767
|
+
const mountPath = options?.path || this.dashboardConfig.path || "/dashboard";
|
|
3768
|
+
const hooks = this.getHooks();
|
|
3769
|
+
if (!app.middleware) {
|
|
3770
|
+
app.middleware = [];
|
|
3771
|
+
}
|
|
3772
|
+
const hooksMiddleware = async (ctx, next) => {
|
|
3773
|
+
if (hooks.onRequestStart) {
|
|
3774
|
+
await hooks.onRequestStart(ctx);
|
|
3775
|
+
}
|
|
3776
|
+
await next();
|
|
3777
|
+
if (hooks.onResponseEnd) {
|
|
3778
|
+
const effectiveResponse = ctx._finalResponse || ctx.response || {};
|
|
3779
|
+
await hooks.onResponseEnd(ctx, effectiveResponse);
|
|
3780
|
+
}
|
|
3781
|
+
};
|
|
3782
|
+
app.use(hooksMiddleware);
|
|
3783
|
+
app.mount(mountPath, this.router);
|
|
3784
|
+
this.setupRoutes();
|
|
3785
|
+
}
|
|
3786
|
+
setupRoutes() {
|
|
3787
|
+
this.router.get("/metrics", async (ctx) => {
|
|
3788
|
+
const uptimeSeconds = Math.floor((Date.now() - this.startTime) / 1e3);
|
|
3789
|
+
const uptime = `${Math.floor(uptimeSeconds / 3600)}h ${Math.floor(uptimeSeconds % 3600 / 60)}m ${uptimeSeconds % 60}s`;
|
|
3790
|
+
const interval = ctx.query["interval"];
|
|
3791
|
+
if (interval) {
|
|
3792
|
+
const intervalMap = {
|
|
3793
|
+
"10s": 10 * 1e3,
|
|
3794
|
+
"1m": 60 * 1e3,
|
|
3795
|
+
"5m": 5 * 60 * 1e3,
|
|
3796
|
+
"30m": 30 * 60 * 1e3,
|
|
3797
|
+
"1h": 60 * 60 * 1e3,
|
|
3798
|
+
"2h": 2 * 60 * 60 * 1e3,
|
|
3799
|
+
"6h": 6 * 60 * 60 * 1e3,
|
|
3800
|
+
"12h": 12 * 60 * 60 * 1e3,
|
|
3801
|
+
"1d": 24 * 60 * 60 * 1e3,
|
|
3802
|
+
"3d": 3 * 24 * 60 * 60 * 1e3,
|
|
3803
|
+
"7d": 7 * 24 * 60 * 60 * 1e3,
|
|
3804
|
+
"30d": 30 * 24 * 60 * 60 * 1e3
|
|
3805
|
+
};
|
|
3806
|
+
const ms = intervalMap[interval] || 60 * 1e3;
|
|
3807
|
+
const startTime = Date.now() - ms;
|
|
3808
|
+
let stats;
|
|
3809
|
+
try {
|
|
3810
|
+
stats = await datastore.query(`
|
|
3811
|
+
SELECT
|
|
3812
|
+
count() as total,
|
|
3813
|
+
count(IF status < 400 THEN 1 END) as success,
|
|
3814
|
+
count(IF status >= 400 THEN 1 END) as failed,
|
|
3815
|
+
math::mean(duration) as avg_latency
|
|
3816
|
+
FROM requests
|
|
3817
|
+
WHERE timestamp >= $start
|
|
3818
|
+
GROUP ALL
|
|
3819
|
+
`, { start: startTime });
|
|
3820
|
+
} catch (error) {
|
|
3821
|
+
console.error("[Dashboard] Query failed at plugin.ts:180-191", {
|
|
3822
|
+
error,
|
|
3823
|
+
interval,
|
|
3824
|
+
startTime,
|
|
3825
|
+
query: "metrics interval stats",
|
|
3826
|
+
stack: new Error().stack
|
|
3827
|
+
});
|
|
3828
|
+
throw error;
|
|
3829
|
+
}
|
|
3830
|
+
const s = stats[0] || { total: 0, success: 0, failed: 0, avg_latency: 0 };
|
|
3831
|
+
return ctx.json({
|
|
3832
|
+
metrics: {
|
|
3833
|
+
totalRequests: s.total || 0,
|
|
3834
|
+
successfulRequests: s.success || 0,
|
|
3835
|
+
failedRequests: s.failed || 0,
|
|
3836
|
+
activeRequests: this.metrics.activeRequests,
|
|
3837
|
+
averageTotalTime_ms: s.avg_latency || 0,
|
|
3838
|
+
recentTimings: this.metrics.recentTimings,
|
|
3839
|
+
logs: [],
|
|
3840
|
+
rateLimitedCounts: this.metrics.rateLimitedCounts,
|
|
3841
|
+
nodeMetrics: this.metrics.nodeMetrics,
|
|
3842
|
+
edgeMetrics: this.metrics.edgeMetrics
|
|
3843
|
+
},
|
|
3844
|
+
uptime
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
return ctx.json({
|
|
3848
|
+
metrics: this.metrics,
|
|
3849
|
+
uptime
|
|
3850
|
+
});
|
|
3851
|
+
});
|
|
3852
|
+
this.router.get("/metrics/history", async (ctx) => {
|
|
3853
|
+
const interval = ctx.query["interval"] || "1m";
|
|
3854
|
+
const intervalMap = {
|
|
3855
|
+
"10s": 10 * 1e3,
|
|
3856
|
+
"1m": 60 * 1e3,
|
|
3857
|
+
"5m": 5 * 60 * 1e3,
|
|
3858
|
+
"30m": 30 * 60 * 1e3,
|
|
3859
|
+
"1h": 60 * 60 * 1e3,
|
|
3860
|
+
"2h": 2 * 60 * 60 * 1e3,
|
|
3861
|
+
"6h": 6 * 60 * 60 * 1e3,
|
|
3862
|
+
"12h": 12 * 60 * 60 * 1e3,
|
|
3863
|
+
"1d": 24 * 60 * 60 * 1e3,
|
|
3864
|
+
"3d": 3 * 24 * 60 * 60 * 1e3,
|
|
3865
|
+
"7d": 7 * 24 * 60 * 60 * 1e3,
|
|
3866
|
+
"30d": 30 * 24 * 60 * 60 * 1e3
|
|
3867
|
+
};
|
|
3868
|
+
const periodMs = intervalMap[interval] || 60 * 1e3;
|
|
3869
|
+
const startTime = Date.now() - periodMs * 3;
|
|
3870
|
+
const endTime = Date.now();
|
|
3871
|
+
const result = await datastore.query(
|
|
3872
|
+
"SELECT * FROM metrics WHERE timestamp >= $start AND timestamp <= $end AND interval = $interval ORDER BY timestamp ASC",
|
|
3873
|
+
{ start: startTime, end: endTime, interval }
|
|
3874
|
+
);
|
|
3875
|
+
return ctx.json({
|
|
3876
|
+
metrics: result[0] || []
|
|
3877
|
+
});
|
|
3878
|
+
});
|
|
3879
|
+
const getIntervalStartTime = (interval) => {
|
|
3880
|
+
if (!interval) return 0;
|
|
3881
|
+
const intervalMap = {
|
|
3882
|
+
"10s": 10 * 1e3,
|
|
3883
|
+
"1m": 60 * 1e3,
|
|
3884
|
+
"5m": 5 * 60 * 1e3,
|
|
3885
|
+
"30m": 30 * 60 * 1e3,
|
|
3886
|
+
"1h": 60 * 60 * 1e3,
|
|
3887
|
+
"2h": 2 * 60 * 60 * 1e3,
|
|
3888
|
+
"6h": 6 * 60 * 60 * 1e3,
|
|
3889
|
+
"12h": 12 * 60 * 60 * 1e3,
|
|
3890
|
+
"1d": 24 * 60 * 60 * 1e3,
|
|
3891
|
+
"3d": 3 * 24 * 60 * 60 * 1e3,
|
|
3892
|
+
"7d": 7 * 24 * 60 * 60 * 1e3,
|
|
3893
|
+
"30d": 30 * 24 * 60 * 60 * 1e3
|
|
3894
|
+
};
|
|
3895
|
+
const ms = intervalMap[interval] || 0;
|
|
3896
|
+
return ms ? Date.now() - ms : 0;
|
|
3897
|
+
};
|
|
3898
|
+
this.router.get("/requests/top", async (ctx) => {
|
|
3899
|
+
const startTime = getIntervalStartTime(ctx.query["interval"]);
|
|
3900
|
+
const result = await datastore.query(
|
|
3901
|
+
"SELECT method, url, count() as count FROM requests WHERE timestamp >= $start GROUP BY method, url ORDER BY count DESC LIMIT 10",
|
|
3902
|
+
{ start: startTime }
|
|
3903
|
+
);
|
|
3904
|
+
return ctx.json({ top: result[0] || [] });
|
|
3905
|
+
});
|
|
3906
|
+
this.router.get("/errors/top", async (ctx) => {
|
|
3907
|
+
const startTime = getIntervalStartTime(ctx.query["interval"]);
|
|
3908
|
+
const result = await datastore.query(
|
|
3909
|
+
"SELECT status, count() as count FROM failed_requests WHERE timestamp >= $start GROUP BY status ORDER BY count DESC LIMIT 10",
|
|
3910
|
+
{ start: startTime }
|
|
3911
|
+
);
|
|
3912
|
+
return ctx.json({ top: result[0] || [] });
|
|
3913
|
+
});
|
|
3914
|
+
this.router.get("/requests/failing", async (ctx) => {
|
|
3915
|
+
const startTime = getIntervalStartTime(ctx.query["interval"]);
|
|
3916
|
+
const result = await datastore.query(
|
|
3917
|
+
"SELECT method, url, count() as count FROM failed_requests WHERE timestamp >= $start GROUP BY method, url ORDER BY count DESC LIMIT 10",
|
|
3918
|
+
{ start: startTime }
|
|
3919
|
+
);
|
|
3920
|
+
return ctx.json({ top: result[0] || [] });
|
|
3921
|
+
});
|
|
3922
|
+
this.router.get("/requests/slowest", async (ctx) => {
|
|
3923
|
+
const startTime = getIntervalStartTime(ctx.query["interval"]);
|
|
3924
|
+
const result = await datastore.query(
|
|
3925
|
+
"SELECT method, url, duration, status, timestamp FROM requests WHERE timestamp >= $start ORDER BY duration DESC LIMIT 10",
|
|
3926
|
+
{ start: startTime }
|
|
3927
|
+
);
|
|
3928
|
+
return ctx.json({ slowest: result[0] || [] });
|
|
3929
|
+
});
|
|
3930
|
+
this.router.get("/registry", (ctx) => {
|
|
3931
|
+
const app = this[$appRoot];
|
|
3932
|
+
if (!this.instrumented && app) {
|
|
3933
|
+
this.instrumentApp(app);
|
|
3934
|
+
}
|
|
3935
|
+
const registry = app?.getComponentRegistry?.();
|
|
3936
|
+
if (registry) {
|
|
3937
|
+
this.assignIdsToRegistry(registry, "root");
|
|
3938
|
+
}
|
|
3939
|
+
return ctx.json({ registry: registry || {} });
|
|
3940
|
+
});
|
|
3941
|
+
this.router.get("/requests", async (ctx) => {
|
|
3942
|
+
const result = await datastore.query("SELECT * FROM requests ORDER BY timestamp DESC LIMIT 100");
|
|
3943
|
+
return ctx.json({ requests: result[0] || [] });
|
|
3944
|
+
});
|
|
3945
|
+
this.router.get("/requests/:id", async (ctx) => {
|
|
3946
|
+
const result = await datastore.query("SELECT * FROM requests WHERE id = $id", { id: ctx.params["id"] });
|
|
3947
|
+
return ctx.json({ request: result[0]?.[0] });
|
|
3948
|
+
});
|
|
3949
|
+
this.router.get("/failures", async (ctx) => {
|
|
3950
|
+
const result = await datastore.query("SELECT * FROM failed_requests ORDER BY timestamp DESC LIMIT 50");
|
|
3951
|
+
return ctx.json({ failures: result[0] });
|
|
3952
|
+
});
|
|
3953
|
+
this.router.post("/replay", async (ctx) => {
|
|
3954
|
+
const body = await ctx.body();
|
|
3955
|
+
const app = this[$appRoot];
|
|
3956
|
+
if (!app) return unknownError(ctx);
|
|
3957
|
+
try {
|
|
3958
|
+
const result = await app.processRequest({
|
|
3959
|
+
method: body.method,
|
|
3960
|
+
path: body.url,
|
|
3961
|
+
// or path
|
|
3962
|
+
headers: body.headers,
|
|
3963
|
+
body: body.body
|
|
3964
|
+
});
|
|
3965
|
+
return ctx.json({
|
|
3966
|
+
status: result.status,
|
|
3967
|
+
headers: result.headers,
|
|
3968
|
+
data: result.data
|
|
3969
|
+
});
|
|
3970
|
+
} catch (e) {
|
|
3971
|
+
return ctx.json({ error: String(e) }, 500);
|
|
3972
|
+
}
|
|
3973
|
+
});
|
|
3974
|
+
this.router.get("/", async (ctx) => {
|
|
3975
|
+
const uptimeSeconds = Math.floor((Date.now() - this.startTime) / 1e3);
|
|
3976
|
+
const uptime = `${Math.floor(uptimeSeconds / 3600)}h ${Math.floor(uptimeSeconds % 3600 / 60)}m ${uptimeSeconds % 60}s`;
|
|
3977
|
+
const linkPattern = this.getLinkPattern();
|
|
3978
|
+
const template = await readFile(Dashboard.getBasePath() + "/template.eta", "utf8");
|
|
3979
|
+
return ctx.html(this.eta.renderString(template, {
|
|
3980
|
+
metrics: this.metrics,
|
|
3981
|
+
uptime,
|
|
3982
|
+
rootPath: process.cwd(),
|
|
3983
|
+
linkPattern,
|
|
3984
|
+
headers: this.dashboardConfig.getRequestHeaders?.()
|
|
3985
|
+
}));
|
|
3986
|
+
});
|
|
3987
|
+
}
|
|
3988
|
+
instrumentApp(app) {
|
|
3989
|
+
if (!app.getComponentRegistry) return;
|
|
3990
|
+
const registry = app.getComponentRegistry();
|
|
3991
|
+
this.assignIdsToRegistry(registry, "root");
|
|
3992
|
+
this.instrumented = true;
|
|
3993
|
+
}
|
|
3994
|
+
// Traverses registry, generates IDs, and attaches them to the actual function objects
|
|
3995
|
+
assignIdsToRegistry(node, parentId) {
|
|
3996
|
+
if (!node) return;
|
|
3997
|
+
const makeId = (type, parent, idx, name) => `${type}_${parent}_${idx}_${name.replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
3998
|
+
node.middleware?.forEach((mw, idx) => {
|
|
3999
|
+
const id = makeId("mw", parentId, idx, mw.name);
|
|
4000
|
+
mw.id = id;
|
|
4001
|
+
if (mw._fn) mw._fn._debugId = id;
|
|
4002
|
+
});
|
|
4003
|
+
node.controllers?.forEach((ctrl, idx) => {
|
|
4004
|
+
const id = makeId("ctrl", parentId, idx, ctrl.name);
|
|
4005
|
+
ctrl.id = id;
|
|
4006
|
+
});
|
|
4007
|
+
node.routes?.forEach((r, idx) => {
|
|
4008
|
+
const id = makeId("route", parentId, idx, r.handlerName || "handler");
|
|
4009
|
+
r.id = id;
|
|
4010
|
+
if (r._fn) r._fn._debugId = id;
|
|
4011
|
+
});
|
|
4012
|
+
node.routers?.forEach((r, idx) => {
|
|
4013
|
+
const id = makeId("router", parentId, idx, r.path);
|
|
4014
|
+
r.id = id;
|
|
4015
|
+
this.assignIdsToRegistry(r.children, id);
|
|
4016
|
+
});
|
|
4017
|
+
}
|
|
4018
|
+
recordNodeMetric(id, type, duration, isError) {
|
|
4019
|
+
if (!this.metrics.nodeMetrics[id]) {
|
|
4020
|
+
this.metrics.nodeMetrics[id] = {
|
|
4021
|
+
id,
|
|
4022
|
+
type,
|
|
4023
|
+
requests: 0,
|
|
4024
|
+
totalTime: 0,
|
|
4025
|
+
failures: 0,
|
|
4026
|
+
name: id
|
|
4027
|
+
// simplify
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
const m = this.metrics.nodeMetrics[id];
|
|
4031
|
+
m.requests++;
|
|
4032
|
+
m.totalTime += duration;
|
|
4033
|
+
if (isError) m.failures++;
|
|
4034
|
+
}
|
|
4035
|
+
recordEdgeMetric(from, to) {
|
|
4036
|
+
const key = `${from}|${to}`;
|
|
4037
|
+
this.metrics.edgeMetrics[key] = (this.metrics.edgeMetrics[key] || 0) + 1;
|
|
4038
|
+
}
|
|
4039
|
+
getLinkPattern() {
|
|
4040
|
+
const term = process.env["TERM_PROGRAM"] || "";
|
|
4041
|
+
if (["vscode", "cursor", "antigravity"].some((t) => term.includes(t))) {
|
|
4042
|
+
return "vscode://file/{{absolute}}:{{line}}";
|
|
4043
|
+
}
|
|
4044
|
+
return "file:///{{absolute}}:{{line}}";
|
|
4045
|
+
}
|
|
4046
|
+
getHooks() {
|
|
4047
|
+
return {
|
|
4048
|
+
onRequestStart: (ctx) => {
|
|
4049
|
+
const app = this[$appRoot];
|
|
4050
|
+
if (!this.instrumented && app) {
|
|
4051
|
+
this.instrumentApp(app);
|
|
4052
|
+
}
|
|
4053
|
+
this.metrics.totalRequests++;
|
|
4054
|
+
this.metrics.activeRequests++;
|
|
4055
|
+
ctx._debugStartTime = performance.now();
|
|
4056
|
+
ctx[$debug] = new Collector(this);
|
|
4057
|
+
},
|
|
4058
|
+
onResponseEnd: async (ctx, response) => {
|
|
4059
|
+
this.metrics.activeRequests = Math.max(0, this.metrics.activeRequests - 1);
|
|
4060
|
+
const start = ctx._debugStartTime;
|
|
4061
|
+
let duration = 0;
|
|
4062
|
+
if (start) {
|
|
4063
|
+
duration = performance.now() - start;
|
|
4064
|
+
this.updateTiming(duration);
|
|
4065
|
+
}
|
|
4066
|
+
const isError = response.status >= 400;
|
|
4067
|
+
this.metricsCollector.recordRequest(duration, isError);
|
|
4068
|
+
if (response.status >= 400) {
|
|
4069
|
+
this.metrics.failedRequests++;
|
|
4070
|
+
if (response.status === 429) {
|
|
4071
|
+
const path = ctx.path;
|
|
4072
|
+
this.metrics.rateLimitedCounts[path] = (this.metrics.rateLimitedCounts[path] || 0) + 1;
|
|
4073
|
+
}
|
|
4074
|
+
try {
|
|
4075
|
+
const headers = {};
|
|
4076
|
+
if (ctx.request.headers && typeof ctx.request.headers.forEach === "function") {
|
|
4077
|
+
ctx.request.headers.forEach((v, k) => {
|
|
4078
|
+
headers[k] = v;
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
await datastore.set(new RecordId("failed_requests", ctx.requestId), {
|
|
4082
|
+
method: ctx.method,
|
|
4083
|
+
url: ctx.url.toString(),
|
|
4084
|
+
headers,
|
|
4085
|
+
status: response.status,
|
|
4086
|
+
timestamp: Date.now(),
|
|
4087
|
+
state: ctx.state
|
|
4088
|
+
// body?
|
|
4089
|
+
});
|
|
4090
|
+
} catch (e) {
|
|
4091
|
+
console.error("Failed to record failed request", e);
|
|
4092
|
+
}
|
|
4093
|
+
} else {
|
|
4094
|
+
this.metrics.successfulRequests++;
|
|
4095
|
+
}
|
|
4096
|
+
const logEntry = {
|
|
4097
|
+
method: ctx.method,
|
|
4098
|
+
url: ctx.url.toString(),
|
|
4099
|
+
status: response.status,
|
|
4100
|
+
duration,
|
|
4101
|
+
timestamp: Date.now(),
|
|
4102
|
+
handlerStack: ctx.handlerStack
|
|
4103
|
+
};
|
|
4104
|
+
this.metrics.logs.push(logEntry);
|
|
4105
|
+
try {
|
|
4106
|
+
await datastore.set(new RecordId("requests", ctx.requestId), logEntry);
|
|
4107
|
+
} catch (e) {
|
|
4108
|
+
console.error("Failed to record request log", e);
|
|
4109
|
+
}
|
|
4110
|
+
const retention = this.dashboardConfig.retentionMs ?? 72e5;
|
|
4111
|
+
const cutoff = Date.now() - retention;
|
|
4112
|
+
if (this.metrics.logs.length > 0 && this.metrics.logs[0].timestamp < cutoff) {
|
|
4113
|
+
this.metrics.logs = this.metrics.logs.filter((log) => log.timestamp >= cutoff);
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
};
|
|
4117
|
+
}
|
|
4118
|
+
updateTiming(duration) {
|
|
4119
|
+
const alpha = 0.1;
|
|
4120
|
+
if (this.metrics.averageTotalTime_ms === 0) {
|
|
4121
|
+
this.metrics.averageTotalTime_ms = duration;
|
|
4122
|
+
} else {
|
|
4123
|
+
this.metrics.averageTotalTime_ms = alpha * duration + (1 - alpha) * this.metrics.averageTotalTime_ms;
|
|
4124
|
+
}
|
|
4125
|
+
this.metrics.recentTimings.push(duration);
|
|
4126
|
+
if (this.metrics.recentTimings.length > 50) {
|
|
4127
|
+
this.metrics.recentTimings.shift();
|
|
4128
|
+
}
|
|
4129
|
+
}
|
|
4130
|
+
}
|
|
4131
|
+
function unknownError(ctx) {
|
|
4132
|
+
return ctx.json({ error: "Unknown Error" }, 500);
|
|
4133
|
+
}
|
|
3294
4134
|
const eta = new Eta();
|
|
3295
4135
|
class ScalarPlugin extends ShokupanRouter {
|
|
3296
4136
|
constructor(pluginOptions = {}) {
|
|
@@ -3389,23 +4229,23 @@ function Compression(options = {}) {
|
|
|
3389
4229
|
return next();
|
|
3390
4230
|
}
|
|
3391
4231
|
let response = await next();
|
|
3392
|
-
if (!(response instanceof Response) && ctx
|
|
3393
|
-
response = ctx
|
|
4232
|
+
if (!(response instanceof Response) && ctx[$finalResponse] instanceof Response) {
|
|
4233
|
+
response = ctx[$finalResponse];
|
|
3394
4234
|
}
|
|
3395
4235
|
if (response instanceof Response) {
|
|
3396
4236
|
if (response.headers.has("Content-Encoding")) return response;
|
|
3397
4237
|
let body;
|
|
3398
4238
|
let bodySize;
|
|
3399
|
-
if (ctx
|
|
3400
|
-
if (typeof ctx
|
|
3401
|
-
const encoded = new TextEncoder().encode(ctx
|
|
4239
|
+
if (ctx[$rawBody] !== void 0) {
|
|
4240
|
+
if (typeof ctx[$rawBody] === "string") {
|
|
4241
|
+
const encoded = new TextEncoder().encode(ctx[$rawBody]);
|
|
3402
4242
|
body = encoded;
|
|
3403
4243
|
bodySize = encoded.byteLength;
|
|
3404
|
-
} else if (ctx
|
|
3405
|
-
body = ctx
|
|
3406
|
-
bodySize = ctx.
|
|
4244
|
+
} else if (ctx[$rawBody] instanceof Uint8Array) {
|
|
4245
|
+
body = ctx[$rawBody];
|
|
4246
|
+
bodySize = ctx[$rawBody].byteLength;
|
|
3407
4247
|
} else {
|
|
3408
|
-
body = ctx
|
|
4248
|
+
body = ctx[$rawBody];
|
|
3409
4249
|
bodySize = body.byteLength;
|
|
3410
4250
|
}
|
|
3411
4251
|
} else {
|
|
@@ -4271,20 +5111,39 @@ function Session(options) {
|
|
|
4271
5111
|
}
|
|
4272
5112
|
export {
|
|
4273
5113
|
$appRoot,
|
|
5114
|
+
$bodyParseError,
|
|
5115
|
+
$bodyParsed,
|
|
5116
|
+
$bodyType,
|
|
5117
|
+
$cachedBody,
|
|
5118
|
+
$cachedHost,
|
|
5119
|
+
$cachedHostname,
|
|
5120
|
+
$cachedOrigin,
|
|
5121
|
+
$cachedProtocol,
|
|
5122
|
+
$cachedQuery,
|
|
4274
5123
|
$childControllers,
|
|
4275
5124
|
$childRouters,
|
|
4276
5125
|
$controllerPath,
|
|
5126
|
+
$debug,
|
|
4277
5127
|
$dispatch,
|
|
5128
|
+
$eventMethods,
|
|
5129
|
+
$finalResponse,
|
|
5130
|
+
$io,
|
|
4278
5131
|
$isApplication,
|
|
4279
5132
|
$isMounted,
|
|
4280
5133
|
$isRouter,
|
|
4281
5134
|
$middleware,
|
|
4282
5135
|
$mountPath,
|
|
4283
5136
|
$parent,
|
|
5137
|
+
$rawBody,
|
|
5138
|
+
$requestId,
|
|
4284
5139
|
$routeArgs,
|
|
5140
|
+
$routeMatched,
|
|
4285
5141
|
$routeMethods,
|
|
4286
5142
|
$routeSpec,
|
|
4287
5143
|
$routes,
|
|
5144
|
+
$socket,
|
|
5145
|
+
$url,
|
|
5146
|
+
$ws,
|
|
4288
5147
|
All,
|
|
4289
5148
|
AuthPlugin,
|
|
4290
5149
|
Body,
|
|
@@ -4294,7 +5153,9 @@ export {
|
|
|
4294
5153
|
Controller,
|
|
4295
5154
|
Cors,
|
|
4296
5155
|
Ctx,
|
|
5156
|
+
Dashboard,
|
|
4297
5157
|
Delete,
|
|
5158
|
+
Event,
|
|
4298
5159
|
Get,
|
|
4299
5160
|
HTTPMethods,
|
|
4300
5161
|
Head,
|