@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
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { startBunServer } from "./bun-server";
|
|
2
|
+
import { startNodeServer, type NodeServerOptions } from "./node-server";
|
|
3
|
+
/**
|
|
4
|
+
* Addendum Q (3B): pemilih transport produksi.
|
|
5
|
+
* Default: Bun.serve saat berjalan di Bun, node:http di Node.
|
|
6
|
+
* Override eksplisit: `MOONS_SERVER=bun|node`.
|
|
7
|
+
*/
|
|
8
|
+
export declare function startServer(handler: (request: Request) => Promise<Response>, options?: NodeServerOptions): ReturnType<typeof startNodeServer> | ReturnType<typeof startBunServer>;
|
|
9
|
+
//# sourceMappingURL=server-select.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server-select.d.ts","sourceRoot":"","sources":["../../src/server/server-select.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC9C,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAExE;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,OAAO,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,EAChD,OAAO,GAAE,iBAAsB,GAC9B,UAAU,CAAC,OAAO,eAAe,CAAC,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAexE"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type PrerenderStorage } from "@moonsjs/core";
|
|
2
|
+
import type { Logger } from "./logger";
|
|
3
|
+
/**
|
|
4
|
+
* Addendum Q (3B): bagian transport yang tidak bergantung runtime — dipakai
|
|
5
|
+
* `node-server` dan `bun-server` agar paritas bukan duplikasi.
|
|
6
|
+
*/
|
|
7
|
+
/** Batas transport default (Addendum J.2); RPC punya batas lebih kecil dari config. */
|
|
8
|
+
export declare const MAX_TRANSPORT_BODY_BYTES: number;
|
|
9
|
+
/** Request tidak valid (Host/URL malformed) → dipetakan ke 400 oleh server. */
|
|
10
|
+
export declare class InvalidRequestError extends Error {
|
|
11
|
+
constructor(message: string);
|
|
12
|
+
}
|
|
13
|
+
/** Body melebihi batas → dipetakan ke 413; pembacaan dihentikan saat melewati. */
|
|
14
|
+
export declare class BodyLimitError extends Error {
|
|
15
|
+
readonly limit: number;
|
|
16
|
+
constructor(limit: number);
|
|
17
|
+
}
|
|
18
|
+
export interface ToWebRequestOptions {
|
|
19
|
+
/** Addendum J.1: percayai `x-forwarded-proto`/`x-forwarded-host` (default false). */
|
|
20
|
+
trustProxy?: boolean;
|
|
21
|
+
/** Batas pembacaan body (byte). Default: `MAX_TRANSPORT_BODY_BYTES`. */
|
|
22
|
+
maxBodyBytes?: number;
|
|
23
|
+
}
|
|
24
|
+
/** Addendum J.7: default aman runtime; `config.headers` tetap menang (cek `has`). */
|
|
25
|
+
export declare const DEFAULT_SECURITY_HEADERS: Record<string, string>;
|
|
26
|
+
export declare function firstHeaderValue(value: string | string[] | undefined): string | null;
|
|
27
|
+
export declare function forwardedProtocol(value: string | string[] | undefined): "http" | "https" | null;
|
|
28
|
+
export declare function forwardedHost(value: string | string[] | undefined): string | null;
|
|
29
|
+
/**
|
|
30
|
+
* IP klien tepercaya (Addendum J.1) dari nilai header mentah — inti yang sama
|
|
31
|
+
* untuk Node (`IncomingMessage`) dan Bun (`Request` + `server.requestIP`).
|
|
32
|
+
*/
|
|
33
|
+
export declare function resolveClientIpValues(forwardedFor: string | string[] | undefined, remoteAddress: string | null, trustProxy: boolean): string | null;
|
|
34
|
+
export declare const MIME_TYPES: Record<string, string>;
|
|
35
|
+
export declare const COMPRESSIBLE_FILE: RegExp;
|
|
36
|
+
export declare const GZIP_MIN_BYTES = 1024;
|
|
37
|
+
/** Varian gzip ter-cache per file+mtime (bounded FIFO) — Addendum K.5. */
|
|
38
|
+
export declare function gzipCached(file: string, mtimeMs: number, body: Buffer): Buffer;
|
|
39
|
+
export declare function acceptsGzipHeader(value: string | null): boolean;
|
|
40
|
+
/** 413 transport dengan format §15 yang sama seperti `rpc.ts` (Addendum J.2). */
|
|
41
|
+
export declare function rpcBodyTooLargeText(limit: number, size: number | null): string;
|
|
42
|
+
/** Handler melewati `requestTimeoutMs` → transport menjawab 504 (Addendum R). */
|
|
43
|
+
export declare class RequestTimeoutError extends Error {
|
|
44
|
+
readonly limit: number;
|
|
45
|
+
constructor(limit: number);
|
|
46
|
+
}
|
|
47
|
+
/** 504 dengan format §15 (dipakai kedua transport). */
|
|
48
|
+
export declare function requestTimeoutText(limit: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Jalankan handler dengan batas waktu eksplisit (Addendum R; review G1).
|
|
51
|
+
* `timeoutMs` undefined/0 → tanpa batas (perilaku lama).
|
|
52
|
+
*/
|
|
53
|
+
export declare function runHandlerWithTimeout(handler: (request: Request) => Promise<Response>, request: Request, timeoutMs: number | undefined): Promise<Response>;
|
|
54
|
+
/** Klasifikasi abort klien / koneksi putus (review G7) — bukan error server. */
|
|
55
|
+
export declare function isAbortLike(error: unknown): boolean;
|
|
56
|
+
/** Respons teks transport (400/413/500/504) — tetap membawa header config + default aman (review G6). */
|
|
57
|
+
export declare function textResponseWithHeaders(status: number, text: string, configured?: Record<string, string>): Response;
|
|
58
|
+
/** Target header minimal yang dipenuhi `ServerResponse` dan `Headers`. */
|
|
59
|
+
export interface HeaderTarget {
|
|
60
|
+
has(name: string): boolean;
|
|
61
|
+
set(name: string, value: string): void;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Addendum J.7: header konfigurasi (menang), default aman, dan
|
|
65
|
+
* `Cache-Control: immutable` untuk aset ber-hash di `/assets/*`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function applyStaticHeaders(target: HeaderTarget, pathname: string, configured: Record<string, string> | undefined): void;
|
|
68
|
+
/**
|
|
69
|
+
* Baca body Web stream dengan batas tegas (Addendum J.2): begitu melewati
|
|
70
|
+
* `maxBytes`, pembacaan dihentikan dan `BodyLimitError` dilempar.
|
|
71
|
+
*/
|
|
72
|
+
export declare function readBodyWithLimit(stream: ReadableStream<Uint8Array> | null, maxBytes: number): Promise<Uint8Array>;
|
|
73
|
+
/** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
|
|
74
|
+
export declare function resolveStaticFile(clientDir: string, pathname: string): string | null;
|
|
75
|
+
/**
|
|
76
|
+
* Target file untuk `pathname` (tanpa cek keberadaan) — dipakai penyajian,
|
|
77
|
+
* `createFileStorage().write`, dan test. Proteksi path traversal tetap.
|
|
78
|
+
*/
|
|
79
|
+
export declare function staticFileTarget(clientDir: string, pathname: string): string | null;
|
|
80
|
+
export interface PrerenderManifestInfo {
|
|
81
|
+
paths: Set<string>;
|
|
82
|
+
revalidate: Map<string, number>;
|
|
83
|
+
}
|
|
84
|
+
export declare function readPrerenderManifest(clientDir: string): PrerenderManifestInfo;
|
|
85
|
+
/** ISR: jika file prerender kedaluwarsa, regenerasi background (SWR). */
|
|
86
|
+
export declare function scheduleRevalidation(input: {
|
|
87
|
+
pathname: string;
|
|
88
|
+
mtimeMs: number;
|
|
89
|
+
file?: string;
|
|
90
|
+
storage?: PrerenderStorage;
|
|
91
|
+
handler: (request: Request) => Promise<Response>;
|
|
92
|
+
options: {
|
|
93
|
+
canary?: string;
|
|
94
|
+
};
|
|
95
|
+
clientDir: string;
|
|
96
|
+
logger?: Logger | null;
|
|
97
|
+
}): void;
|
|
98
|
+
//# sourceMappingURL=transport.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"transport.d.ts","sourceRoot":"","sources":["../../src/server/transport.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moonsjs/vite",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -50,9 +50,9 @@
|
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
52
|
"@babel/parser": "^8.0.7",
|
|
53
|
-
"@moonsjs/core": "^0.
|
|
54
|
-
"@moonsjs/react": "^0.
|
|
55
|
-
"@moonsjs/server": "^0.
|
|
53
|
+
"@moonsjs/core": "^0.5.0",
|
|
54
|
+
"@moonsjs/react": "^0.5.0",
|
|
55
|
+
"@moonsjs/server": "^0.5.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"vite": "^8.3.4"
|
package/src/plugin.ts
CHANGED
|
@@ -60,6 +60,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
60
60
|
// Addendum J.1/J.2: paritas dev & produksi (paritas E7).
|
|
61
61
|
let trustProxy = false;
|
|
62
62
|
let bodyLimitBytes: number | undefined;
|
|
63
|
+
let requestTimeoutMs: number | undefined;
|
|
63
64
|
let apiDir = "";
|
|
64
65
|
let apiRoutes: ApiRouteDefinition[] = [];
|
|
65
66
|
let apiMiddlewares: ApiMiddlewareDefinition[] = [];
|
|
@@ -251,6 +252,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
251
252
|
serverOnlyPackages = config.security?.serverOnlyPackages ?? [];
|
|
252
253
|
trustProxy = config.security?.trustProxy === true;
|
|
253
254
|
bodyLimitBytes = config.security?.bodyLimitBytes;
|
|
255
|
+
requestTimeoutMs = config.requestTimeoutMs;
|
|
254
256
|
|
|
255
257
|
// Addendum D.1: scan + validasi statis modul `src/api`.
|
|
256
258
|
apiDir = resolvePath(root, config.apiDir ?? "src/api");
|
|
@@ -466,6 +468,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
466
468
|
: { apiPrefix: `/${apiDir.split(/[\\/]/).filter((part) => part.length > 0).pop() ?? "api"}` }),
|
|
467
469
|
trustProxy,
|
|
468
470
|
...(bodyLimitBytes === undefined ? {} : { bodyLimitBytes }),
|
|
471
|
+
...(requestTimeoutMs === undefined ? {} : { requestTimeoutMs }),
|
|
469
472
|
});
|
|
470
473
|
server.config.logger.info("[moons] devtools: /__moons/devtools");
|
|
471
474
|
},
|
|
@@ -557,7 +560,7 @@ function renderServerEntryBuild(
|
|
|
557
560
|
): string {
|
|
558
561
|
return [
|
|
559
562
|
"// Generated by @moons. Do not edit manually.",
|
|
560
|
-
'import { createFileShell, createPluginRuntime, createRequestHandler,
|
|
563
|
+
'import { createFileShell, createPluginRuntime, createRequestHandler, startServer } from "@moonsjs/vite/server";',
|
|
561
564
|
'import { renderToMarkup } from "@moonsjs/react/ssr";',
|
|
562
565
|
'import config from "virtual:moons/config";',
|
|
563
566
|
'import { routes, loadRoute } from "virtual:moons/routes";',
|
|
@@ -579,7 +582,7 @@ function renderServerEntryBuild(
|
|
|
579
582
|
" onContext: (ctx) => plugins.applyExtensions(ctx),",
|
|
580
583
|
"});",
|
|
581
584
|
"",
|
|
582
|
-
`
|
|
585
|
+
`startServer(handler, { plugins, headers: config.headers, storage: config.isr?.storage, trustProxy: config.security?.trustProxy, bodyLimitBytes: config.security?.bodyLimitBytes, requestTimeoutMs: config.requestTimeoutMs, logging: config.logging, trailingSlash: config.trailingSlash${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
|
|
583
586
|
"",
|
|
584
587
|
].join("\n");
|
|
585
588
|
}
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { extname, join, resolve } from "node:path";
|
|
3
|
+
import { CLIENT_IP_HEADER, DEFAULT_BODY_LIMIT_BYTES, trailingSlashRedirect } from "@moonsjs/core";
|
|
4
|
+
import { contentTypeForPathname } from "./storage";
|
|
5
|
+
import {
|
|
6
|
+
BodyLimitError,
|
|
7
|
+
COMPRESSIBLE_FILE,
|
|
8
|
+
DEFAULT_SECURITY_HEADERS,
|
|
9
|
+
GZIP_MIN_BYTES,
|
|
10
|
+
InvalidRequestError,
|
|
11
|
+
MAX_TRANSPORT_BODY_BYTES,
|
|
12
|
+
MIME_TYPES,
|
|
13
|
+
RequestTimeoutError,
|
|
14
|
+
acceptsGzipHeader,
|
|
15
|
+
applyStaticHeaders,
|
|
16
|
+
forwardedHost,
|
|
17
|
+
forwardedProtocol,
|
|
18
|
+
gzipCached,
|
|
19
|
+
isAbortLike,
|
|
20
|
+
readBodyWithLimit,
|
|
21
|
+
requestTimeoutText,
|
|
22
|
+
resolveClientIpValues,
|
|
23
|
+
resolveStaticFile,
|
|
24
|
+
rpcBodyTooLargeText,
|
|
25
|
+
runHandlerWithTimeout,
|
|
26
|
+
scheduleRevalidation,
|
|
27
|
+
textResponseWithHeaders,
|
|
28
|
+
} from "./transport";
|
|
29
|
+
import { createLogger, type Logger } from "./logger";
|
|
30
|
+
import type { NodeServerOptions } from "./node-server";
|
|
31
|
+
import type { PluginRuntime } from "./plugins";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Addendum Q (3B): host `Bun.serve` dengan perilaku paritas `node-server`:
|
|
35
|
+
* static + storage/ISR + ETag/gzip + trailingSlash + body limit RPC +
|
|
36
|
+
* header default + access log + drain SIGTERM. Dipilih otomatis oleh
|
|
37
|
+
* `startServer` saat berjalan di Bun (override: `MOONS_SERVER=node|bun`).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
interface BunServerHandle {
|
|
41
|
+
port: number;
|
|
42
|
+
stop(closeActiveConnections?: boolean): Promise<void>;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function appendVaryHeader(headers: Headers, value: string): void {
|
|
46
|
+
const existing = headers.get("vary");
|
|
47
|
+
if (existing !== null && existing.length > 0) {
|
|
48
|
+
if (!existing.toLowerCase().includes(value)) {
|
|
49
|
+
headers.set("vary", `${existing}, ${value}`);
|
|
50
|
+
}
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
headers.set("vary", value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** J.7: response handler tanpa header default aman — mutasi langsung bila mungkin. */
|
|
57
|
+
function withDefaultSecurityHeaders(response: Response): Response {
|
|
58
|
+
let missing = false;
|
|
59
|
+
for (const name of Object.keys(DEFAULT_SECURITY_HEADERS)) {
|
|
60
|
+
if (!response.headers.has(name)) {
|
|
61
|
+
missing = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (!missing) {
|
|
66
|
+
return response;
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
|
|
70
|
+
if (!response.headers.has(name)) {
|
|
71
|
+
response.headers.set(name, value);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return response;
|
|
75
|
+
} catch {
|
|
76
|
+
// Header immutable (mis. dari fetch) → bungkus ulang.
|
|
77
|
+
const headers = new Headers(response.headers);
|
|
78
|
+
for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
|
|
79
|
+
if (!headers.has(name)) {
|
|
80
|
+
headers.set(name, value);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return new Response(response.body, {
|
|
84
|
+
status: response.status,
|
|
85
|
+
statusText: response.statusText,
|
|
86
|
+
headers,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** J.1: URL dari Request Bun, mengikuti `x-forwarded-*` bila trustProxy aktif. */
|
|
92
|
+
function resolveBunUrl(request: Request, trustProxy: boolean): URL {
|
|
93
|
+
let url: URL;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(request.url);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new InvalidRequestError(`invalid request URL: ${request.url}`);
|
|
98
|
+
}
|
|
99
|
+
if (!trustProxy) {
|
|
100
|
+
return url;
|
|
101
|
+
}
|
|
102
|
+
const proto = forwardedProtocol(request.headers.get("x-forwarded-proto") ?? undefined);
|
|
103
|
+
const host = forwardedHost(request.headers.get("x-forwarded-host") ?? undefined);
|
|
104
|
+
if (proto === null && host === null) {
|
|
105
|
+
return url;
|
|
106
|
+
}
|
|
107
|
+
// Bangun URL baru (bukan mutasi `.host`): setter `host` mempertahankan port
|
|
108
|
+
// lama, sehingga `internal:3000` + `x-forwarded-host: app.example.com`
|
|
109
|
+
// menghasilkan origin `https://app.example.com:3000` — origin RPC meleset.
|
|
110
|
+
const effectiveProto = proto ?? (url.protocol === "https:" ? "https" : "http");
|
|
111
|
+
const effectiveHost = host ?? url.host;
|
|
112
|
+
try {
|
|
113
|
+
return new URL(`${effectiveProto}://${effectiveHost}${url.pathname}${url.search}${url.hash}`);
|
|
114
|
+
} catch {
|
|
115
|
+
throw new InvalidRequestError(`invalid forwarded host: ${JSON.stringify(effectiveHost)}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Request Web dengan body terbatas + `x-moons-client-ip` tepercaya (J.1/J.2). */
|
|
120
|
+
async function toBoundedRequest(
|
|
121
|
+
request: Request,
|
|
122
|
+
url: URL,
|
|
123
|
+
clientIp: string | null,
|
|
124
|
+
maxBodyBytes: number,
|
|
125
|
+
): Promise<Request> {
|
|
126
|
+
const headers = new Headers(request.headers);
|
|
127
|
+
headers.delete("content-length");
|
|
128
|
+
if (clientIp !== null) {
|
|
129
|
+
headers.set(CLIENT_IP_HEADER, clientIp);
|
|
130
|
+
}
|
|
131
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
132
|
+
return new Request(url, { method: request.method, headers });
|
|
133
|
+
}
|
|
134
|
+
const body = await readBodyWithLimit(request.body, maxBodyBytes);
|
|
135
|
+
return new Request(url, { method: request.method, headers, body: body as unknown as BodyInit });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function installBunShutdown(
|
|
139
|
+
server: BunServerHandle,
|
|
140
|
+
options: { timeoutMs?: number; plugins?: PluginRuntime | undefined },
|
|
141
|
+
): (signal: string) => void {
|
|
142
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
143
|
+
let shuttingDown = false;
|
|
144
|
+
return (signal: string): void => {
|
|
145
|
+
if (shuttingDown) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
shuttingDown = true;
|
|
149
|
+
console.log(`[moons] received ${signal} — no longer accepting connections, draining...`);
|
|
150
|
+
const timer = setTimeout(() => {
|
|
151
|
+
console.log(`[moons] drain exceeded ${timeoutMs}ms — forcing exit`);
|
|
152
|
+
void server.stop(true).catch(() => {});
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
(timer as { unref?: () => void }).unref?.();
|
|
156
|
+
void (async () => {
|
|
157
|
+
try {
|
|
158
|
+
await server.stop(false);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
console.log(
|
|
162
|
+
`[moons] server close failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
163
|
+
);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (options.plugins !== undefined) {
|
|
168
|
+
// Berbagi batas waktu dengan drain: timer di-ref kembali.
|
|
169
|
+
(timer as { ref?: () => void }).ref?.();
|
|
170
|
+
try {
|
|
171
|
+
await options.plugins.shutdown();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.log(`[moons] onShutdown failed: ${String(error)}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
console.log("[moons] shutdown complete (full drain)");
|
|
178
|
+
process.exit(0);
|
|
179
|
+
})();
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Serve produksi dengan `Bun.serve` (Addendum Q). API yang sama dengan
|
|
185
|
+
* `startNodeServer`; hanya tersedia di runtime Bun.
|
|
186
|
+
*/
|
|
187
|
+
export function startBunServer(
|
|
188
|
+
handler: (request: Request) => Promise<Response>,
|
|
189
|
+
options: NodeServerOptions = {},
|
|
190
|
+
): BunServerHandle {
|
|
191
|
+
if (typeof Bun === "undefined") {
|
|
192
|
+
throw new Error("[moons] startBunServer requires the Bun runtime");
|
|
193
|
+
}
|
|
194
|
+
const clientDir = resolve(options.clientDir ?? join(process.cwd(), "dist/client"));
|
|
195
|
+
const port = options.port ?? Number(process.env["PORT"] ?? 3000);
|
|
196
|
+
const trustProxy = options.trustProxy === true;
|
|
197
|
+
const requestTimeoutMs = options.requestTimeoutMs;
|
|
198
|
+
// Addendum R: idleTimeout Bun diselaraskan dengan batas kita (+5 dtk grace)
|
|
199
|
+
// supaya 504 dari framework yang menang, bukan koneksi diputus diam-diam.
|
|
200
|
+
const idleTimeout =
|
|
201
|
+
requestTimeoutMs === undefined || requestTimeoutMs <= 0
|
|
202
|
+
? 0
|
|
203
|
+
: Math.ceil(requestTimeoutMs / 1000) + 5;
|
|
204
|
+
const accessLogger: Logger | null =
|
|
205
|
+
options.logging?.accessLog === true
|
|
206
|
+
? createLogger({
|
|
207
|
+
json: options.logging.json !== false,
|
|
208
|
+
redact: options.logging.redact ?? [],
|
|
209
|
+
})
|
|
210
|
+
: null;
|
|
211
|
+
|
|
212
|
+
const serveStatic = async (
|
|
213
|
+
request: Request,
|
|
214
|
+
url: URL,
|
|
215
|
+
): Promise<Response | null> => {
|
|
216
|
+
if (options.storage !== undefined) {
|
|
217
|
+
// Addendum R (review G2): storage error = miss — aset & halaman non-ISR
|
|
218
|
+
// harus tetap jalan saat store bersama outage.
|
|
219
|
+
let entry: Awaited<ReturnType<NonNullable<typeof options.storage>["read"]>> | null = null;
|
|
220
|
+
try {
|
|
221
|
+
entry = await options.storage.read(url.pathname);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
224
|
+
if (accessLogger !== null) {
|
|
225
|
+
accessLogger({ msg: "isr:storage-read-failed", level: "warn", path: url.pathname, error: message });
|
|
226
|
+
} else {
|
|
227
|
+
console.warn(`[moons] isr storage read failed (${url.pathname}): ${message} — falling back to dist/client`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (entry !== null) {
|
|
231
|
+
scheduleRevalidation({
|
|
232
|
+
pathname: url.pathname,
|
|
233
|
+
mtimeMs: entry.mtimeMs,
|
|
234
|
+
storage: options.storage,
|
|
235
|
+
handler,
|
|
236
|
+
options,
|
|
237
|
+
clientDir,
|
|
238
|
+
logger: accessLogger,
|
|
239
|
+
});
|
|
240
|
+
const headers = new Headers();
|
|
241
|
+
headers.set("content-type", contentTypeForPathname(url.pathname));
|
|
242
|
+
applyStaticHeaders(headers, url.pathname, options.headers);
|
|
243
|
+
return new Response(
|
|
244
|
+
request.method === "HEAD" ? null : (entry.body as unknown as BodyInit),
|
|
245
|
+
{ status: 200, headers },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
// L.2 (temuan F1): storage MISS → fallback `dist/client`; storage tetap menang.
|
|
249
|
+
}
|
|
250
|
+
const staticFile = resolveStaticFile(clientDir, url.pathname);
|
|
251
|
+
if (staticFile === null) {
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
const stat = statSync(staticFile);
|
|
255
|
+
scheduleRevalidation({
|
|
256
|
+
pathname: url.pathname,
|
|
257
|
+
mtimeMs: stat.mtimeMs,
|
|
258
|
+
file: staticFile,
|
|
259
|
+
...(options.storage === undefined ? {} : { storage: options.storage }),
|
|
260
|
+
handler,
|
|
261
|
+
options,
|
|
262
|
+
clientDir,
|
|
263
|
+
logger: accessLogger,
|
|
264
|
+
});
|
|
265
|
+
const headers = new Headers();
|
|
266
|
+
headers.set(
|
|
267
|
+
"content-type",
|
|
268
|
+
MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream",
|
|
269
|
+
);
|
|
270
|
+
applyStaticHeaders(headers, url.pathname, options.headers);
|
|
271
|
+
|
|
272
|
+
// K.5: ETag + 304.
|
|
273
|
+
const etag = `W/"${stat.size}-${Math.floor(stat.mtimeMs)}"`;
|
|
274
|
+
headers.set("etag", etag);
|
|
275
|
+
const ifNoneMatch = request.headers.get("if-none-match");
|
|
276
|
+
if (ifNoneMatch !== null && ifNoneMatch.split(",").some((value) => value.trim() === etag)) {
|
|
277
|
+
return new Response(null, { status: 304, headers });
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// K.5: gzip untuk aset teks (cache per mtime).
|
|
281
|
+
let payload: Uint8Array | null =
|
|
282
|
+
request.method === "HEAD" ? null : readFileSync(staticFile);
|
|
283
|
+
if (
|
|
284
|
+
payload !== null &&
|
|
285
|
+
COMPRESSIBLE_FILE.test(staticFile) &&
|
|
286
|
+
payload.byteLength >= GZIP_MIN_BYTES &&
|
|
287
|
+
acceptsGzipHeader(request.headers.get("accept-encoding"))
|
|
288
|
+
) {
|
|
289
|
+
payload = gzipCached(staticFile, stat.mtimeMs, Buffer.from(payload));
|
|
290
|
+
headers.set("content-encoding", "gzip");
|
|
291
|
+
appendVaryHeader(headers, "accept-encoding");
|
|
292
|
+
}
|
|
293
|
+
return new Response(payload as unknown as BodyInit | null, { status: 200, headers });
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const server = Bun.serve({
|
|
297
|
+
port,
|
|
298
|
+
idleTimeout,
|
|
299
|
+
fetch: async (request, bunServer) => {
|
|
300
|
+
const requestStartedAt = performance.now();
|
|
301
|
+
let url: URL;
|
|
302
|
+
try {
|
|
303
|
+
url = resolveBunUrl(request, trustProxy);
|
|
304
|
+
} catch (error) {
|
|
305
|
+
if (error instanceof InvalidRequestError) {
|
|
306
|
+
console.warn(`[moons] ${error.message} — 400`);
|
|
307
|
+
return textResponseWithHeaders(400, "Bad Request", options.headers);
|
|
308
|
+
}
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
let clientIp: string | null = null;
|
|
312
|
+
const finish = (response: Response): Response => {
|
|
313
|
+
if (accessLogger !== null) {
|
|
314
|
+
accessLogger({
|
|
315
|
+
msg: "request",
|
|
316
|
+
method: request.method,
|
|
317
|
+
path: url.pathname + url.search,
|
|
318
|
+
status: response.status,
|
|
319
|
+
durationMs: Math.round(performance.now() - requestStartedAt),
|
|
320
|
+
...(clientIp === null ? {} : { ip: clientIp }),
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
return response;
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
try {
|
|
327
|
+
// M: kanonikalisasi trailing slash di transport (termasuk path prerender).
|
|
328
|
+
if (options.trailingSlash !== undefined) {
|
|
329
|
+
const canonical = trailingSlashRedirect(url.pathname, options.trailingSlash);
|
|
330
|
+
if (canonical !== null) {
|
|
331
|
+
url.pathname = canonical;
|
|
332
|
+
return new Response(null, { status: 308, headers: { location: url.toString() } });
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
337
|
+
const staticResponse = await serveStatic(request, url);
|
|
338
|
+
if (staticResponse !== null) {
|
|
339
|
+
return finish(staticResponse);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// J.2: batas body RPC di transport — content-length diperiksa lebih dulu.
|
|
344
|
+
const isRpc = url.pathname.startsWith("/_moons/fn/");
|
|
345
|
+
const rpcLimit = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
|
|
346
|
+
if (isRpc) {
|
|
347
|
+
const contentLength = Number(request.headers.get("content-length") ?? "0");
|
|
348
|
+
if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
|
|
349
|
+
return finish(
|
|
350
|
+
textResponseWithHeaders(413, rpcBodyTooLargeText(rpcLimit, contentLength), options.headers),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const remoteAddress = bunServer.requestIP(request)?.address ?? null;
|
|
356
|
+
clientIp = resolveClientIpValues(
|
|
357
|
+
request.headers.get("x-forwarded-for") ?? undefined,
|
|
358
|
+
remoteAddress,
|
|
359
|
+
trustProxy,
|
|
360
|
+
);
|
|
361
|
+
// Fast path: GET/HEAD tanpa rewrite URL & tanpa IP tepercaya → pakai
|
|
362
|
+
// Request asli (menghindari rekonstruksi + salinan header per request).
|
|
363
|
+
const needsRewrite = clientIp !== null || url.toString() !== request.url;
|
|
364
|
+
const webRequest =
|
|
365
|
+
(request.method === "GET" || request.method === "HEAD") && !needsRewrite
|
|
366
|
+
? request
|
|
367
|
+
: await toBoundedRequest(
|
|
368
|
+
request,
|
|
369
|
+
url,
|
|
370
|
+
clientIp,
|
|
371
|
+
isRpc ? rpcLimit : MAX_TRANSPORT_BODY_BYTES,
|
|
372
|
+
);
|
|
373
|
+
// Addendum R: batas waktu eksplisit; 0/undefined = tanpa batas.
|
|
374
|
+
const response = await runHandlerWithTimeout(handler, webRequest, requestTimeoutMs);
|
|
375
|
+
return finish(withDefaultSecurityHeaders(response));
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error instanceof BodyLimitError) {
|
|
378
|
+
const isRpc = url.pathname.startsWith("/_moons/fn/");
|
|
379
|
+
return finish(
|
|
380
|
+
textResponseWithHeaders(
|
|
381
|
+
413,
|
|
382
|
+
isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large",
|
|
383
|
+
options.headers,
|
|
384
|
+
),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
// Addendum R: timeout eksplisit — 504 + log terstruktur.
|
|
388
|
+
if (error instanceof RequestTimeoutError) {
|
|
389
|
+
if (accessLogger !== null) {
|
|
390
|
+
accessLogger({
|
|
391
|
+
msg: "request-timeout",
|
|
392
|
+
level: "warn",
|
|
393
|
+
method: request.method,
|
|
394
|
+
path: url.pathname + url.search,
|
|
395
|
+
limitMs: error.limit,
|
|
396
|
+
});
|
|
397
|
+
} else {
|
|
398
|
+
console.warn(`[moons] request timed out after ${error.limit}ms — ${request.method} ${url.pathname}`);
|
|
399
|
+
}
|
|
400
|
+
return finish(textResponseWithHeaders(504, requestTimeoutText(error.limit), options.headers));
|
|
401
|
+
}
|
|
402
|
+
// Review G7: abort klien/koneksi putus bukan error server (tanpa stack).
|
|
403
|
+
if (isAbortLike(error)) {
|
|
404
|
+
if (accessLogger !== null) {
|
|
405
|
+
accessLogger({
|
|
406
|
+
msg: "request-aborted",
|
|
407
|
+
level: "warn",
|
|
408
|
+
method: request.method,
|
|
409
|
+
path: url.pathname + url.search,
|
|
410
|
+
});
|
|
411
|
+
} else {
|
|
412
|
+
console.warn(`[moons] request aborted by the client — ${request.method} ${url.pathname}`);
|
|
413
|
+
}
|
|
414
|
+
return new Response(null, { status: 499 });
|
|
415
|
+
}
|
|
416
|
+
console.error("[moons] server error:", error);
|
|
417
|
+
return finish(textResponseWithHeaders(500, "Internal Server Error", options.headers));
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
// B.4: onStart sebelum log siap, onReady setelah server menerima koneksi.
|
|
423
|
+
const listenAfterStart = (): void => {
|
|
424
|
+
console.log(`[moons] server listening on http://localhost:${server.port}`);
|
|
425
|
+
void options.plugins?.ready();
|
|
426
|
+
};
|
|
427
|
+
if (options.plugins !== undefined) {
|
|
428
|
+
void options.plugins.start().then(listenAfterStart);
|
|
429
|
+
} else {
|
|
430
|
+
listenAfterStart();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
if (options.handleSignals !== false) {
|
|
434
|
+
const shutdown = installBunShutdown(server as unknown as BunServerHandle, {
|
|
435
|
+
...(options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs }),
|
|
436
|
+
plugins: options.plugins,
|
|
437
|
+
});
|
|
438
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
439
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return server as unknown as BunServerHandle;
|
|
443
|
+
}
|
package/src/server/dev.ts
CHANGED
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
toWebRequest,
|
|
14
14
|
} from "./node";
|
|
15
15
|
import { DEFAULT_BODY_LIMIT_BYTES } from "@moonsjs/core";
|
|
16
|
+
import {
|
|
17
|
+
RequestTimeoutError,
|
|
18
|
+
applyStaticHeaders,
|
|
19
|
+
requestTimeoutText,
|
|
20
|
+
runHandlerWithTimeout,
|
|
21
|
+
} from "./transport";
|
|
16
22
|
import { emptyDevtoolsData, renderDevtoolsHtml, type DevtoolsData } from "../devtools";
|
|
17
23
|
|
|
18
24
|
interface DevApp {
|
|
@@ -41,6 +47,8 @@ export function installDevMiddleware(
|
|
|
41
47
|
/** Addendum J.1: paritas dev dengan produksi (trustProxy/bodyLimitBytes). */
|
|
42
48
|
trustProxy?: boolean;
|
|
43
49
|
bodyLimitBytes?: number;
|
|
50
|
+
/** Addendum R: batas waktu respons (ms); 0/undefined = tanpa batas. */
|
|
51
|
+
requestTimeoutMs?: number;
|
|
44
52
|
/** Addendum I.4: datasource devtools overlay (dipanggil per request). */
|
|
45
53
|
devtools?: () => DevtoolsData;
|
|
46
54
|
},
|
|
@@ -110,7 +118,7 @@ async function handleRequest(
|
|
|
110
118
|
getApp: () => Promise<DevApp>,
|
|
111
119
|
req: IncomingMessage,
|
|
112
120
|
res: ServerResponse,
|
|
113
|
-
options: { trustProxy?: boolean; bodyLimitBytes?: number },
|
|
121
|
+
options: { trustProxy?: boolean; bodyLimitBytes?: number; requestTimeoutMs?: number },
|
|
114
122
|
): Promise<void> {
|
|
115
123
|
const app = await getApp();
|
|
116
124
|
const url = resolveRequestUrl(req, { trustProxy: options.trustProxy === true });
|
|
@@ -119,7 +127,24 @@ async function handleRequest(
|
|
|
119
127
|
trustProxy: options.trustProxy === true,
|
|
120
128
|
...(isRpc ? { maxBodyBytes: options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES } : {}),
|
|
121
129
|
});
|
|
122
|
-
|
|
130
|
+
// Addendum R: paritas dev — batas waktu eksplisit yang sama dengan produksi.
|
|
131
|
+
let response: Response;
|
|
132
|
+
try {
|
|
133
|
+
response = await runHandlerWithTimeout(app.handler, request, options.requestTimeoutMs);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
if (error instanceof RequestTimeoutError) {
|
|
136
|
+
res.statusCode = 504;
|
|
137
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
138
|
+
applyStaticHeaders(
|
|
139
|
+
{ has: (name) => res.hasHeader(name), set: (name, value) => res.setHeader(name, value) },
|
|
140
|
+
"/",
|
|
141
|
+
undefined,
|
|
142
|
+
);
|
|
143
|
+
res.end(requestTimeoutText(error.limit));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
123
148
|
await sendNodeResponse(res, response);
|
|
124
149
|
}
|
|
125
150
|
|