@moonsjs/vite 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/plugin.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
- import { resolve as resolvePath } from "node:path";
3
+ import { relative as relativePath, resolve as resolvePath } from "node:path";
4
4
  import {
5
+ MOONS_VERSION,
5
6
  MoonsError,
6
7
  fnRegistryKey,
7
8
  generateTypes,
@@ -56,6 +57,9 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
56
57
  let configFilePath = "";
57
58
  let routesDir = "";
58
59
  let serverOnlyPackages: ReadonlyArray<string> = [];
60
+ // Addendum J.1/J.2: paritas dev & produksi (paritas E7).
61
+ let trustProxy = false;
62
+ let bodyLimitBytes: number | undefined;
59
63
  let apiDir = "";
60
64
  let apiRoutes: ApiRouteDefinition[] = [];
61
65
  let apiMiddlewares: ApiMiddlewareDefinition[] = [];
@@ -245,6 +249,8 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
245
249
  const config = validateConfig(raw, { file: configFilePath, projectRoot: root });
246
250
  routesDir = resolvePath(root, config.routesDir);
247
251
  serverOnlyPackages = config.security?.serverOnlyPackages ?? [];
252
+ trustProxy = config.security?.trustProxy === true;
253
+ bodyLimitBytes = config.security?.bodyLimitBytes;
248
254
 
249
255
  // Addendum D.1: scan + validasi statis modul `src/api`.
250
256
  apiDir = resolvePath(root, config.apiDir ?? "src/api");
@@ -366,6 +372,8 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
366
372
  },
367
373
  analyze: (absoluteFile) => analysisCache.get(absoluteFile) ?? analyzeFile(absoluteFile),
368
374
  apiDir,
375
+ configFile: configFilePath,
376
+ serverDir: resolvePath(root, "src/server"),
369
377
  serverOnlyPackages,
370
378
  packageCache,
371
379
  });
@@ -423,6 +431,30 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
423
431
  configureServer(server) {
424
432
  installDevMiddleware(server, {
425
433
  templatePath: resolvePath(root, "index.html"),
434
+ // Addendum I.4 (W4): overlay devtools — data segar per request.
435
+ devtools: () => {
436
+ const toRelative = (file: string): string =>
437
+ file.startsWith(root) ? relativePath(root, file).split("\\").join("/") : file;
438
+ return {
439
+ routes: [...routes]
440
+ .sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind))
441
+ .map((route) => ({ path: route.path, kind: route.kind, file: toRelative(route.file) })),
442
+ apiRoutes: [...apiRoutes]
443
+ .sort((a, b) => a.path.localeCompare(b.path))
444
+ .map((route) => ({ path: route.path, kind: "api", file: toRelative(route.file) })),
445
+ serverFns: [...analysisCache.values()]
446
+ .flatMap((analysis) =>
447
+ analysis.serverFns.map((fn) => ({
448
+ file: toRelative(analysis.file),
449
+ name: fn.name,
450
+ hash: fn.hash,
451
+ })),
452
+ )
453
+ .sort((a, b) => a.file.localeCompare(b.file) || a.name.localeCompare(b.name)),
454
+ canary: options.leakTest?.canary !== undefined,
455
+ version: MOONS_VERSION,
456
+ };
457
+ },
426
458
  // A1: pemindaian payload SSR hanya di dev (warning), tanpa biaya di production.
427
459
  payloadScan: {
428
460
  ...(options.leakTest?.canary === undefined ? {} : { canary: options.leakTest.canary }),
@@ -432,7 +464,10 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
432
464
  ...(apiRoutes.length === 0
433
465
  ? {}
434
466
  : { apiPrefix: `/${apiDir.split(/[\\/]/).filter((part) => part.length > 0).pop() ?? "api"}` }),
467
+ trustProxy,
468
+ ...(bodyLimitBytes === undefined ? {} : { bodyLimitBytes }),
435
469
  });
470
+ server.config.logger.info("[moons] devtools: /__moons/devtools");
436
471
  },
437
472
 
438
473
  async handleHotUpdate(context) {
@@ -541,7 +576,7 @@ function renderServerEntryBuild(
541
576
  " onContext: (ctx) => plugins.applyExtensions(ctx),",
542
577
  "});",
543
578
  "",
544
- `startNodeServer(handler, { plugins, headers: config.headers, storage: config.isr?.storage${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
579
+ `startNodeServer(handler, { plugins, headers: config.headers, storage: config.isr?.storage, trustProxy: config.security?.trustProxy, bodyLimitBytes: config.security?.bodyLimitBytes${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
545
580
  "",
546
581
  ].join("\n");
547
582
  }
package/src/server/dev.ts CHANGED
@@ -5,7 +5,15 @@ import type { PayloadScanOptions } from "../payload-scan";
5
5
  import type { PluginRuntime } from "./plugins";
6
6
  import type { Shell } from "./shell";
7
7
  import { createDevShell } from "./shell";
8
- import { sendNodeResponse, toWebRequest } from "./node";
8
+ import {
9
+ BodyLimitError,
10
+ InvalidRequestError,
11
+ resolveRequestUrl,
12
+ sendNodeResponse,
13
+ toWebRequest,
14
+ } from "./node";
15
+ import { DEFAULT_BODY_LIMIT_BYTES } from "@moonsjs/core";
16
+ import { emptyDevtoolsData, renderDevtoolsHtml, type DevtoolsData } from "../devtools";
9
17
 
10
18
  interface DevApp {
11
19
  handler: (request: Request) => Promise<Response>;
@@ -26,7 +34,16 @@ type DevServerEntry = {
26
34
  */
27
35
  export function installDevMiddleware(
28
36
  server: ViteDevServer,
29
- options: { templatePath: string; payloadScan?: PayloadScanOptions; apiPrefix?: string },
37
+ options: {
38
+ templatePath: string;
39
+ payloadScan?: PayloadScanOptions;
40
+ apiPrefix?: string;
41
+ /** Addendum J.1: paritas dev dengan produksi (trustProxy/bodyLimitBytes). */
42
+ trustProxy?: boolean;
43
+ bodyLimitBytes?: number;
44
+ /** Addendum I.4: datasource devtools overlay (dipanggil per request). */
45
+ devtools?: () => DevtoolsData;
46
+ },
30
47
  ): void {
31
48
  let appPromise: Promise<DevApp> | null = null;
32
49
 
@@ -48,11 +65,42 @@ export function installDevMiddleware(
48
65
  });
49
66
 
50
67
  server.middlewares.use((req, res, next) => {
68
+ // Addendum I.4 (W4): overlay dev-only — selalu data segar per request.
69
+ if (
70
+ (req.method === "GET" || req.method === "HEAD") &&
71
+ new URL(req.url ?? "/", "http://localhost").pathname === "/__moons/devtools"
72
+ ) {
73
+ try {
74
+ const data = options.devtools?.() ?? emptyDevtoolsData();
75
+ res.statusCode = 200;
76
+ res.setHeader("content-type", "text/html; charset=utf-8");
77
+ res.end(req.method === "HEAD" ? undefined : renderDevtoolsHtml(data));
78
+ } catch (error) {
79
+ res.statusCode = 500;
80
+ res.setHeader("content-type", "text/plain; charset=utf-8");
81
+ res.end(`[moons] devtools gagal dirender: ${String(error)}`);
82
+ }
83
+ return;
84
+ }
85
+
51
86
  if (!shouldHandle(req, options.apiPrefix)) {
52
87
  next();
53
88
  return;
54
89
  }
55
- void handleRequest(getApp, req, res).catch((error: unknown) => {
90
+ void handleRequest(getApp, req, res, options).catch((error: unknown) => {
91
+ if (error instanceof InvalidRequestError) {
92
+ res.statusCode = 400;
93
+ res.setHeader("content-type", "text/plain; charset=utf-8");
94
+ res.end("Bad Request");
95
+ return;
96
+ }
97
+ if (error instanceof BodyLimitError) {
98
+ res.statusCode = 413;
99
+ res.setHeader("content-type", "text/plain; charset=utf-8");
100
+ res.end("Payload Too Large");
101
+ res.once("finish", () => req.destroy());
102
+ return;
103
+ }
56
104
  next(error as Error);
57
105
  });
58
106
  });
@@ -62,9 +110,16 @@ async function handleRequest(
62
110
  getApp: () => Promise<DevApp>,
63
111
  req: IncomingMessage,
64
112
  res: ServerResponse,
113
+ options: { trustProxy?: boolean; bodyLimitBytes?: number },
65
114
  ): Promise<void> {
66
115
  const app = await getApp();
67
- const response = await app.handler(await toWebRequest(req));
116
+ const url = resolveRequestUrl(req, { trustProxy: options.trustProxy === true });
117
+ const isRpc = url.pathname.startsWith("/_moons/fn/");
118
+ const request = await toWebRequest(req, {
119
+ trustProxy: options.trustProxy === true,
120
+ ...(isRpc ? { maxBodyBytes: options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES } : {}),
121
+ });
122
+ const response = await app.handler(request);
68
123
  await sendNodeResponse(res, response);
69
124
  }
70
125
 
@@ -1,10 +1,17 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer, type Server } from "node:http";
3
3
  import { extname, join, resolve, sep } from "node:path";
4
- import type { PrerenderStorage } from "@moonsjs/core";
4
+ import { DEFAULT_BODY_LIMIT_BYTES, MoonsError, type PrerenderStorage } from "@moonsjs/core";
5
5
  import { revalidatePrerendered, shouldRevalidate } from "./isr";
6
6
  import { contentTypeForPathname } from "./storage";
7
- import { sendNodeResponse, toWebRequest } from "./node";
7
+ import {
8
+ BodyLimitError,
9
+ DEFAULT_SECURITY_HEADERS,
10
+ InvalidRequestError,
11
+ resolveRequestUrl,
12
+ sendNodeResponse,
13
+ toWebRequest,
14
+ } from "./node";
8
15
  import type { PluginRuntime } from "./plugins";
9
16
  import { installGracefulShutdown } from "./shutdown";
10
17
 
@@ -52,6 +59,10 @@ export interface NodeServerOptions {
52
59
  * memakai storage ini; default: file sistem lokal.
53
60
  */
54
61
  storage?: PrerenderStorage;
62
+ /** Addendum J.1: percayai `x-forwarded-proto`/`x-forwarded-host` (default false). */
63
+ trustProxy?: boolean;
64
+ /** Addendum J.2: batas body RPC di transport (default `DEFAULT_BODY_LIMIT_BYTES`). */
65
+ bodyLimitBytes?: number;
55
66
  }
56
67
 
57
68
  /**
@@ -67,9 +78,21 @@ export function startNodeServer(
67
78
 
68
79
  const server = createServer((req, res) => {
69
80
  void (async () => {
81
+ let url: URL;
70
82
  try {
71
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
83
+ // Addendum J.1: URL/Origin mengikuti `x-forwarded-*` bila trustProxy aktif;
84
+ // request malformed → 400 (bukan 500 + stack).
85
+ url = resolveRequestUrl(req, { trustProxy: options.trustProxy === true });
86
+ } catch (error) {
87
+ if (error instanceof InvalidRequestError) {
88
+ console.warn(`[moons] ${error.message} — 400`);
89
+ respondBadRequest(res);
90
+ return;
91
+ }
92
+ throw error;
93
+ }
72
94
 
95
+ try {
73
96
  if (req.method === "GET" || req.method === "HEAD") {
74
97
  // Addendum I: storage bersama (bila dikonfigurasi) menggantikan file lokal.
75
98
  if (options.storage !== undefined) {
@@ -85,11 +108,7 @@ export function startNodeServer(
85
108
  });
86
109
  res.statusCode = 200;
87
110
  res.setHeader("content-type", contentTypeForPathname(url.pathname));
88
- for (const [name, value] of Object.entries(options.headers ?? {})) {
89
- if (!res.hasHeader(name)) {
90
- res.setHeader(name, value);
91
- }
92
- }
111
+ applyStaticHeaders(res, url.pathname, options.headers);
93
112
  res.end(req.method === "HEAD" ? undefined : Buffer.from(entry.body));
94
113
  return;
95
114
  }
@@ -107,20 +126,44 @@ export function startNodeServer(
107
126
  const body = readFileSync(staticFile);
108
127
  res.statusCode = 200;
109
128
  res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
110
- for (const [name, value] of Object.entries(options.headers ?? {})) {
111
- if (!res.hasHeader(name)) {
112
- res.setHeader(name, value);
113
- }
114
- }
129
+ applyStaticHeaders(res, url.pathname, options.headers);
115
130
  res.end(req.method === "HEAD" ? undefined : body);
116
131
  return;
117
132
  }
118
133
  }
119
134
  }
120
135
 
121
- const response = await handler(await toWebRequest(req));
136
+ // Addendum J.2: batas body RPC ditegakkan di transport — lebih awal dari
137
+ // pipeline, tanpa membaca penuh; `content-length` diperiksa lebih dulu.
138
+ const isRpc = url.pathname.startsWith("/_moons/fn/");
139
+ const rpcLimit = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
140
+ if (isRpc) {
141
+ const contentLength = Number(req.headers["content-length"] ?? "0");
142
+ if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
143
+ res.statusCode = 413;
144
+ res.setHeader("content-type", "text/plain; charset=utf-8");
145
+ res.end(rpcBodyTooLargeText(rpcLimit, contentLength));
146
+ res.once("finish", () => req.destroy());
147
+ return;
148
+ }
149
+ }
150
+
151
+ const request = await toWebRequest(req, {
152
+ trustProxy: options.trustProxy === true,
153
+ ...(isRpc ? { maxBodyBytes: rpcLimit } : {}),
154
+ });
155
+ const response = await handler(request);
122
156
  await sendNodeResponse(res, response);
123
157
  } catch (error) {
158
+ if (error instanceof BodyLimitError) {
159
+ const isRpc = url.pathname.startsWith("/_moons/fn/");
160
+ res.statusCode = 413;
161
+ res.setHeader("content-type", "text/plain; charset=utf-8");
162
+ res.end(isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large");
163
+ // Sisa body tidak perlu dibaca — putuskan setelah respons terkirim.
164
+ res.once("finish", () => req.destroy());
165
+ return;
166
+ }
124
167
  console.error("[moons] server error:", error);
125
168
  res.statusCode = 500;
126
169
  res.setHeader("content-type", "text/plain; charset=utf-8");
@@ -159,6 +202,54 @@ export function startNodeServer(
159
202
  return server;
160
203
  }
161
204
 
205
+ /**
206
+ * Addendum J.7: header konfigurasi (menang), default aman, dan
207
+ * `Cache-Control: immutable` untuk aset ber-hash di `/assets/*`.
208
+ */
209
+ function applyStaticHeaders(
210
+ res: import("node:http").ServerResponse,
211
+ pathname: string,
212
+ configured: Record<string, string> | undefined,
213
+ ): void {
214
+ for (const [name, value] of Object.entries(configured ?? {})) {
215
+ if (!res.hasHeader(name)) {
216
+ res.setHeader(name, value);
217
+ }
218
+ }
219
+ for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
220
+ if (!res.hasHeader(name)) {
221
+ res.setHeader(name, value);
222
+ }
223
+ }
224
+ if (pathname.startsWith("/assets/") && !res.hasHeader("cache-control")) {
225
+ res.setHeader("cache-control", "public, max-age=31536000, immutable");
226
+ }
227
+ }
228
+
229
+ function respondBadRequest(res: import("node:http").ServerResponse): void {
230
+ res.statusCode = 400;
231
+ res.setHeader("content-type", "text/plain; charset=utf-8");
232
+ res.end("Bad Request");
233
+ }
234
+
235
+ /** 413 transport dengan format §15 yang sama seperti `rpc.ts` (Addendum J.2). */
236
+ function rpcBodyTooLargeText(limit: number, size: number | null): string {
237
+ const megabytes = (bytes: number): string => (bytes / 1_000_000).toFixed(1);
238
+ return new MoonsError({
239
+ code: "E_RPC_BODY_TOO_LARGE",
240
+ message:
241
+ size === null
242
+ ? `body melebihi limit ${megabytes(limit)} MB`
243
+ : `body ${megabytes(size)} MB melebihi limit ${megabytes(limit)} MB`,
244
+ details:
245
+ "Body melebihi `security.bodyLimitBytes`; pembacaan dihentikan di transport sebelum penuh.",
246
+ suggestions: [
247
+ "Kurangi ukuran payload, atau",
248
+ "Naikkan security.bodyLimitBytes jika memang perlu",
249
+ ],
250
+ }).format();
251
+ }
252
+
162
253
  /** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
163
254
  export function resolveStaticFile(clientDir: string, pathname: string): string | null {
164
255
  const target = staticFileTarget(clientDir, pathname);
@@ -1,17 +1,88 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { CLIENT_IP_HEADER } from "@moonsjs/core";
2
5
 
3
- const MAX_BODY_BYTES = 32 * 1024 * 1024; // batas defensif transport; limit RPC §7.3 tetap berlaku
6
+ /** Batas transport default (Addendum J.2); RPC punya batas lebih kecil dari config. */
7
+ export const MAX_TRANSPORT_BODY_BYTES = 32 * 1024 * 1024;
4
8
 
5
- /** Konversi request Node → Web `Request` (body diteruskan untuk non-GET/HEAD, §7.3). */
6
- export async function toWebRequest(req: IncomingMessage): Promise<Request> {
7
- const host = req.headers.host ?? "localhost";
8
- const url = `http://${host}${req.url ?? "/"}`;
9
+ /** Request tidak valid (Host/URL malformed) → dipetakan ke 400 oleh server. */
10
+ export class InvalidRequestError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "InvalidRequestError";
14
+ }
15
+ }
16
+
17
+ /** Body melebihi batas → dipetakan ke 413; pembacaan dihentikan saat melewati. */
18
+ export class BodyLimitError extends Error {
19
+ readonly limit: number;
20
+ constructor(limit: number) {
21
+ super(`request body melebihi batas ${limit} byte`);
22
+ this.name = "BodyLimitError";
23
+ this.limit = limit;
24
+ }
25
+ }
26
+
27
+ export interface ToWebRequestOptions {
28
+ /** Addendum J.1: percayai `x-forwarded-proto`/`x-forwarded-host` (default false). */
29
+ trustProxy?: boolean;
30
+ /** Batas pembacaan body (byte). Default: `MAX_TRANSPORT_BODY_BYTES`. */
31
+ maxBodyBytes?: number;
32
+ }
33
+
34
+ /**
35
+ * Rekonstruksi URL request (Addendum J.1). Tanpa `trustProxy`, origin/host
36
+ * murni dari header `Host` (perilaku lama, kebal spoofing `x-forwarded-*`).
37
+ * Malformed → `InvalidRequestError` (server memetakan ke 400).
38
+ */
39
+ export function resolveRequestUrl(
40
+ req: IncomingMessage,
41
+ options: ToWebRequestOptions = {},
42
+ ): URL {
43
+ const hostHeader = req.headers.host ?? "localhost";
44
+ const trustProxy = options.trustProxy === true;
45
+ const proto = trustProxy ? forwardedProtocol(req.headers["x-forwarded-proto"]) : null;
46
+ const forwardedHostValue = trustProxy ? forwardedHost(req.headers["x-forwarded-host"]) : null;
47
+ const host = forwardedHostValue ?? hostHeader;
48
+ try {
49
+ return new URL(`${proto ?? "http"}://${host}${req.url ?? "/"}`);
50
+ } catch {
51
+ throw new InvalidRequestError(`header Host tidak valid: ${JSON.stringify(hostHeader)}`);
52
+ }
53
+ }
54
+
55
+ /** IP klien tepercaya (Addendum J.1): XFF (hanya bila trustProxy) → socket. */
56
+ export function resolveClientIp(req: IncomingMessage, trustProxy: boolean): string | null {
57
+ if (trustProxy) {
58
+ const forwarded = firstHeaderValue(req.headers["x-forwarded-for"]);
59
+ if (forwarded !== null && isPlausibleIp(forwarded)) {
60
+ return normalizeIp(forwarded);
61
+ }
62
+ }
63
+ const remote = req.socket?.remoteAddress ?? null;
64
+ return remote === null ? null : normalizeIp(remote);
65
+ }
66
+
67
+ /** `::ffff:127.0.0.1` → `127.0.0.1`; buang zone id (`%eth0`). */
68
+ function normalizeIp(value: string): string {
69
+ const withoutZone = stripZoneId(value);
70
+ return withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
71
+ }
72
+
73
+ /** Konversi request Node → Web `Request` (body diteruskan untuk non-GET/HEAD, §7.3 + J.1/J.2). */
74
+ export async function toWebRequest(
75
+ req: IncomingMessage,
76
+ options: ToWebRequestOptions = {},
77
+ ): Promise<Request> {
78
+ const url = resolveRequestUrl(req, options);
9
79
  const method = req.method ?? "GET";
10
80
 
11
81
  const headers = new Headers();
12
82
  for (const [key, value] of Object.entries(req.headers)) {
13
- if (value === undefined || key === "content-length") {
83
+ if (value === undefined || key === "content-length" || key === CLIENT_IP_HEADER) {
14
84
  // content-length tidak disalin; fetch menghitung ulang dari body.
85
+ // x-moons-client-ip selalu ditimpa nilai tepercaya di bawah.
15
86
  continue;
16
87
  }
17
88
  if (Array.isArray(value)) {
@@ -23,34 +94,69 @@ export async function toWebRequest(req: IncomingMessage): Promise<Request> {
23
94
  }
24
95
  }
25
96
 
97
+ const clientIp = resolveClientIp(req, options.trustProxy === true);
98
+ if (clientIp !== null) {
99
+ headers.set(CLIENT_IP_HEADER, clientIp);
100
+ }
101
+
26
102
  if (method === "GET" || method === "HEAD") {
27
103
  return new Request(url, { method, headers });
28
104
  }
29
105
 
30
- const body = await readBody(req);
31
- return new Request(url, { method, headers, body: new Uint8Array(body) });
106
+ const body = await readBody(req, options.maxBodyBytes ?? MAX_TRANSPORT_BODY_BYTES);
107
+ // View zero-copy (bukan salinan) — Buffer selalu di atas ArrayBuffer biasa.
108
+ const view = new Uint8Array(body.buffer as ArrayBuffer, body.byteOffset, body.byteLength);
109
+ return new Request(url, { method, headers, body: view });
32
110
  }
33
111
 
34
- function readBody(req: IncomingMessage): Promise<Buffer> {
112
+ /**
113
+ * Baca body dengan batas tegas (Addendum J.2): begitu melewati `maxBytes`,
114
+ * pembacaan dihentikan (pause) dan `BodyLimitError` dilempar — tidak pernah
115
+ * seluruh body besar masuk memori, dan hanya satu salinan buffer dibuat.
116
+ */
117
+ export function readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
35
118
  return new Promise((resolvePromise, reject) => {
36
119
  const chunks: Buffer[] = [];
37
120
  let size = 0;
121
+ let settled = false;
122
+
123
+ const fail = (error: Error): void => {
124
+ if (settled) {
125
+ return;
126
+ }
127
+ settled = true;
128
+ req.pause();
129
+ reject(error);
130
+ };
38
131
 
39
132
  req.on("data", (chunk: Buffer) => {
133
+ if (settled) {
134
+ return;
135
+ }
40
136
  size += chunk.length;
41
- if (size > MAX_BODY_BYTES) {
42
- req.destroy();
43
- reject(new Error("request body melebihi batas transport"));
137
+ if (size > maxBytes) {
138
+ fail(new BodyLimitError(maxBytes));
44
139
  return;
45
140
  }
46
141
  chunks.push(chunk);
47
142
  });
48
- req.on("end", () => resolvePromise(Buffer.concat(chunks)));
49
- req.on("error", (error) => reject(error));
143
+ req.on("end", () => {
144
+ if (!settled) {
145
+ settled = true;
146
+ resolvePromise(Buffer.concat(chunks));
147
+ }
148
+ });
149
+ req.on("error", (error) => fail(error));
50
150
  });
51
151
  }
52
152
 
53
- /** Tulis Web `Response` ke `ServerResponse` Node. */
153
+ /** Addendum J.7: default aman runtime; `config.headers` tetap menang (cek hasHeader). */
154
+ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
155
+ "x-content-type-options": "nosniff",
156
+ "referrer-policy": "strict-origin-when-cross-origin",
157
+ };
158
+
159
+ /** Tulis Web `Response` ke `ServerResponse` Node (streaming bila ada body). */
54
160
  export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
55
161
  res.statusCode = response.status;
56
162
  response.headers.forEach((value, key) => {
@@ -73,6 +179,65 @@ export async function sendNodeResponse(res: ServerResponse, response: Response):
73
179
  res.setHeader("set-cookie", single);
74
180
  }
75
181
  }
76
- const body = Buffer.from(await response.arrayBuffer());
77
- res.end(body);
182
+
183
+ for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
184
+ if (!res.hasHeader(name)) {
185
+ res.setHeader(name, value);
186
+ }
187
+ }
188
+
189
+ if (response.body === null || res.req?.method === "HEAD") {
190
+ res.end();
191
+ return;
192
+ }
193
+
194
+ // Addendum J.2: stream langsung — tidak ada `arrayBuffer()` penuh di memori.
195
+ await pipeline(
196
+ Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream),
197
+ res,
198
+ );
199
+ }
200
+
201
+ function firstHeaderValue(value: string | string[] | undefined): string | null {
202
+ if (value === undefined) {
203
+ return null;
204
+ }
205
+ const raw = Array.isArray(value) ? value[0] : value;
206
+ if (raw === undefined) {
207
+ return null;
208
+ }
209
+ const first = raw.split(",")[0]?.trim() ?? "";
210
+ return first.length === 0 ? null : first;
211
+ }
212
+
213
+ function forwardedProtocol(value: string | string[] | undefined): "http" | "https" | null {
214
+ const first = firstHeaderValue(value)?.toLowerCase() ?? null;
215
+ if (first === "https") {
216
+ return "https";
217
+ }
218
+ if (first === "http") {
219
+ return "http";
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function forwardedHost(value: string | string[] | undefined): string | null {
225
+ const first = firstHeaderValue(value);
226
+ if (first === null || first.length === 0 || first.length > 255) {
227
+ return null;
228
+ }
229
+ // hostname[:port] — tolak karakter di luar himpunan aman (spasi, CRLF, /, @, ?).
230
+ if (!/^[A-Za-z0-9.\-[\]:]+$/.test(first)) {
231
+ return null;
232
+ }
233
+ return first;
234
+ }
235
+
236
+ function isPlausibleIp(value: string): boolean {
237
+ return value.length <= 64 && /^[0-9a-fA-F:.]+$/.test(value);
238
+ }
239
+
240
+ function stripZoneId(value: string): string {
241
+ const index = value.indexOf("%");
242
+ return index === -1 ? value : value.slice(0, index);
78
243
  }