@moonsjs/vite 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{index-df6b8rqt.js → index-sdm38v1p.js} +84 -4
- package/dist/{index-df6b8rqt.js.map → index-sdm38v1p.js.map} +4 -4
- package/dist/index.js +7 -4
- package/dist/index.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/server/bun-server.d.ts.map +1 -1
- package/dist/server/dev.d.ts +2 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/index.js +115 -25
- package/dist/server/index.js.map +5 -5
- package/dist/server/node-server.d.ts +2 -0
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/transport.d.ts +16 -0
- package/dist/server/transport.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/plugin.ts +4 -1
- package/src/server/bun-server.ts +67 -9
- package/src/server/dev.ts +27 -2
- package/src/server/node-server.ts +77 -15
- package/src/server/storage.ts +10 -0
- package/src/server/transport.ts +90 -0
package/src/plugin.ts
CHANGED
|
@@ -60,6 +60,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
60
60
|
// Addendum J.1/J.2: paritas dev & produksi (paritas E7).
|
|
61
61
|
let trustProxy = false;
|
|
62
62
|
let bodyLimitBytes: number | undefined;
|
|
63
|
+
let requestTimeoutMs: number | undefined;
|
|
63
64
|
let apiDir = "";
|
|
64
65
|
let apiRoutes: ApiRouteDefinition[] = [];
|
|
65
66
|
let apiMiddlewares: ApiMiddlewareDefinition[] = [];
|
|
@@ -251,6 +252,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
251
252
|
serverOnlyPackages = config.security?.serverOnlyPackages ?? [];
|
|
252
253
|
trustProxy = config.security?.trustProxy === true;
|
|
253
254
|
bodyLimitBytes = config.security?.bodyLimitBytes;
|
|
255
|
+
requestTimeoutMs = config.requestTimeoutMs;
|
|
254
256
|
|
|
255
257
|
// Addendum D.1: scan + validasi statis modul `src/api`.
|
|
256
258
|
apiDir = resolvePath(root, config.apiDir ?? "src/api");
|
|
@@ -466,6 +468,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
466
468
|
: { apiPrefix: `/${apiDir.split(/[\\/]/).filter((part) => part.length > 0).pop() ?? "api"}` }),
|
|
467
469
|
trustProxy,
|
|
468
470
|
...(bodyLimitBytes === undefined ? {} : { bodyLimitBytes }),
|
|
471
|
+
...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }),
|
|
469
472
|
});
|
|
470
473
|
server.config.logger.info("[moons] devtools: /__moons/devtools");
|
|
471
474
|
},
|
|
@@ -579,7 +582,7 @@ function renderServerEntryBuild(
|
|
|
579
582
|
" onContext: (ctx) => plugins.applyExtensions(ctx),",
|
|
580
583
|
"});",
|
|
581
584
|
"",
|
|
582
|
-
`startServer(handler, { plugins, headers: config.headers, storage: config.isr?.storage, trustProxy: config.security?.trustProxy, bodyLimitBytes: config.security?.bodyLimitBytes, logging: config.logging, trailingSlash: config.trailingSlash${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
|
|
585
|
+
`startServer(handler, { plugins, headers: config.headers, storage: config.isr?.storage, trustProxy: config.security?.trustProxy, bodyLimitBytes: config.security?.bodyLimitBytes, requestTimeoutMs: config.requestTimeoutMs, logging: config.logging, trailingSlash: config.trailingSlash${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
|
|
583
586
|
"",
|
|
584
587
|
].join("\n");
|
|
585
588
|
}
|
package/src/server/bun-server.ts
CHANGED
|
@@ -10,16 +10,21 @@ import {
|
|
|
10
10
|
InvalidRequestError,
|
|
11
11
|
MAX_TRANSPORT_BODY_BYTES,
|
|
12
12
|
MIME_TYPES,
|
|
13
|
+
RequestTimeoutError,
|
|
13
14
|
acceptsGzipHeader,
|
|
14
15
|
applyStaticHeaders,
|
|
15
16
|
forwardedHost,
|
|
16
17
|
forwardedProtocol,
|
|
17
18
|
gzipCached,
|
|
19
|
+
isAbortLike,
|
|
18
20
|
readBodyWithLimit,
|
|
21
|
+
requestTimeoutText,
|
|
19
22
|
resolveClientIpValues,
|
|
20
23
|
resolveStaticFile,
|
|
21
24
|
rpcBodyTooLargeText,
|
|
25
|
+
runHandlerWithTimeout,
|
|
22
26
|
scheduleRevalidation,
|
|
27
|
+
textResponseWithHeaders,
|
|
23
28
|
} from "./transport";
|
|
24
29
|
import { createLogger, type Logger } from "./logger";
|
|
25
30
|
import type { NodeServerOptions } from "./node-server";
|
|
@@ -37,9 +42,6 @@ interface BunServerHandle {
|
|
|
37
42
|
stop(closeActiveConnections?: boolean): Promise<void>;
|
|
38
43
|
}
|
|
39
44
|
|
|
40
|
-
const textResponse = (status: number, text: string): Response =>
|
|
41
|
-
new Response(text, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
42
|
-
|
|
43
45
|
function appendVaryHeader(headers: Headers, value: string): void {
|
|
44
46
|
const existing = headers.get("vary");
|
|
45
47
|
if (existing !== null && existing.length > 0) {
|
|
@@ -192,6 +194,13 @@ export function startBunServer(
|
|
|
192
194
|
const clientDir = resolve(options.clientDir ?? join(process.cwd(), "dist/client"));
|
|
193
195
|
const port = options.port ?? Number(process.env["PORT"] ?? 3000);
|
|
194
196
|
const trustProxy = options.trustProxy === true;
|
|
197
|
+
const requestTimeoutMs = options.requestTimeoutMs;
|
|
198
|
+
// Addendum R: idleTimeout Bun diselaraskan dengan batas kita (+5 dtk grace)
|
|
199
|
+
// supaya 504 dari framework yang menang, bukan koneksi diputus diam-diam.
|
|
200
|
+
const idleTimeout =
|
|
201
|
+
requestTimeoutMs === undefined || requestTimeoutMs <= 0
|
|
202
|
+
? 0
|
|
203
|
+
: Math.ceil(requestTimeoutMs / 1000) + 5;
|
|
195
204
|
const accessLogger: Logger | null =
|
|
196
205
|
options.logging?.accessLog === true
|
|
197
206
|
? createLogger({
|
|
@@ -205,7 +214,19 @@ export function startBunServer(
|
|
|
205
214
|
url: URL,
|
|
206
215
|
): Promise<Response | null> => {
|
|
207
216
|
if (options.storage !== undefined) {
|
|
208
|
-
|
|
217
|
+
// Addendum R (review G2): storage error = miss — aset & halaman non-ISR
|
|
218
|
+
// harus tetap jalan saat store bersama outage.
|
|
219
|
+
let entry: Awaited<ReturnType<NonNullable<typeof options.storage>["read"]>> | null = null;
|
|
220
|
+
try {
|
|
221
|
+
entry = await options.storage.read(url.pathname);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
if (accessLogger !== null) {
|
|
225
|
+
accessLogger({ msg: "isr:storage-read-failed", level: "warn", path: url.pathname, error: message });
|
|
226
|
+
} else {
|
|
227
|
+
console.warn(`[moons] isr storage read failed (${url.pathname}): ${message} — falling back to dist/client`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
209
230
|
if (entry !== null) {
|
|
210
231
|
scheduleRevalidation({
|
|
211
232
|
pathname: url.pathname,
|
|
@@ -274,6 +295,7 @@ export function startBunServer(
|
|
|
274
295
|
|
|
275
296
|
const server = Bun.serve({
|
|
276
297
|
port,
|
|
298
|
+
idleTimeout,
|
|
277
299
|
fetch: async (request, bunServer) => {
|
|
278
300
|
const requestStartedAt = performance.now();
|
|
279
301
|
let url: URL;
|
|
@@ -282,7 +304,7 @@ export function startBunServer(
|
|
|
282
304
|
} catch (error) {
|
|
283
305
|
if (error instanceof InvalidRequestError) {
|
|
284
306
|
console.warn(`[moons] ${error.message} — 400`);
|
|
285
|
-
return
|
|
307
|
+
return textResponseWithHeaders(400, "Bad Request", options.headers);
|
|
286
308
|
}
|
|
287
309
|
throw error;
|
|
288
310
|
}
|
|
@@ -324,7 +346,9 @@ export function startBunServer(
|
|
|
324
346
|
if (isRpc) {
|
|
325
347
|
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
|
326
348
|
if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
|
|
327
|
-
return finish(
|
|
349
|
+
return finish(
|
|
350
|
+
textResponseWithHeaders(413, rpcBodyTooLargeText(rpcLimit, contentLength), options.headers),
|
|
351
|
+
);
|
|
328
352
|
}
|
|
329
353
|
}
|
|
330
354
|
|
|
@@ -346,17 +370,51 @@ export function startBunServer(
|
|
|
346
370
|
clientIp,
|
|
347
371
|
isRpc ? rpcLimit : MAX_TRANSPORT_BODY_BYTES,
|
|
348
372
|
);
|
|
349
|
-
|
|
373
|
+
// Addendum R: batas waktu eksplisit; 0/undefined = tanpa batas.
|
|
374
|
+
const response = await runHandlerWithTimeout(handler, webRequest, requestTimeoutMs);
|
|
350
375
|
return finish(withDefaultSecurityHeaders(response));
|
|
351
376
|
} catch (error) {
|
|
352
377
|
if (error instanceof BodyLimitError) {
|
|
353
378
|
const isRpc = url.pathname.startsWith("/_moons/fn/");
|
|
354
379
|
return finish(
|
|
355
|
-
|
|
380
|
+
textResponseWithHeaders(
|
|
381
|
+
413,
|
|
382
|
+
isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large",
|
|
383
|
+
options.headers,
|
|
384
|
+
),
|
|
356
385
|
);
|
|
357
386
|
}
|
|
387
|
+
// Addendum R: timeout eksplisit — 504 + log terstruktur.
|
|
388
|
+
if (error instanceof RequestTimeoutError) {
|
|
389
|
+
if (accessLogger !== null) {
|
|
390
|
+
accessLogger({
|
|
391
|
+
msg: "request-timeout",
|
|
392
|
+
level: "warn",
|
|
393
|
+
method: request.method,
|
|
394
|
+
path: url.pathname + url.search,
|
|
395
|
+
limitMs: error.limit,
|
|
396
|
+
});
|
|
397
|
+
} else {
|
|
398
|
+
console.warn(`[moons] request timed out after ${error.limit}ms — ${request.method} ${url.pathname}`);
|
|
399
|
+
}
|
|
400
|
+
return finish(textResponseWithHeaders(504, requestTimeoutText(error.limit), options.headers));
|
|
401
|
+
}
|
|
402
|
+
// Review G7: abort klien/koneksi putus bukan error server (tanpa stack).
|
|
403
|
+
if (isAbortLike(error)) {
|
|
404
|
+
if (accessLogger !== null) {
|
|
405
|
+
accessLogger({
|
|
406
|
+
msg: "request-aborted",
|
|
407
|
+
level: "warn",
|
|
408
|
+
method: request.method,
|
|
409
|
+
path: url.pathname + url.search,
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
console.warn(`[moons] request aborted by the client — ${request.method} ${url.pathname}`);
|
|
413
|
+
}
|
|
414
|
+
return new Response(null, { status: 499 });
|
|
415
|
+
}
|
|
358
416
|
console.error("[moons] server error:", error);
|
|
359
|
-
return finish(
|
|
417
|
+
return finish(textResponseWithHeaders(500, "Internal Server Error", options.headers));
|
|
360
418
|
}
|
|
361
419
|
},
|
|
362
420
|
});
|
package/src/server/dev.ts
CHANGED
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
toWebRequest,
|
|
14
14
|
} from "./node";
|
|
15
15
|
import { DEFAULT_BODY_LIMIT_BYTES } from "@moonsjs/core";
|
|
16
|
+
import {
|
|
17
|
+
RequestTimeoutError,
|
|
18
|
+
applyStaticHeaders,
|
|
19
|
+
requestTimeoutText,
|
|
20
|
+
runHandlerWithTimeout,
|
|
21
|
+
} from "./transport";
|
|
16
22
|
import { emptyDevtoolsData, renderDevtoolsHtml, type DevtoolsData } from "../devtools";
|
|
17
23
|
|
|
18
24
|
interface DevApp {
|
|
@@ -41,6 +47,8 @@ export function installDevMiddleware(
|
|
|
41
47
|
/** Addendum J.1: paritas dev dengan produksi (trustProxy/bodyLimitBytes). */
|
|
42
48
|
trustProxy?: boolean;
|
|
43
49
|
bodyLimitBytes?: number;
|
|
50
|
+
/** Addendum R: batas waktu respons (ms); 0/undefined = tanpa batas. */
|
|
51
|
+
requestTimeoutMs?: number;
|
|
44
52
|
/** Addendum I.4: datasource devtools overlay (dipanggil per request). */
|
|
45
53
|
devtools?: () => DevtoolsData;
|
|
46
54
|
},
|
|
@@ -110,7 +118,7 @@ async function handleRequest(
|
|
|
110
118
|
getApp: () => Promise<DevApp>,
|
|
111
119
|
req: IncomingMessage,
|
|
112
120
|
res: ServerResponse,
|
|
113
|
-
options: { trustProxy?: boolean; bodyLimitBytes?: number },
|
|
121
|
+
options: { trustProxy?: boolean; bodyLimitBytes?: number; requestTimeoutMs?: number },
|
|
114
122
|
): Promise<void> {
|
|
115
123
|
const app = await getApp();
|
|
116
124
|
const url = resolveRequestUrl(req, { trustProxy: options.trustProxy === true });
|
|
@@ -119,7 +127,24 @@ async function handleRequest(
|
|
|
119
127
|
trustProxy: options.trustProxy === true,
|
|
120
128
|
...(isRpc ? { maxBodyBytes: options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES } : {}),
|
|
121
129
|
});
|
|
122
|
-
|
|
130
|
+
// Addendum R: paritas dev — batas waktu eksplisit yang sama dengan produksi.
|
|
131
|
+
let response: Response;
|
|
132
|
+
try {
|
|
133
|
+
response = await runHandlerWithTimeout(app.handler, request, options.requestTimeoutMs);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof RequestTimeoutError) {
|
|
136
|
+
res.statusCode = 504;
|
|
137
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
138
|
+
applyStaticHeaders(
|
|
139
|
+
{ has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
|
|
140
|
+
"/",
|
|
141
|
+
undefined,
|
|
142
|
+
);
|
|
143
|
+
res.end(requestTimeoutText(error.limit));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
123
148
|
await sendNodeResponse(res, response);
|
|
124
149
|
}
|
|
125
150
|
|
|
@@ -13,11 +13,15 @@ import {
|
|
|
13
13
|
GZIP_MIN_BYTES,
|
|
14
14
|
InvalidRequestError,
|
|
15
15
|
MIME_TYPES,
|
|
16
|
+
RequestTimeoutError,
|
|
16
17
|
acceptsGzipHeader,
|
|
17
18
|
applyStaticHeaders,
|
|
18
19
|
gzipCached,
|
|
20
|
+
isAbortLike,
|
|
21
|
+
requestTimeoutText,
|
|
19
22
|
resolveStaticFile,
|
|
20
23
|
rpcBodyTooLargeText,
|
|
24
|
+
runHandlerWithTimeout,
|
|
21
25
|
scheduleRevalidation,
|
|
22
26
|
} from "./transport";
|
|
23
27
|
import {
|
|
@@ -64,6 +68,8 @@ export interface NodeServerOptions {
|
|
|
64
68
|
logging?: { accessLog?: boolean; json?: boolean; redact?: string[] };
|
|
65
69
|
/** Addendum M: kebijakan trailing slash (juga untuk path prerender/static). */
|
|
66
70
|
trailingSlash?: "never" | "always";
|
|
71
|
+
/** Addendum R: batas waktu respons handler (ms); `0` menonaktifkan. */
|
|
72
|
+
requestTimeoutMs?: number;
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/**
|
|
@@ -109,7 +115,7 @@ export function startNodeServer(
|
|
|
109
115
|
} catch (error) {
|
|
110
116
|
if (error instanceof InvalidRequestError) {
|
|
111
117
|
console.warn(`[moons] ${error.message} — 400`);
|
|
112
|
-
|
|
118
|
+
respondText(res, 400, "Bad Request", options.headers);
|
|
113
119
|
return;
|
|
114
120
|
}
|
|
115
121
|
throw error;
|
|
@@ -132,7 +138,19 @@ export function startNodeServer(
|
|
|
132
138
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
133
139
|
// Addendum I: storage bersama (bila dikonfigurasi) diutamakan.
|
|
134
140
|
if (options.storage !== undefined) {
|
|
135
|
-
|
|
141
|
+
// Addendum R (review G2): storage error = miss — aset & halaman
|
|
142
|
+
// non-ISR harus tetap jalan saat store bersama outage.
|
|
143
|
+
let entry: Awaited<ReturnType<PrerenderStorage["read"]>> | null = null;
|
|
144
|
+
try {
|
|
145
|
+
entry = await options.storage.read(url.pathname);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
148
|
+
if (accessLogger !== null) {
|
|
149
|
+
accessLogger({ msg: "isr:storage-read-failed", level: "warn", path: url.pathname, error: message });
|
|
150
|
+
} else {
|
|
151
|
+
console.warn(`[moons] isr storage read failed (${url.pathname}): ${message} — falling back to dist/client`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
136
154
|
if (entry !== null) {
|
|
137
155
|
scheduleRevalidation({
|
|
138
156
|
pathname: url.pathname,
|
|
@@ -223,9 +241,7 @@ export function startNodeServer(
|
|
|
223
241
|
if (isRpc) {
|
|
224
242
|
const contentLength = Number(req.headers["content-length"] ?? "0");
|
|
225
243
|
if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
|
|
226
|
-
res
|
|
227
|
-
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
228
|
-
res.end(rpcBodyTooLargeText(rpcLimit, contentLength));
|
|
244
|
+
respondText(res, 413, rpcBodyTooLargeText(rpcLimit, contentLength), options.headers);
|
|
229
245
|
res.once("finish", () => req.destroy());
|
|
230
246
|
return;
|
|
231
247
|
}
|
|
@@ -235,22 +251,57 @@ export function startNodeServer(
|
|
|
235
251
|
trustProxy: options.trustProxy === true,
|
|
236
252
|
...(isRpc ? { maxBodyBytes: rpcLimit } : {}),
|
|
237
253
|
});
|
|
238
|
-
|
|
254
|
+
// Addendum R: batas waktu eksplisit; 0/undefined = tanpa batas.
|
|
255
|
+
const response = await runHandlerWithTimeout(handler, request, options.requestTimeoutMs);
|
|
239
256
|
await sendNodeResponse(res, response);
|
|
240
257
|
} catch (error) {
|
|
241
258
|
if (error instanceof BodyLimitError) {
|
|
242
259
|
const isRpc = url.pathname.startsWith("/_moons/fn/");
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
260
|
+
respondText(
|
|
261
|
+
res,
|
|
262
|
+
413,
|
|
263
|
+
isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large",
|
|
264
|
+
options.headers,
|
|
265
|
+
);
|
|
246
266
|
// Sisa body tidak perlu dibaca — putuskan setelah respons terkirim.
|
|
247
267
|
res.once("finish", () => req.destroy());
|
|
248
268
|
return;
|
|
249
269
|
}
|
|
270
|
+
// Addendum R: timeout eksplisit — 504 + log terstruktur.
|
|
271
|
+
if (error instanceof RequestTimeoutError) {
|
|
272
|
+
if (accessLogger !== null) {
|
|
273
|
+
accessLogger({
|
|
274
|
+
msg: "request-timeout",
|
|
275
|
+
level: "warn",
|
|
276
|
+
method: req.method ?? "GET",
|
|
277
|
+
path: url.pathname + url.search,
|
|
278
|
+
limitMs: error.limit,
|
|
279
|
+
});
|
|
280
|
+
} else {
|
|
281
|
+
console.warn(`[moons] request timed out after ${error.limit}ms — ${req.method ?? "GET"} ${url.pathname}`);
|
|
282
|
+
}
|
|
283
|
+
respondText(res, 504, requestTimeoutText(error.limit), options.headers);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
// Review G7: abort klien/koneksi putus bukan error server (tanpa stack).
|
|
287
|
+
if (isAbortLike(error)) {
|
|
288
|
+
if (accessLogger !== null) {
|
|
289
|
+
accessLogger({
|
|
290
|
+
msg: "request-aborted",
|
|
291
|
+
level: "warn",
|
|
292
|
+
method: req.method ?? "GET",
|
|
293
|
+
path: url.pathname + url.search,
|
|
294
|
+
});
|
|
295
|
+
} else {
|
|
296
|
+
console.warn(`[moons] request aborted by the client — ${req.method ?? "GET"} ${url.pathname}`);
|
|
297
|
+
}
|
|
298
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
299
|
+
respondText(res, 499, "Client Closed Request", options.headers);
|
|
300
|
+
}
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
250
303
|
console.error("[moons] server error:", error);
|
|
251
|
-
res
|
|
252
|
-
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
253
|
-
res.end("Internal Server Error");
|
|
304
|
+
respondText(res, 500, "Internal Server Error", options.headers);
|
|
254
305
|
}
|
|
255
306
|
})();
|
|
256
307
|
});
|
|
@@ -296,8 +347,19 @@ function appendVary(res: import("node:http").ServerResponse, value: string): voi
|
|
|
296
347
|
res.setHeader("vary", value);
|
|
297
348
|
}
|
|
298
349
|
|
|
299
|
-
|
|
300
|
-
|
|
350
|
+
/** Respons teks transport — ikut membawa header config + default aman (review G6). */
|
|
351
|
+
function respondText(
|
|
352
|
+
res: import("node:http").ServerResponse,
|
|
353
|
+
status: number,
|
|
354
|
+
text: string,
|
|
355
|
+
configured: Record<string, string> | undefined,
|
|
356
|
+
): void {
|
|
357
|
+
res.statusCode = status;
|
|
301
358
|
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
302
|
-
|
|
359
|
+
applyStaticHeaders(
|
|
360
|
+
{ has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
|
|
361
|
+
"/",
|
|
362
|
+
configured,
|
|
363
|
+
);
|
|
364
|
+
res.end(text);
|
|
303
365
|
}
|
package/src/server/storage.ts
CHANGED
|
@@ -50,4 +50,14 @@ const MIME: Record<string, string> = {
|
|
|
50
50
|
".webp": "image/webp",
|
|
51
51
|
".woff": "font/woff",
|
|
52
52
|
".woff2": "font/woff2",
|
|
53
|
+
".xml": "application/xml; charset=utf-8",
|
|
54
|
+
".csv": "text/csv; charset=utf-8",
|
|
55
|
+
".md": "text/markdown; charset=utf-8",
|
|
56
|
+
".pdf": "application/pdf",
|
|
57
|
+
".mp4": "video/mp4",
|
|
58
|
+
".webm": "video/webm",
|
|
59
|
+
".mp3": "audio/mpeg",
|
|
60
|
+
".wav": "audio/wav",
|
|
61
|
+
".zip": "application/zip",
|
|
62
|
+
".gz": "application/gzip",
|
|
53
63
|
};
|
package/src/server/transport.ts
CHANGED
|
@@ -134,6 +134,16 @@ export const MIME_TYPES: Record<string, string> = {
|
|
|
134
134
|
".ttf": "font/ttf",
|
|
135
135
|
".txt": "text/plain; charset=utf-8",
|
|
136
136
|
".wasm": "application/wasm",
|
|
137
|
+
".xml": "application/xml; charset=utf-8",
|
|
138
|
+
".csv": "text/csv; charset=utf-8",
|
|
139
|
+
".md": "text/markdown; charset=utf-8",
|
|
140
|
+
".pdf": "application/pdf",
|
|
141
|
+
".mp4": "video/mp4",
|
|
142
|
+
".webm": "video/webm",
|
|
143
|
+
".mp3": "audio/mpeg",
|
|
144
|
+
".wav": "audio/wav",
|
|
145
|
+
".zip": "application/zip",
|
|
146
|
+
".gz": "application/gzip",
|
|
137
147
|
};
|
|
138
148
|
|
|
139
149
|
export const COMPRESSIBLE_FILE = /\.(js|mjs|css|html|json|svg|txt|map|xml)$/i;
|
|
@@ -187,6 +197,86 @@ export function rpcBodyTooLargeText(limit: number, size: number | null): string
|
|
|
187
197
|
}).format();
|
|
188
198
|
}
|
|
189
199
|
|
|
200
|
+
/** Handler melewati `requestTimeoutMs` → transport menjawab 504 (Addendum R). */
|
|
201
|
+
export class RequestTimeoutError extends Error {
|
|
202
|
+
readonly limit: number;
|
|
203
|
+
constructor(limit: number) {
|
|
204
|
+
super(`handler did not respond within ${limit} ms`);
|
|
205
|
+
this.name = "RequestTimeoutError";
|
|
206
|
+
this.limit = limit;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** 504 dengan format §15 (dipakai kedua transport). */
|
|
211
|
+
export function requestTimeoutText(limit: number): string {
|
|
212
|
+
return new MoonsError({
|
|
213
|
+
code: "E_REQUEST_TIMEOUT",
|
|
214
|
+
message: `handler did not respond within ${limit} ms`,
|
|
215
|
+
details:
|
|
216
|
+
"The transport stopped waiting for the handler and answered 504. " +
|
|
217
|
+
"The handler keeps running until it settles; JavaScript cannot cancel it.",
|
|
218
|
+
suggestions: [
|
|
219
|
+
"Make the handler faster, or move slow work out of the request path",
|
|
220
|
+
"Raise `requestTimeoutMs`, or set it to 0 to disable the timeout",
|
|
221
|
+
],
|
|
222
|
+
}).format();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Jalankan handler dengan batas waktu eksplisit (Addendum R; review G1).
|
|
227
|
+
* `timeoutMs` undefined/0 → tanpa batas (perilaku lama).
|
|
228
|
+
*/
|
|
229
|
+
export async function runHandlerWithTimeout(
|
|
230
|
+
handler: (request: Request) => Promise<Response>,
|
|
231
|
+
request: Request,
|
|
232
|
+
timeoutMs: number | undefined,
|
|
233
|
+
): Promise<Response> {
|
|
234
|
+
if (timeoutMs === undefined || timeoutMs <= 0) {
|
|
235
|
+
return handler(request);
|
|
236
|
+
}
|
|
237
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
238
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
239
|
+
timer = setTimeout(() => reject(new RequestTimeoutError(timeoutMs)), timeoutMs);
|
|
240
|
+
});
|
|
241
|
+
try {
|
|
242
|
+
return await Promise.race([handler(request), timeout]);
|
|
243
|
+
} finally {
|
|
244
|
+
if (timer !== undefined) {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Klasifikasi abort klien / koneksi putus (review G7) — bukan error server. */
|
|
251
|
+
export function isAbortLike(error: unknown): boolean {
|
|
252
|
+
if (error instanceof DOMException) {
|
|
253
|
+
return error.name === "AbortError" || error.name === "TimeoutError";
|
|
254
|
+
}
|
|
255
|
+
if (error instanceof Error) {
|
|
256
|
+
if (error.name === "AbortError") {
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
const code = (error as { code?: unknown }).code;
|
|
260
|
+
if (code === "ECONNRESET" || code === "EPIPE" || code === "ERR_STREAM_PREMATURE_CLOSE") {
|
|
261
|
+
return true;
|
|
262
|
+
}
|
|
263
|
+
const message = error.message.toLowerCase();
|
|
264
|
+
return message === "aborted" || message.includes("aborted") || message.includes("premature close");
|
|
265
|
+
}
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Respons teks transport (400/413/500/504) — tetap membawa header config + default aman (review G6). */
|
|
270
|
+
export function textResponseWithHeaders(
|
|
271
|
+
status: number,
|
|
272
|
+
text: string,
|
|
273
|
+
configured?: Record<string, string>,
|
|
274
|
+
): Response {
|
|
275
|
+
const headers = new Headers({ "content-type": "text/plain; charset=utf-8" });
|
|
276
|
+
applyStaticHeaders(headers, "/", configured);
|
|
277
|
+
return new Response(text, { status, headers });
|
|
278
|
+
}
|
|
279
|
+
|
|
190
280
|
/** Target header minimal yang dipenuhi `ServerResponse` dan `Headers`. */
|
|
191
281
|
export interface HeaderTarget {
|
|
192
282
|
has(name: string): boolean;
|