@moonsjs/vite 0.3.0 → 0.4.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.
@@ -1,19 +1,26 @@
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
+ acceptsGzipHeader,
17
+ applyStaticHeaders,
18
+ gzipCached,
19
+ resolveStaticFile,
20
+ rpcBodyTooLargeText,
21
+ scheduleRevalidation,
22
+ } from "./transport";
23
+ import {
17
24
  resolveClientIp,
18
25
  resolveRequestUrl,
19
26
  sendNodeResponse,
@@ -23,27 +30,9 @@ import { createLogger, type Logger } from "./logger";
23
30
  import type { PluginRuntime } from "./plugins";
24
31
  import { installGracefulShutdown } from "./shutdown";
25
32
 
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
- };
33
+ // Kontrak lama tetap diekspor dari sini (index.ts & test lama).
34
+ export { resolveStaticFile, staticFileTarget } from "./transport";
35
+ export type { PrerenderManifestInfo } from "./transport";
47
36
 
48
37
  export interface NodeServerOptions {
49
38
  port?: number;
@@ -156,7 +145,11 @@ export function startNodeServer(
156
145
  });
157
146
  res.statusCode = 200;
158
147
  res.setHeader("content-type", contentTypeForPathname(url.pathname));
159
- applyStaticHeaders(res, url.pathname, options.headers);
148
+ applyStaticHeaders(
149
+ { has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
150
+ url.pathname,
151
+ options.headers,
152
+ );
160
153
  res.end(req.method === "HEAD" ? undefined : Buffer.from(entry.body));
161
154
  return;
162
155
  }
@@ -182,7 +175,11 @@ export function startNodeServer(
182
175
  const stat = statSync(staticFile);
183
176
  res.statusCode = 200;
184
177
  res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
185
- applyStaticHeaders(res, url.pathname, options.headers);
178
+ applyStaticHeaders(
179
+ { has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
180
+ url.pathname,
181
+ options.headers,
182
+ );
186
183
 
187
184
  // Addendum K.5: ETag + 304.
188
185
  const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
@@ -199,11 +196,15 @@ export function startNodeServer(
199
196
 
200
197
  // Addendum K.5: gzip untuk aset teks (cache per mtime).
201
198
  let payload: Buffer = body;
199
+ const encodings = req.headers["accept-encoding"];
200
+ const acceptsGzip = acceptsGzipHeader(
201
+ Array.isArray(encodings) ? (encodings[0] ?? null) : (encodings ?? null),
202
+ );
202
203
  if (
203
204
  req.method === "GET" &&
204
205
  COMPRESSIBLE_FILE.test(staticFile) &&
205
206
  body.byteLength >= GZIP_MIN_BYTES &&
206
- acceptsGzip(req)
207
+ acceptsGzip
207
208
  ) {
208
209
  payload = gzipCached(staticFile, stat.mtimeMs, body);
209
210
  res.setHeader("content-encoding", "gzip");
@@ -284,64 +285,6 @@ export function startNodeServer(
284
285
  return server;
285
286
  }
286
287
 
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
288
  function appendVary(res: import("node:http").ServerResponse, value: string): void {
346
289
  const existing = res.getHeader("vary");
347
290
  if (typeof existing === "string" && existing.length > 0) {
@@ -358,174 +301,3 @@ function respondBadRequest(res: import("node:http").ServerResponse): void {
358
301
  res.setHeader("content-type", "text/plain; charset=utf-8");
359
302
  res.end("Bad Request");
360
303
  }
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>;
431
- }
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
+ }