@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.
- package/dist/{index-0agjty3z.js → index-sdm38v1p.js} +445 -63
- package/dist/index-sdm38v1p.js.map +18 -0
- package/dist/index.js +8 -5
- package/dist/index.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/server/bun-server.d.ts +18 -0
- package/dist/server/bun-server.d.ts.map +1 -0
- package/dist/server/dev.d.ts +2 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +423 -291
- package/dist/server/index.js.map +7 -7
- package/dist/server/node-server.d.ts +4 -11
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts +3 -19
- package/dist/server/node.d.ts.map +1 -1
- package/dist/server/server-select.d.ts +9 -0
- package/dist/server/server-select.d.ts.map +1 -0
- package/dist/server/transport.d.ts +98 -0
- package/dist/server/transport.d.ts.map +1 -0
- package/package.json +4 -4
- package/src/plugin.ts +5 -2
- package/src/server/bun-server.ts +443 -0
- package/src/server/dev.ts +27 -2
- package/src/server/index.ts +2 -0
- package/src/server/node-server.ts +108 -274
- package/src/server/node.ts +24 -92
- package/src/server/server-select.ts +27 -0
- package/src/server/storage.ts +10 -0
- package/src/server/transport.ts +504 -0
- package/dist/index-0agjty3z.js.map +0 -15
package/src/server/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
package/src/server/node.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
+
}
|
package/src/server/storage.ts
CHANGED
|
@@ -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
|
};
|