@moonsjs/vite 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,8 @@ export type { RequestHandlerOptions } from "./handler";
3
3
  export { createDevShell, createFileShell, injectShell, DEFAULT_TEMPLATE, ROOT_PLACEHOLDER } from "./shell";
4
4
  export type { Shell } from "./shell";
5
5
  export { startNodeServer, resolveStaticFile } from "./node-server";
6
+ export { startBunServer } from "./bun-server";
7
+ export { startServer } from "./server-select";
6
8
  export type { NodeServerOptions } from "./node-server";
7
9
  export { installGracefulShutdown } from "./shutdown";
8
10
  export type { ShutdownOptions } from "./shutdown";
@@ -1,19 +1,30 @@
1
- import { existsSync, readFileSync, statSync } from "node:fs";
2
- import { gzipSync } from "node:zlib";
1
+ import { readFileSync, statSync } from "node:fs";
3
2
  import { createServer, type Server } from "node:http";
4
- import { extname, join, resolve, sep } from "node:path";
3
+ import { extname, join, resolve } from "node:path";
5
4
  import {
6
5
  DEFAULT_BODY_LIMIT_BYTES,
7
- MoonsError,
8
6
  trailingSlashRedirect,
9
7
  type PrerenderStorage,
10
8
  } from "@moonsjs/core";
11
- import { revalidatePrerendered, shouldRevalidate } from "./isr";
12
9
  import { contentTypeForPathname } from "./storage";
13
10
  import {
14
11
  BodyLimitError,
15
- DEFAULT_SECURITY_HEADERS,
12
+ COMPRESSIBLE_FILE,
13
+ GZIP_MIN_BYTES,
16
14
  InvalidRequestError,
15
+ MIME_TYPES,
16
+ RequestTimeoutError,
17
+ acceptsGzipHeader,
18
+ applyStaticHeaders,
19
+ gzipCached,
20
+ isAbortLike,
21
+ requestTimeoutText,
22
+ resolveStaticFile,
23
+ rpcBodyTooLargeText,
24
+ runHandlerWithTimeout,
25
+ scheduleRevalidation,
26
+ } from "./transport";
27
+ import {
17
28
  resolveClientIp,
18
29
  resolveRequestUrl,
19
30
  sendNodeResponse,
@@ -23,27 +34,9 @@ import { createLogger, type Logger } from "./logger";
23
34
  import type { PluginRuntime } from "./plugins";
24
35
  import { installGracefulShutdown } from "./shutdown";
25
36
 
26
- const MIME_TYPES: Record<string, string> = {
27
- ".js": "text/javascript; charset=utf-8",
28
- ".mjs": "text/javascript; charset=utf-8",
29
- ".css": "text/css; charset=utf-8",
30
- ".html": "text/html; charset=utf-8",
31
- ".json": "application/json; charset=utf-8",
32
- ".map": "application/json; charset=utf-8",
33
- ".ico": "image/x-icon",
34
- ".png": "image/png",
35
- ".jpg": "image/jpeg",
36
- ".jpeg": "image/jpeg",
37
- ".gif": "image/gif",
38
- ".svg": "image/svg+xml",
39
- ".webp": "image/webp",
40
- ".avif": "image/avif",
41
- ".woff": "font/woff",
42
- ".woff2": "font/woff2",
43
- ".ttf": "font/ttf",
44
- ".txt": "text/plain; charset=utf-8",
45
- ".wasm": "application/wasm",
46
- };
37
+ // Kontrak lama tetap diekspor dari sini (index.ts & test lama).
38
+ export { resolveStaticFile, staticFileTarget } from "./transport";
39
+ export type { PrerenderManifestInfo } from "./transport";
47
40
 
48
41
  export interface NodeServerOptions {
49
42
  port?: number;
@@ -75,6 +68,8 @@ export interface NodeServerOptions {
75
68
  logging?: { accessLog?: boolean; json?: boolean; redact?: string[] };
76
69
  /** Addendum M: kebijakan trailing slash (juga untuk path prerender/static). */
77
70
  trailingSlash?: "never" | "always";
71
+ /** Addendum R: batas waktu respons handler (ms); `0` menonaktifkan. */
72
+ requestTimeoutMs?: number;
78
73
  }
79
74
 
80
75
  /**
@@ -120,7 +115,7 @@ export function startNodeServer(
120
115
  } catch (error) {
121
116
  if (error instanceof InvalidRequestError) {
122
117
  console.warn(`[moons] ${error.message} — 400`);
123
- respondBadRequest(res);
118
+ respondText(res, 400, "Bad Request", options.headers);
124
119
  return;
125
120
  }
126
121
  throw error;
@@ -143,7 +138,19 @@ export function startNodeServer(
143
138
  if (req.method === "GET" || req.method === "HEAD") {
144
139
  // Addendum I: storage bersama (bila dikonfigurasi) diutamakan.
145
140
  if (options.storage !== undefined) {
146
- const entry = await options.storage.read(url.pathname);
141
+ // Addendum R (review G2): storage error = miss — aset & halaman
142
+ // non-ISR harus tetap jalan saat store bersama outage.
143
+ let entry: Awaited<ReturnType<PrerenderStorage["read"]>> | null = null;
144
+ try {
145
+ entry = await options.storage.read(url.pathname);
146
+ } catch (error) {
147
+ const message = error instanceof Error ? error.message : String(error);
148
+ if (accessLogger !== null) {
149
+ accessLogger({ msg: "isr:storage-read-failed", level: "warn", path: url.pathname, error: message });
150
+ } else {
151
+ console.warn(`[moons] isr storage read failed (${url.pathname}): ${message} — falling back to dist/client`);
152
+ }
153
+ }
147
154
  if (entry !== null) {
148
155
  scheduleRevalidation({
149
156
  pathname: url.pathname,
@@ -156,7 +163,11 @@ export function startNodeServer(
156
163
  });
157
164
  res.statusCode = 200;
158
165
  res.setHeader("content-type", contentTypeForPathname(url.pathname));
159
- applyStaticHeaders(res, url.pathname, options.headers);
166
+ applyStaticHeaders(
167
+ { has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
168
+ url.pathname,
169
+ options.headers,
170
+ );
160
171
  res.end(req.method === "HEAD" ? undefined : Buffer.from(entry.body));
161
172
  return;
162
173
  }
@@ -182,7 +193,11 @@ export function startNodeServer(
182
193
  const stat = statSync(staticFile);
183
194
  res.statusCode = 200;
184
195
  res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
185
- applyStaticHeaders(res, url.pathname, options.headers);
196
+ applyStaticHeaders(
197
+ { has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
198
+ url.pathname,
199
+ options.headers,
200
+ );
186
201
 
187
202
  // Addendum K.5: ETag + 304.
188
203
  const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
@@ -199,11 +214,15 @@ export function startNodeServer(
199
214
 
200
215
  // Addendum K.5: gzip untuk aset teks (cache per mtime).
201
216
  let payload: Buffer = body;
217
+ const encodings = req.headers["accept-encoding"];
218
+ const acceptsGzip = acceptsGzipHeader(
219
+ Array.isArray(encodings) ? (encodings[0] ?? null) : (encodings ?? null),
220
+ );
202
221
  if (
203
222
  req.method === "GET" &&
204
223
  COMPRESSIBLE_FILE.test(staticFile) &&
205
224
  body.byteLength >= GZIP_MIN_BYTES &&
206
- acceptsGzip(req)
225
+ acceptsGzip
207
226
  ) {
208
227
  payload = gzipCached(staticFile, stat.mtimeMs, body);
209
228
  res.setHeader("content-encoding", "gzip");
@@ -222,9 +241,7 @@ export function startNodeServer(
222
241
  if (isRpc) {
223
242
  const contentLength = Number(req.headers["content-length"] ?? "0");
224
243
  if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
225
- res.statusCode = 413;
226
- res.setHeader("content-type", "text/plain; charset=utf-8");
227
- res.end(rpcBodyTooLargeText(rpcLimit, contentLength));
244
+ respondText(res, 413, rpcBodyTooLargeText(rpcLimit, contentLength), options.headers);
228
245
  res.once("finish", () => req.destroy());
229
246
  return;
230
247
  }
@@ -234,22 +251,57 @@ export function startNodeServer(
234
251
  trustProxy: options.trustProxy === true,
235
252
  ...(isRpc ? { maxBodyBytes: rpcLimit } : {}),
236
253
  });
237
- const response = await handler(request);
254
+ // Addendum R: batas waktu eksplisit; 0/undefined = tanpa batas.
255
+ const response = await runHandlerWithTimeout(handler, request, options.requestTimeoutMs);
238
256
  await sendNodeResponse(res, response);
239
257
  } catch (error) {
240
258
  if (error instanceof BodyLimitError) {
241
259
  const isRpc = url.pathname.startsWith("/_moons/fn/");
242
- res.statusCode = 413;
243
- res.setHeader("content-type", "text/plain; charset=utf-8");
244
- res.end(isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large");
260
+ respondText(
261
+ res,
262
+ 413,
263
+ isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large",
264
+ options.headers,
265
+ );
245
266
  // Sisa body tidak perlu dibaca — putuskan setelah respons terkirim.
246
267
  res.once("finish", () => req.destroy());
247
268
  return;
248
269
  }
270
+ // Addendum R: timeout eksplisit — 504 + log terstruktur.
271
+ if (error instanceof RequestTimeoutError) {
272
+ if (accessLogger !== null) {
273
+ accessLogger({
274
+ msg: "request-timeout",
275
+ level: "warn",
276
+ method: req.method ?? "GET",
277
+ path: url.pathname + url.search,
278
+ limitMs: error.limit,
279
+ });
280
+ } else {
281
+ console.warn(`[moons] request timed out after ${error.limit}ms — ${req.method ?? "GET"} ${url.pathname}`);
282
+ }
283
+ respondText(res, 504, requestTimeoutText(error.limit), options.headers);
284
+ return;
285
+ }
286
+ // Review G7: abort klien/koneksi putus bukan error server (tanpa stack).
287
+ if (isAbortLike(error)) {
288
+ if (accessLogger !== null) {
289
+ accessLogger({
290
+ msg: "request-aborted",
291
+ level: "warn",
292
+ method: req.method ?? "GET",
293
+ path: url.pathname + url.search,
294
+ });
295
+ } else {
296
+ console.warn(`[moons] request aborted by the client — ${req.method ?? "GET"} ${url.pathname}`);
297
+ }
298
+ if (!res.headersSent && !res.writableEnded) {
299
+ respondText(res, 499, "Client Closed Request", options.headers);
300
+ }
301
+ return;
302
+ }
249
303
  console.error("[moons] server error:", error);
250
- res.statusCode = 500;
251
- res.setHeader("content-type", "text/plain; charset=utf-8");
252
- res.end("Internal Server Error");
304
+ respondText(res, 500, "Internal Server Error", options.headers);
253
305
  }
254
306
  })();
255
307
  });
@@ -284,64 +336,6 @@ export function startNodeServer(
284
336
  return server;
285
337
  }
286
338
 
287
- /**
288
- * Addendum J.7: header konfigurasi (menang), default aman, dan
289
- * `Cache-Control: immutable` untuk aset ber-hash di `/assets/*`.
290
- */
291
- function applyStaticHeaders(
292
- res: import("node:http").ServerResponse,
293
- pathname: string,
294
- configured: Record<string, string> | undefined,
295
- ): void {
296
- for (const [name, value] of Object.entries(configured ?? {})) {
297
- if (!res.hasHeader(name)) {
298
- res.setHeader(name, value);
299
- }
300
- }
301
- for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
302
- if (!res.hasHeader(name)) {
303
- res.setHeader(name, value);
304
- }
305
- }
306
- if (pathname.startsWith("/assets/") && !res.hasHeader("cache-control")) {
307
- res.setHeader("cache-control", "public, max-age=31536000, immutable");
308
- }
309
- }
310
-
311
- const COMPRESSIBLE_FILE = /\.(js|mjs|css|html|json|svg|txt|map|xml)$/i;
312
- const GZIP_MIN_BYTES = 1024;
313
- const GZIP_CACHE_MAX = 64;
314
- const gzipCache = new Map<string, { mtimeMs: number; gzip: Buffer }>();
315
-
316
- function acceptsGzip(req: import("node:http").IncomingMessage): boolean {
317
- const header = req.headers["accept-encoding"];
318
- if (typeof header !== "string") {
319
- return false;
320
- }
321
- return header
322
- .toLowerCase()
323
- .split(",")
324
- .map((part) => part.trim())
325
- .some((part) => part === "gzip" || part === "*" || part.startsWith("gzip;"));
326
- }
327
-
328
- /** Varian gzip ter-cache per file+mtime (bounded FIFO) — Addendum K.5. */
329
- function gzipCached(file: string, mtimeMs: number, body: Buffer): Buffer {
330
- const cached = gzipCache.get(file);
331
- if (cached !== undefined && cached.mtimeMs === mtimeMs) {
332
- return cached.gzip;
333
- }
334
- const gzip = gzipSync(body);
335
- if (gzipCache.size >= GZIP_CACHE_MAX) {
336
- const oldest = gzipCache.keys().next().value;
337
- if (oldest !== undefined) {
338
- gzipCache.delete(oldest);
339
- }
340
- }
341
- gzipCache.set(file, { mtimeMs, gzip });
342
- return gzip;
343
- }
344
-
345
339
  function appendVary(res: import("node:http").ServerResponse, value: string): void {
346
340
  const existing = res.getHeader("vary");
347
341
  if (typeof existing === "string" && existing.length > 0) {
@@ -353,179 +347,19 @@ function appendVary(res: import("node:http").ServerResponse, value: string): voi
353
347
  res.setHeader("vary", value);
354
348
  }
355
349
 
356
- function respondBadRequest(res: import("node:http").ServerResponse): void {
357
- res.statusCode = 400;
350
+ /** Respons teks transport — ikut membawa header config + default aman (review G6). */
351
+ function respondText(
352
+ res: import("node:http").ServerResponse,
353
+ status: number,
354
+ text: string,
355
+ configured: Record<string, string> | undefined,
356
+ ): void {
357
+ res.statusCode = status;
358
358
  res.setHeader("content-type", "text/plain; charset=utf-8");
359
- res.end("Bad Request");
360
- }
361
-
362
- /** 413 transport dengan format §15 yang sama seperti `rpc.ts` (Addendum J.2). */
363
- function rpcBodyTooLargeText(limit: number, size: number | null): string {
364
- const megabytes = (bytes: number): string => (bytes / 1_000_000).toFixed(1);
365
- return new MoonsError({
366
- code: "E_RPC_BODY_TOO_LARGE",
367
- message:
368
- size === null
369
- ? `body exceeds the ${megabytes(limit)} MB limit`
370
- : `body ${megabytes(size)} MB exceeds the ${megabytes(limit)} MB limit`,
371
- details:
372
- "Body exceeds `security.bodyLimitBytes`; reading stops at the transport before the body is fully buffered.",
373
- suggestions: [
374
- "Reduce the payload size, or",
375
- "Increase security.bodyLimitBytes if you really need to",
376
- ],
377
- }).format();
378
- }
379
-
380
- /** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
381
- export function resolveStaticFile(clientDir: string, pathname: string): string | null {
382
- const target = staticFileTarget(clientDir, pathname);
383
- return target !== null && isFile(target) ? target : null;
384
- }
385
-
386
- /**
387
- * Target file untuk `pathname` (tanpa cek keberadaan) — dipakai penyajian,
388
- * `createFileStorage().write`, dan test. Proteksi path traversal tetap.
389
- */
390
- export function staticFileTarget(clientDir: string, pathname: string): string | null {
391
- let decoded: string;
392
- try {
393
- decoded = decodeURIComponent(pathname);
394
- } catch {
395
- return null;
396
- }
397
-
398
- // 1) File persis (aset: js/css/png/…, atau HTML yang diminta eksplisit).
399
- const exact = safeWithin(clientDir, `.${decoded}`);
400
- if (exact !== null && isFile(exact)) {
401
- return exact;
402
- }
403
-
404
- // 2) Hasil prerender: hanya path yang tercatat di manifest yang dilayani
405
- // statis — bukan shell `index.html` biasa (yang berarti bypass SSR).
406
- const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
407
- const manifest = readPrerenderManifest(clientDir);
408
- if (manifest.paths.has(normalized)) {
409
- const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
410
- return safeWithin(clientDir, `./${target}`);
411
- }
412
-
413
- return null;
414
- }
415
-
416
- function safeWithin(clientDir: string, relative: string): string | null {
417
- const candidate = resolve(clientDir, relative);
418
- if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
419
- return null;
420
- }
421
- return candidate;
422
- }
423
-
424
- function isFile(path: string): boolean {
425
- return existsSync(path) && statSync(path).isFile();
426
- }
427
-
428
- export interface PrerenderManifestInfo {
429
- paths: Set<string>;
430
- revalidate: Map<string, number>;
359
+ applyStaticHeaders(
360
+ { has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
361
+ "/",
362
+ configured,
363
+ );
364
+ res.end(text);
431
365
  }
432
-
433
- let prerenderManifestCache:
434
- | { file: string; mtimeMs: number; info: PrerenderManifestInfo }
435
- | null = null;
436
-
437
- function readPrerenderManifest(clientDir: string): PrerenderManifestInfo {
438
- const file = join(clientDir, "_moons_prerender.json");
439
- const empty: PrerenderManifestInfo = { paths: new Set(), revalidate: new Map() };
440
- try {
441
- const mtimeMs = statSync(file).mtimeMs;
442
- if (
443
- prerenderManifestCache !== null &&
444
- prerenderManifestCache.file === file &&
445
- prerenderManifestCache.mtimeMs === mtimeMs
446
- ) {
447
- return prerenderManifestCache.info;
448
- }
449
- const json = JSON.parse(readFileSync(file, "utf8")) as {
450
- paths?: unknown;
451
- version?: unknown;
452
- entries?: unknown;
453
- };
454
- const paths = new Set<string>();
455
- const revalidate = new Map<string, number>();
456
- if (Array.isArray(json.paths)) {
457
- // Manifest v1 (D.5) — tanpa revalidate.
458
- for (const path of json.paths) {
459
- if (typeof path === "string") {
460
- paths.add(path);
461
- }
462
- }
463
- }
464
- if (Array.isArray(json.entries)) {
465
- // Manifest v2 (E.1).
466
- for (const entry of json.entries as Array<Record<string, unknown>>) {
467
- if (entry === null || typeof entry !== "object" || typeof entry["path"] !== "string") {
468
- continue;
469
- }
470
- const path = entry["path"] as string;
471
- paths.add(path);
472
- const seconds = entry["revalidate"];
473
- if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
474
- revalidate.set(path, seconds);
475
- }
476
- }
477
- }
478
- const info = { paths, revalidate };
479
- prerenderManifestCache = { file, mtimeMs, info };
480
- return info;
481
- } catch {
482
- return empty;
483
- }
484
- }
485
-
486
- /** ISR: jika file prerender kedaluwarsa, regenerasi background (SWR). */
487
- function scheduleRevalidation(input: {
488
- pathname: string;
489
- mtimeMs: number;
490
- file?: string;
491
- storage?: PrerenderStorage;
492
- handler: (request: Request) => Promise<Response>;
493
- options: NodeServerOptions;
494
- clientDir: string;
495
- logger?: Logger | null;
496
- }): void {
497
- const normalized = input.pathname.length > 1 ? input.pathname.replace(/\/+$/, "") : input.pathname;
498
- const seconds = readPrerenderManifest(input.clientDir).revalidate.get(normalized);
499
- if (seconds === undefined || seconds < 0) {
500
- return;
501
- }
502
- if (!shouldRevalidate({ mtimeMs: input.mtimeMs, revalidateSeconds: seconds })) {
503
- return;
504
- }
505
- const regeneratedAt = performance.now();
506
- const logger = input.logger ?? null;
507
- void revalidatePrerendered({
508
- pathname: normalized,
509
- ...(input.file === undefined ? {} : { file: input.file }),
510
- ...(input.storage === undefined ? {} : { storage: input.storage }),
511
- handler: input.handler,
512
- ...(input.options.canary === undefined ? {} : { canary: input.options.canary }),
513
- env: process.env,
514
- })
515
- .then(() => {
516
- logger?.({
517
- msg: "isr:regenerated",
518
- path: normalized,
519
- durationMs: Math.round(performance.now() - regeneratedAt),
520
- });
521
- })
522
- .catch((error: unknown) => {
523
- const message = error instanceof Error ? error.message : String(error);
524
- if (logger !== null) {
525
- logger({ msg: "isr:revalidate-failed", level: "warn", path: normalized, error: message });
526
- } else {
527
- console.error(`[moons] isr regeneration failed (${normalized}): ${message}`);
528
- }
529
- });
530
- }
531
-
@@ -2,34 +2,25 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { Readable } from "node:stream";
3
3
  import { pipeline } from "node:stream/promises";
4
4
  import { CLIENT_IP_HEADER } from "@moonsjs/core";
5
-
6
- /** Batas transport default (Addendum J.2); RPC punya batas lebih kecil dari config. */
7
- export const MAX_TRANSPORT_BODY_BYTES = 32 * 1024 * 1024;
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 exceeds the ${limit} byte limit`);
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
- }
5
+ import {
6
+ BodyLimitError,
7
+ DEFAULT_SECURITY_HEADERS,
8
+ InvalidRequestError,
9
+ MAX_TRANSPORT_BODY_BYTES,
10
+ forwardedHost,
11
+ forwardedProtocol,
12
+ resolveClientIpValues,
13
+ type ToWebRequestOptions,
14
+ } from "./transport";
15
+
16
+ // Kontrak lama tetap diekspor dari sini (test & pemakai lama).
17
+ export {
18
+ BodyLimitError,
19
+ DEFAULT_SECURITY_HEADERS,
20
+ InvalidRequestError,
21
+ MAX_TRANSPORT_BODY_BYTES,
22
+ } from "./transport";
23
+ export type { ToWebRequestOptions } from "./transport";
33
24
 
34
25
  /**
35
26
  * Rekonstruksi URL request (Addendum J.1). Tanpa `trustProxy`, origin/host
@@ -54,20 +45,11 @@ export function resolveRequestUrl(
54
45
 
55
46
  /** IP klien tepercaya (Addendum J.1): XFF (hanya bila trustProxy) → socket. */
56
47
  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;
48
+ return resolveClientIpValues(
49
+ req.headers["x-forwarded-for"],
50
+ req.socket?.remoteAddress ?? null,
51
+ trustProxy,
52
+ );
71
53
  }
72
54
 
73
55
  /** Konversi request Node → Web `Request` (body diteruskan untuk non-GET/HEAD, §7.3 + J.1/J.2). */
@@ -150,12 +132,6 @@ export function readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer
150
132
  });
151
133
  }
152
134
 
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
135
  /** Tulis Web `Response` ke `ServerResponse` Node (streaming bila ada body). */
160
136
  export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
161
137
  res.statusCode = response.status;
@@ -197,47 +173,3 @@ export async function sendNodeResponse(res: ServerResponse, response: Response):
197
173
  res,
198
174
  );
199
175
  }
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);
243
- }
@@ -0,0 +1,27 @@
1
+ import { startBunServer } from "./bun-server";
2
+ import { startNodeServer, type NodeServerOptions } from "./node-server";
3
+
4
+ /**
5
+ * Addendum Q (3B): pemilih transport produksi.
6
+ * Default: Bun.serve saat berjalan di Bun, node:http di Node.
7
+ * Override eksplisit: `MOONS_SERVER=bun|node`.
8
+ */
9
+ export function startServer(
10
+ handler: (request: Request) => Promise<Response>,
11
+ options: NodeServerOptions = {},
12
+ ): ReturnType<typeof startNodeServer> | ReturnType<typeof startBunServer> {
13
+ const forced = process.env["MOONS_SERVER"];
14
+ const isBun = typeof Bun !== "undefined";
15
+
16
+ if (forced === "node") {
17
+ return startNodeServer(handler, options);
18
+ }
19
+ if (forced === "bun" && !isBun) {
20
+ console.warn("[moons] MOONS_SERVER=bun requires the Bun runtime — falling back to node");
21
+ return startNodeServer(handler, options);
22
+ }
23
+ if (forced !== undefined && forced !== "bun" && forced !== "node") {
24
+ console.warn(`[moons] unknown MOONS_SERVER="${forced}" — using ${isBun ? "bun" : "node"}`);
25
+ }
26
+ return isBun ? startBunServer(handler, options) : startNodeServer(handler, options);
27
+ }
@@ -50,4 +50,14 @@ const MIME: Record<string, string> = {
50
50
  ".webp": "image/webp",
51
51
  ".woff": "font/woff",
52
52
  ".woff2": "font/woff2",
53
+ ".xml": "application/xml; charset=utf-8",
54
+ ".csv": "text/csv; charset=utf-8",
55
+ ".md": "text/markdown; charset=utf-8",
56
+ ".pdf": "application/pdf",
57
+ ".mp4": "video/mp4",
58
+ ".webm": "video/webm",
59
+ ".mp3": "audio/mpeg",
60
+ ".wav": "audio/wav",
61
+ ".zip": "application/zip",
62
+ ".gz": "application/gzip",
53
63
  };