@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/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
  }
@@ -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
- const entry = await options.storage.read(url.pathname);
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 textResponse(400, "Bad Request");
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(textResponse(413, rpcBodyTooLargeText(rpcLimit, contentLength)));
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
- const response = await handler(webRequest);
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
- textResponse(413, isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large"),
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(textResponse(500, "Internal Server Error"));
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
- const response = await app.handler(request);
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
- respondBadRequest(res);
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
- const entry = await options.storage.read(url.pathname);
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.statusCode = 413;
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
- const response = await handler(request);
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
- res.statusCode = 413;
244
- res.setHeader("content-type", "text/plain; charset=utf-8");
245
- res.end(isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large");
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.statusCode = 500;
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
- function respondBadRequest(res: import("node:http").ServerResponse): void {
300
- res.statusCode = 400;
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
- res.end("Bad Request");
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
  }
@@ -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
  };
@@ -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;