@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/dist/devtools.d.ts +30 -0
- package/dist/devtools.d.ts.map +1 -0
- package/dist/{index-tysyk2qf.js → index-qr2w9b24.js} +88 -5
- package/dist/index-qr2w9b24.js.map +11 -0
- package/dist/{index-qz9sc74p.js → index-vafk4b9b.js} +247 -20
- package/dist/index-vafk4b9b.js.map +15 -0
- package/dist/index.js +30 -6
- package/dist/index.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/server/dev.d.ts +6 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/index.js +83 -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 -0
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +1 -1
- package/package.json +1 -1
- package/src/devtools.ts +127 -0
- package/src/plugin.ts +37 -2
- package/src/server/dev.ts +59 -4
- package/src/server/node-server.ts +105 -14
- package/src/server/node.ts +182 -17
- package/src/transform.ts +114 -4
- package/dist/index-qz9sc74p.js.map +0 -14
- package/dist/index-tysyk2qf.js.map +0 -11
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 {
|
|
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: {
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
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
|
}
|