@moonsjs/vite 0.1.12 → 0.1.14
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-tysyk2qf.js → index-snbxks72.js} +249 -5
- package/dist/index-snbxks72.js.map +11 -0
- package/dist/{index-h66m8653.js → index-vafk4b9b.js} +157 -20
- package/dist/index-vafk4b9b.js.map +15 -0
- package/dist/index.js +13 -5
- package/dist/index.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/server/dev.d.ts +3 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/index.js +135 -20
- package/dist/server/index.js.map +3 -3
- package/dist/server/node-server.d.ts +5 -1
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts +36 -3
- package/dist/server/node.d.ts.map +1 -1
- package/dist/transform.d.ts +4 -1
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +1 -1
- package/package.json +1 -1
- package/src/plugin.ts +10 -1
- package/src/server/dev.ts +33 -3
- package/src/server/node-server.ts +178 -14
- package/src/server/node.ts +182 -17
- package/src/transform.ts +313 -4
- package/dist/index-h66m8653.js.map +0 -15
- package/dist/index-tysyk2qf.js.map +0 -11
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { gzipSync } from "node:zlib";
|
|
2
3
|
import { createServer, type Server } from "node:http";
|
|
3
4
|
import { extname, join, resolve, sep } from "node:path";
|
|
4
|
-
import type
|
|
5
|
+
import { DEFAULT_BODY_LIMIT_BYTES, MoonsError, type PrerenderStorage } from "@moonsjs/core";
|
|
5
6
|
import { revalidatePrerendered, shouldRevalidate } from "./isr";
|
|
6
7
|
import { contentTypeForPathname } from "./storage";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
BodyLimitError,
|
|
10
|
+
DEFAULT_SECURITY_HEADERS,
|
|
11
|
+
InvalidRequestError,
|
|
12
|
+
resolveRequestUrl,
|
|
13
|
+
sendNodeResponse,
|
|
14
|
+
toWebRequest,
|
|
15
|
+
} from "./node";
|
|
8
16
|
import type { PluginRuntime } from "./plugins";
|
|
9
17
|
import { installGracefulShutdown } from "./shutdown";
|
|
10
18
|
|
|
@@ -52,6 +60,10 @@ export interface NodeServerOptions {
|
|
|
52
60
|
* memakai storage ini; default: file sistem lokal.
|
|
53
61
|
*/
|
|
54
62
|
storage?: PrerenderStorage;
|
|
63
|
+
/** Addendum J.1: percayai `x-forwarded-proto`/`x-forwarded-host` (default false). */
|
|
64
|
+
trustProxy?: boolean;
|
|
65
|
+
/** Addendum J.2: batas body RPC di transport (default `DEFAULT_BODY_LIMIT_BYTES`). */
|
|
66
|
+
bodyLimitBytes?: number;
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
/**
|
|
@@ -67,9 +79,21 @@ export function startNodeServer(
|
|
|
67
79
|
|
|
68
80
|
const server = createServer((req, res) => {
|
|
69
81
|
void (async () => {
|
|
82
|
+
let url: URL;
|
|
70
83
|
try {
|
|
71
|
-
|
|
84
|
+
// Addendum J.1: URL/Origin mengikuti `x-forwarded-*` bila trustProxy aktif;
|
|
85
|
+
// request malformed → 400 (bukan 500 + stack).
|
|
86
|
+
url = resolveRequestUrl(req, { trustProxy: options.trustProxy === true });
|
|
87
|
+
} catch (error) {
|
|
88
|
+
if (error instanceof InvalidRequestError) {
|
|
89
|
+
console.warn(`[moons] ${error.message} — 400`);
|
|
90
|
+
respondBadRequest(res);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
72
95
|
|
|
96
|
+
try {
|
|
73
97
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
74
98
|
// Addendum I: storage bersama (bila dikonfigurasi) menggantikan file lokal.
|
|
75
99
|
if (options.storage !== undefined) {
|
|
@@ -85,11 +109,7 @@ export function startNodeServer(
|
|
|
85
109
|
});
|
|
86
110
|
res.statusCode = 200;
|
|
87
111
|
res.setHeader("content-type", contentTypeForPathname(url.pathname));
|
|
88
|
-
|
|
89
|
-
if (!res.hasHeader(name)) {
|
|
90
|
-
res.setHeader(name, value);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
112
|
+
applyStaticHeaders(res, url.pathname, options.headers);
|
|
93
113
|
res.end(req.method === "HEAD" ? undefined : Buffer.from(entry.body));
|
|
94
114
|
return;
|
|
95
115
|
}
|
|
@@ -105,22 +125,73 @@ export function startNodeServer(
|
|
|
105
125
|
clientDir,
|
|
106
126
|
});
|
|
107
127
|
const body = readFileSync(staticFile);
|
|
128
|
+
const stat = statSync(staticFile);
|
|
108
129
|
res.statusCode = 200;
|
|
109
130
|
res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
131
|
+
applyStaticHeaders(res, url.pathname, options.headers);
|
|
132
|
+
|
|
133
|
+
// Addendum K.5: ETag + 304.
|
|
134
|
+
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
|
|
135
|
+
res.setHeader("etag", etag);
|
|
136
|
+
const ifNoneMatch = req.headers["if-none-match"];
|
|
137
|
+
if (
|
|
138
|
+
typeof ifNoneMatch === "string" &&
|
|
139
|
+
ifNoneMatch.split(",").some((value) => value.trim() === etag)
|
|
140
|
+
) {
|
|
141
|
+
res.statusCode = 304;
|
|
142
|
+
res.end();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Addendum K.5: gzip untuk aset teks (cache per mtime).
|
|
147
|
+
let payload: Buffer = body;
|
|
148
|
+
if (
|
|
149
|
+
req.method === "GET" &&
|
|
150
|
+
COMPRESSIBLE_FILE.test(staticFile) &&
|
|
151
|
+
body.byteLength >= GZIP_MIN_BYTES &&
|
|
152
|
+
acceptsGzip(req)
|
|
153
|
+
) {
|
|
154
|
+
payload = gzipCached(staticFile, stat.mtimeMs, body);
|
|
155
|
+
res.setHeader("content-encoding", "gzip");
|
|
156
|
+
appendVary(res, "accept-encoding");
|
|
114
157
|
}
|
|
115
|
-
res.end(req.method === "HEAD" ? undefined :
|
|
158
|
+
res.end(req.method === "HEAD" ? undefined : payload);
|
|
116
159
|
return;
|
|
117
160
|
}
|
|
118
161
|
}
|
|
119
162
|
}
|
|
120
163
|
|
|
121
|
-
|
|
164
|
+
// Addendum J.2: batas body RPC ditegakkan di transport — lebih awal dari
|
|
165
|
+
// pipeline, tanpa membaca penuh; `content-length` diperiksa lebih dulu.
|
|
166
|
+
const isRpc = url.pathname.startsWith("/_moons/fn/");
|
|
167
|
+
const rpcLimit = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
|
|
168
|
+
if (isRpc) {
|
|
169
|
+
const contentLength = Number(req.headers["content-length"] ?? "0");
|
|
170
|
+
if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
|
|
171
|
+
res.statusCode = 413;
|
|
172
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
173
|
+
res.end(rpcBodyTooLargeText(rpcLimit, contentLength));
|
|
174
|
+
res.once("finish", () => req.destroy());
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const request = await toWebRequest(req, {
|
|
180
|
+
trustProxy: options.trustProxy === true,
|
|
181
|
+
...(isRpc ? { maxBodyBytes: rpcLimit } : {}),
|
|
182
|
+
});
|
|
183
|
+
const response = await handler(request);
|
|
122
184
|
await sendNodeResponse(res, response);
|
|
123
185
|
} catch (error) {
|
|
186
|
+
if (error instanceof BodyLimitError) {
|
|
187
|
+
const isRpc = url.pathname.startsWith("/_moons/fn/");
|
|
188
|
+
res.statusCode = 413;
|
|
189
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
190
|
+
res.end(isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large");
|
|
191
|
+
// Sisa body tidak perlu dibaca — putuskan setelah respons terkirim.
|
|
192
|
+
res.once("finish", () => req.destroy());
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
124
195
|
console.error("[moons] server error:", error);
|
|
125
196
|
res.statusCode = 500;
|
|
126
197
|
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
@@ -159,6 +230,99 @@ export function startNodeServer(
|
|
|
159
230
|
return server;
|
|
160
231
|
}
|
|
161
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Addendum J.7: header konfigurasi (menang), default aman, dan
|
|
235
|
+
* `Cache-Control: immutable` untuk aset ber-hash di `/assets/*`.
|
|
236
|
+
*/
|
|
237
|
+
function applyStaticHeaders(
|
|
238
|
+
res: import("node:http").ServerResponse,
|
|
239
|
+
pathname: string,
|
|
240
|
+
configured: Record<string, string> | undefined,
|
|
241
|
+
): void {
|
|
242
|
+
for (const [name, value] of Object.entries(configured ?? {})) {
|
|
243
|
+
if (!res.hasHeader(name)) {
|
|
244
|
+
res.setHeader(name, value);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
|
|
248
|
+
if (!res.hasHeader(name)) {
|
|
249
|
+
res.setHeader(name, value);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (pathname.startsWith("/assets/") && !res.hasHeader("cache-control")) {
|
|
253
|
+
res.setHeader("cache-control", "public, max-age=31536000, immutable");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const COMPRESSIBLE_FILE = /\.(js|mjs|css|html|json|svg|txt|map|xml)$/i;
|
|
258
|
+
const GZIP_MIN_BYTES = 1024;
|
|
259
|
+
const GZIP_CACHE_MAX = 64;
|
|
260
|
+
const gzipCache = new Map<string, { mtimeMs: number; gzip: Buffer }>();
|
|
261
|
+
|
|
262
|
+
function acceptsGzip(req: import("node:http").IncomingMessage): boolean {
|
|
263
|
+
const header = req.headers["accept-encoding"];
|
|
264
|
+
if (typeof header !== "string") {
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
return header
|
|
268
|
+
.toLowerCase()
|
|
269
|
+
.split(",")
|
|
270
|
+
.map((part) => part.trim())
|
|
271
|
+
.some((part) => part === "gzip" || part === "*" || part.startsWith("gzip;"));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Varian gzip ter-cache per file+mtime (bounded FIFO) — Addendum K.5. */
|
|
275
|
+
function gzipCached(file: string, mtimeMs: number, body: Buffer): Buffer {
|
|
276
|
+
const cached = gzipCache.get(file);
|
|
277
|
+
if (cached !== undefined && cached.mtimeMs === mtimeMs) {
|
|
278
|
+
return cached.gzip;
|
|
279
|
+
}
|
|
280
|
+
const gzip = gzipSync(body);
|
|
281
|
+
if (gzipCache.size >= GZIP_CACHE_MAX) {
|
|
282
|
+
const oldest = gzipCache.keys().next().value;
|
|
283
|
+
if (oldest !== undefined) {
|
|
284
|
+
gzipCache.delete(oldest);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
gzipCache.set(file, { mtimeMs, gzip });
|
|
288
|
+
return gzip;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function appendVary(res: import("node:http").ServerResponse, value: string): void {
|
|
292
|
+
const existing = res.getHeader("vary");
|
|
293
|
+
if (typeof existing === "string" && existing.length > 0) {
|
|
294
|
+
if (!existing.toLowerCase().includes(value)) {
|
|
295
|
+
res.setHeader("vary", `${existing}, ${value}`);
|
|
296
|
+
}
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
res.setHeader("vary", value);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function respondBadRequest(res: import("node:http").ServerResponse): void {
|
|
303
|
+
res.statusCode = 400;
|
|
304
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
305
|
+
res.end("Bad Request");
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** 413 transport dengan format §15 yang sama seperti `rpc.ts` (Addendum J.2). */
|
|
309
|
+
function rpcBodyTooLargeText(limit: number, size: number | null): string {
|
|
310
|
+
const megabytes = (bytes: number): string => (bytes / 1_000_000).toFixed(1);
|
|
311
|
+
return new MoonsError({
|
|
312
|
+
code: "E_RPC_BODY_TOO_LARGE",
|
|
313
|
+
message:
|
|
314
|
+
size === null
|
|
315
|
+
? `body melebihi limit ${megabytes(limit)} MB`
|
|
316
|
+
: `body ${megabytes(size)} MB melebihi limit ${megabytes(limit)} MB`,
|
|
317
|
+
details:
|
|
318
|
+
"Body melebihi `security.bodyLimitBytes`; pembacaan dihentikan di transport sebelum penuh.",
|
|
319
|
+
suggestions: [
|
|
320
|
+
"Kurangi ukuran payload, atau",
|
|
321
|
+
"Naikkan security.bodyLimitBytes jika memang perlu",
|
|
322
|
+
],
|
|
323
|
+
}).format();
|
|
324
|
+
}
|
|
325
|
+
|
|
162
326
|
/** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
|
|
163
327
|
export function resolveStaticFile(clientDir: string, pathname: string): string | null {
|
|
164
328
|
const target = staticFileTarget(clientDir, pathname);
|
package/src/server/node.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
42
|
-
|
|
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", () =>
|
|
49
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
77
|
-
|
|
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
|
}
|