@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.
@@ -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 { PrerenderStorage } from "@moonsjs/core";
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 { sendNodeResponse, toWebRequest } from "./node";
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
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
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
- for (const [name, value] of Object.entries(options.headers ?? {})) {
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
- for (const [name, value] of Object.entries(options.headers ?? {})) {
111
- if (!res.hasHeader(name)) {
112
- res.setHeader(name, value);
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 : body);
158
+ res.end(req.method === "HEAD" ? undefined : payload);
116
159
  return;
117
160
  }
118
161
  }
119
162
  }
120
163
 
121
- const response = await handler(await toWebRequest(req));
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);
@@ -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
  }