@moonsjs/vite 0.0.0-stage → 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -2
  3. package/dist/index-8xq8fcgq.js +222 -0
  4. package/dist/index-8xq8fcgq.js.map +14 -0
  5. package/dist/index-fg6nqart.js +776 -0
  6. package/dist/index-fg6nqart.js.map +10 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +423 -0
  10. package/dist/index.js.map +11 -0
  11. package/dist/leak-check.d.ts +14 -0
  12. package/dist/leak-check.d.ts.map +1 -0
  13. package/dist/manifest.d.ts +21 -0
  14. package/dist/manifest.d.ts.map +1 -0
  15. package/dist/plugin.d.ts +13 -0
  16. package/dist/plugin.d.ts.map +1 -0
  17. package/dist/server/dev.d.ts +9 -0
  18. package/dist/server/dev.d.ts.map +1 -0
  19. package/dist/server/handler.d.ts +18 -0
  20. package/dist/server/handler.d.ts.map +1 -0
  21. package/dist/server/index.d.ts +13 -0
  22. package/dist/server/index.d.ts.map +1 -0
  23. package/dist/server/index.js +311 -0
  24. package/dist/server/index.js.map +13 -0
  25. package/dist/server/node-server.d.ts +17 -0
  26. package/dist/server/node-server.d.ts.map +1 -0
  27. package/dist/server/node.d.ts +6 -0
  28. package/dist/server/node.d.ts.map +1 -0
  29. package/dist/server/shell.d.ts +18 -0
  30. package/dist/server/shell.d.ts.map +1 -0
  31. package/dist/server/shutdown.d.ts +17 -0
  32. package/dist/server/shutdown.d.ts.map +1 -0
  33. package/dist/server/test-server.d.ts +21 -0
  34. package/dist/server/test-server.d.ts.map +1 -0
  35. package/dist/transform.d.ts +101 -0
  36. package/dist/transform.d.ts.map +1 -0
  37. package/dist/transform.js +34 -0
  38. package/dist/transform.js.map +9 -0
  39. package/dist/virtual-modules.d.ts +12 -0
  40. package/dist/virtual-modules.d.ts.map +1 -0
  41. package/package.json +51 -4
  42. package/src/ctx-fallback.d.ts +9 -0
  43. package/src/index.ts +2 -0
  44. package/src/leak-check.ts +36 -0
  45. package/src/manifest.ts +79 -0
  46. package/src/plugin.ts +471 -0
  47. package/src/server/dev.ts +54 -0
  48. package/src/server/handler.ts +249 -0
  49. package/src/server/index.ts +12 -0
  50. package/src/server/node-server.ts +115 -0
  51. package/src/server/node.ts +61 -0
  52. package/src/server/shell.ts +77 -0
  53. package/src/server/shutdown.ts +64 -0
  54. package/src/server/test-server.ts +44 -0
  55. package/src/transform.ts +1073 -0
  56. package/src/virtual-modules.ts +23 -0
@@ -0,0 +1,249 @@
1
+ import {
2
+ MoonsError,
3
+ composeMiddleware,
4
+ resolveRoute,
5
+ routeKeyFromFile,
6
+ runLoaders,
7
+ type MoonsConfig,
8
+ type ResolvedRoute,
9
+ type RouteDefinition,
10
+ type RouteModule,
11
+ } from "@moonsjs/core";
12
+ import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
13
+ import { renderToMarkup } from "@moonsjs/react/ssr";
14
+ import type { ClientRouteEntry, RouteModuleShape } from "@moonsjs/react/internal";
15
+ import { createRpcHandler, runWithContext } from "@moonsjs/server";
16
+ import { ERROR_ROUTE_KEY, NOT_FOUND_ROUTE_KEY } from "../manifest";
17
+ import type { Shell } from "./shell";
18
+
19
+ const RPC_PREFIX = "/_moons/fn/";
20
+ const HEALTH_PATH = "/_moons/health";
21
+
22
+ export interface RequestHandlerOptions {
23
+ routes: RouteDefinition[];
24
+ /** Config yang sudah divalidasi. */
25
+ config: MoonsConfig;
26
+ /** Direktori route absolut; dipakai untuk derivasi key stabil. */
27
+ routesDir: string;
28
+ /** Peta import() modul route, key = routeKeyFromFile. */
29
+ loadRoute: Record<string, () => Promise<RouteModule>>;
30
+ shell: Shell;
31
+ }
32
+
33
+ /**
34
+ * Handler HTML: context → middleware global → resolve route → loader paralel
35
+ * → render React → inject payload devalue (PRD §11.2, §14.2, §17).
36
+ */
37
+ export function createRequestHandler(
38
+ options: RequestHandlerOptions,
39
+ ): (request: Request) => Promise<Response> {
40
+ const { routes, config, routesDir, loadRoute, shell } = options;
41
+ const rpc = createRpcHandler(
42
+ config.security === undefined ? {} : { security: config.security },
43
+ );
44
+ const notFoundRoute = routes.find((route) => route.kind === "404");
45
+ const errorRoute = routes.find((route) => route.kind === "error");
46
+
47
+ async function renderRoute(
48
+ resolved: ResolvedRoute,
49
+ request: Request,
50
+ ctx: Ctx,
51
+ ): Promise<Response> {
52
+ const entries = [...resolved.layouts, resolved.route];
53
+ const keys = entries.map((entry) => routeKeyFromFile(routesDir, entry.file));
54
+ const modules = await loadModules(keys, loadRoute);
55
+
56
+ const data = await runLoaders(
57
+ entries.map((entry, index) => ({ file: keys[index] as string, module: modules[index] as RouteModule })),
58
+ { params: resolved.params, ctx, req: request },
59
+ );
60
+
61
+ const pageKey = routeKeyFromFile(routesDir, resolved.route.file);
62
+ const pageModule = modules[modules.length - 1] as RouteModule;
63
+ const entry: ClientRouteEntry = { page: pageKey, layouts: keys.slice(0, -1) };
64
+ // Addendum A.3: query string ikut ke payload agar server/client identik.
65
+ const searchParams = Object.fromEntries(new URL(request.url).searchParams);
66
+
67
+ const markup = renderToMarkup(
68
+ entry,
69
+ { page: pageModule as RouteModuleShape, layouts: modules.slice(0, -1) as RouteModuleShape[] },
70
+ resolved.params,
71
+ data,
72
+ searchParams,
73
+ );
74
+ const payload = encodeSsrPayload({
75
+ key: pageKey,
76
+ params: resolved.params,
77
+ searchParams,
78
+ data,
79
+ });
80
+
81
+ return htmlResponse(await shell(markup, payload, request.url), 200);
82
+ }
83
+
84
+ /** §11.2: halaman `_404.tsx` kustom (standalone), fallback bawaan. */
85
+ async function notFoundResponse(request: Request): Promise<Response> {
86
+ if (notFoundRoute !== undefined) {
87
+ try {
88
+ const key = routeKeyFromFile(routesDir, notFoundRoute.file);
89
+ const [module] = await loadModules([key], loadRoute);
90
+ const payload = encodeSsrPayload({ key: NOT_FOUND_ROUTE_KEY, params: {}, data: {} });
91
+ const markup = renderToMarkup(
92
+ { page: key, layouts: [] },
93
+ { page: module as RouteModuleShape, layouts: [] },
94
+ {},
95
+ {},
96
+ );
97
+ return htmlResponse(await shell(markup, payload, request.url), 404);
98
+ } catch (error) {
99
+ console.error("[moons] gagal merender _404 kustom:", error);
100
+ }
101
+ }
102
+
103
+ const payload = encodeSsrPayload({ key: "", params: {}, data: {} });
104
+ const html = await shell(
105
+ "<h1>404</h1><p>Halaman tidak ditemukan.</p>",
106
+ payload,
107
+ request.url,
108
+ );
109
+ return htmlResponse(html, 404);
110
+ }
111
+
112
+ /**
113
+ * §11.2: halaman `_error.tsx` kustom dengan `RouteErrorProvider`.
114
+ * Production tidak men-serialisasi error apa pun (§16.6); dev mengirim aslinya.
115
+ */
116
+ async function routeErrorResponse(error: unknown, request: Request): Promise<Response> {
117
+ console.error("[moons] route error:", error);
118
+
119
+ if (errorRoute !== undefined) {
120
+ try {
121
+ const key = routeKeyFromFile(routesDir, errorRoute.file);
122
+ const [module] = await loadModules([key], loadRoute);
123
+ const isProduction = process.env["NODE_ENV"] === "production";
124
+ const serialized = isProduction ? undefined : serializeError(error);
125
+ const payload = encodeSsrPayload({
126
+ key: ERROR_ROUTE_KEY,
127
+ params: {},
128
+ data: {},
129
+ ...(serialized === undefined ? {} : { error: serialized }),
130
+ });
131
+ const markup = renderToMarkup(
132
+ { page: key, layouts: [] },
133
+ { page: module as RouteModuleShape, layouts: [] },
134
+ {},
135
+ {},
136
+ {},
137
+ serialized === undefined ? undefined : reviveError(serialized),
138
+ );
139
+ return htmlResponse(await shell(markup, payload, request.url), 500);
140
+ } catch (renderError) {
141
+ console.error("[moons] gagal merender _error kustom:", renderError);
142
+ }
143
+ }
144
+
145
+ return internalErrorResponse(error, { log: false });
146
+ }
147
+
148
+ return async function handler(request: Request): Promise<Response> {
149
+ try {
150
+ const url = new URL(request.url);
151
+
152
+ // §19.5: health ditangani paling awal — tetap hidup walau kode user rusak.
153
+ if (url.pathname === HEALTH_PATH) {
154
+ return healthResponse(request.method);
155
+ }
156
+
157
+ // `MoonsConfig.context` bertipe `unknown` (menghindari referensi sirkular
158
+ // ke global `Ctx` — §13); kontraknya tetap `Promise<Ctx>`, jadi cast di
159
+ // batas runtime ini. Tanpa cast, tsc app user yang memuat `@moonsjs/vite/server`
160
+ // (mis. `createTestServer` §19.5) melihat `unknown` vs `Ctx` global app.
161
+ const ctx = (await config.context(request)) as Ctx;
162
+
163
+ const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
164
+ if (url.pathname.startsWith(RPC_PREFIX)) {
165
+ return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
166
+ }
167
+ const resolved = resolveRoute(url.pathname, routes);
168
+ if (resolved === null) {
169
+ return await notFoundResponse(request);
170
+ }
171
+ try {
172
+ return await renderRoute(resolved, request, ctx);
173
+ } catch (error) {
174
+ return await routeErrorResponse(error, request);
175
+ }
176
+ });
177
+
178
+ return await runWithContext(ctx, () => respond(request));
179
+ } catch (error) {
180
+ return internalErrorResponse(error);
181
+ }
182
+ };
183
+ }
184
+
185
+ async function loadModules(
186
+ keys: string[],
187
+ loadRoute: Record<string, () => Promise<RouteModule>>,
188
+ ): Promise<RouteModule[]> {
189
+ return await Promise.all(
190
+ keys.map(async (key) => {
191
+ const load = loadRoute[key];
192
+ if (load === undefined) {
193
+ throw new MoonsError({
194
+ code: "E_INTERNAL",
195
+ message: `modul route \`${key}\` tidak terdaftar`,
196
+ details: "Registry route tidak sinkron dengan hasil scan.",
197
+ suggestions: ["Jalankan ulang `bun run build`, atau restart dev server"],
198
+ });
199
+ }
200
+ return await load();
201
+ }),
202
+ );
203
+ }
204
+
205
+ function internalErrorResponse(error: unknown, options: { log?: boolean } = {}): Response {
206
+ if (options.log !== false) {
207
+ console.error("[moons] request error:", error);
208
+ }
209
+
210
+ const detail =
211
+ process.env["NODE_ENV"] !== "production" && error instanceof MoonsError
212
+ ? `<pre>${escapeHtml(error.format())}</pre>`
213
+ : "<p>Terjadi kesalahan internal.</p>";
214
+
215
+ return htmlResponse(
216
+ `<!doctype html><html lang="id"><head><meta charset="utf-8"><title>500</title></head><body><h1>500</h1>${detail}</body></html>`,
217
+ 500,
218
+ );
219
+ }
220
+
221
+ function htmlResponse(html: string, status: number): Response {
222
+ return new Response(html, {
223
+ status,
224
+ headers: { "content-type": "text/html; charset=utf-8" },
225
+ });
226
+ }
227
+
228
+ /** §19.5: `/_moons/health` → `{ ok: true }`. */
229
+ function healthResponse(method: string): Response {
230
+ const headers = { "content-type": "application/json; charset=utf-8" };
231
+ if (method === "GET" || method === "HEAD") {
232
+ return new Response(method === "HEAD" ? null : JSON.stringify({ ok: true }), {
233
+ status: 200,
234
+ headers,
235
+ });
236
+ }
237
+ return new Response("Method Not Allowed", {
238
+ status: 405,
239
+ headers: { allow: "GET, HEAD" },
240
+ });
241
+ }
242
+
243
+ function escapeHtml(text: string): string {
244
+ return text
245
+ .replaceAll("&", "&amp;")
246
+ .replaceAll("<", "&lt;")
247
+ .replaceAll(">", "&gt;")
248
+ .replaceAll('"', "&quot;");
249
+ }
@@ -0,0 +1,12 @@
1
+ export { createRequestHandler } from "./handler";
2
+ export type { RequestHandlerOptions } from "./handler";
3
+ export { createDevShell, createFileShell, injectShell, DEFAULT_TEMPLATE, ROOT_PLACEHOLDER } from "./shell";
4
+ export type { Shell } from "./shell";
5
+ export { startNodeServer, resolveStaticFile } from "./node-server";
6
+ export type { NodeServerOptions } from "./node-server";
7
+ export { installGracefulShutdown } from "./shutdown";
8
+ export type { ShutdownOptions } from "./shutdown";
9
+ export { createTestServer } from "./test-server";
10
+ export type { TestServer, TestServerOptions } from "./test-server";
11
+ export { sendNodeResponse, toWebRequest } from "./node";
12
+ export { installDevMiddleware } from "./dev";
@@ -0,0 +1,115 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { createServer, type Server } from "node:http";
3
+ import { extname, join, resolve, sep } from "node:path";
4
+ import { sendNodeResponse, toWebRequest } from "./node";
5
+ import { installGracefulShutdown } from "./shutdown";
6
+
7
+ const MIME_TYPES: Record<string, string> = {
8
+ ".js": "text/javascript; charset=utf-8",
9
+ ".mjs": "text/javascript; charset=utf-8",
10
+ ".css": "text/css; charset=utf-8",
11
+ ".html": "text/html; charset=utf-8",
12
+ ".json": "application/json; charset=utf-8",
13
+ ".map": "application/json; charset=utf-8",
14
+ ".ico": "image/x-icon",
15
+ ".png": "image/png",
16
+ ".jpg": "image/jpeg",
17
+ ".jpeg": "image/jpeg",
18
+ ".gif": "image/gif",
19
+ ".svg": "image/svg+xml",
20
+ ".webp": "image/webp",
21
+ ".avif": "image/avif",
22
+ ".woff": "font/woff",
23
+ ".woff2": "font/woff2",
24
+ ".ttf": "font/ttf",
25
+ ".txt": "text/plain; charset=utf-8",
26
+ ".wasm": "application/wasm",
27
+ };
28
+
29
+ export interface NodeServerOptions {
30
+ port?: number;
31
+ clientDir?: string;
32
+ /** Pasang handler SIGTERM/SIGINT (default true). */
33
+ handleSignals?: boolean;
34
+ /** Batas drain shutdown (default 10_000 ms). */
35
+ shutdownTimeoutMs?: number;
36
+ }
37
+
38
+ /**
39
+ * Serve produksi (M1 §19.2, §18): static dari `dist/client` + handler SSR.
40
+ * Berjalan di Bun dan Node (hanya API `node:*`).
41
+ */
42
+ export function startNodeServer(
43
+ handler: (request: Request) => Promise<Response>,
44
+ options: NodeServerOptions = {},
45
+ ): Server {
46
+ const clientDir = resolve(options.clientDir ?? join(process.cwd(), "dist/client"));
47
+ const port = options.port ?? Number(process.env["PORT"] ?? 3000);
48
+
49
+ const server = createServer((req, res) => {
50
+ void (async () => {
51
+ try {
52
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
53
+
54
+ if (req.method === "GET" || req.method === "HEAD") {
55
+ const staticFile = resolveStaticFile(clientDir, url.pathname);
56
+ if (staticFile !== null) {
57
+ const body = readFileSync(staticFile);
58
+ res.statusCode = 200;
59
+ res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
60
+ res.end(req.method === "HEAD" ? undefined : body);
61
+ return;
62
+ }
63
+ }
64
+
65
+ const response = await handler(await toWebRequest(req));
66
+ await sendNodeResponse(res, response);
67
+ } catch (error) {
68
+ console.error("[moons] server error:", error);
69
+ res.statusCode = 500;
70
+ res.setHeader("content-type", "text/plain; charset=utf-8");
71
+ res.end("Internal Server Error");
72
+ }
73
+ })();
74
+ });
75
+
76
+ server.listen(port, () => {
77
+ const address = server.address();
78
+ const boundPort = typeof address === "object" && address !== null ? address.port : port;
79
+ console.log(`[moons] server listening on http://localhost:${boundPort}`);
80
+ });
81
+
82
+ if (options.handleSignals !== false) {
83
+ const shutdown = installGracefulShutdown(
84
+ server,
85
+ options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs },
86
+ );
87
+ // `once`: sinyal kedua memakai perilaku default (proses langsung berhenti).
88
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
89
+ process.once("SIGINT", () => shutdown("SIGINT"));
90
+ }
91
+
92
+ return server;
93
+ }
94
+
95
+ /** Resolve file static dengan proteksi path traversal. */
96
+ export function resolveStaticFile(clientDir: string, pathname: string): string | null {
97
+ let decoded: string;
98
+ try {
99
+ decoded = decodeURIComponent(pathname);
100
+ } catch {
101
+ return null;
102
+ }
103
+
104
+ const candidate = resolve(clientDir, `.${decoded}`);
105
+ if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
106
+ return null;
107
+ }
108
+ if (!existsSync(candidate)) {
109
+ return null;
110
+ }
111
+ if (!statSync(candidate).isFile()) {
112
+ return null;
113
+ }
114
+ return candidate;
115
+ }
@@ -0,0 +1,61 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+
3
+ const MAX_BODY_BYTES = 32 * 1024 * 1024; // batas defensif transport; limit RPC §7.3 tetap berlaku
4
+
5
+ /** Konversi request Node → Web `Request` (body diteruskan untuk non-GET/HEAD, §7.3). */
6
+ export async function toWebRequest(req: IncomingMessage): Promise<Request> {
7
+ const host = req.headers.host ?? "localhost";
8
+ const url = `http://${host}${req.url ?? "/"}`;
9
+ const method = req.method ?? "GET";
10
+
11
+ const headers = new Headers();
12
+ for (const [key, value] of Object.entries(req.headers)) {
13
+ if (value === undefined || key === "content-length") {
14
+ // content-length tidak disalin; fetch menghitung ulang dari body.
15
+ continue;
16
+ }
17
+ if (Array.isArray(value)) {
18
+ for (const item of value) {
19
+ headers.append(key, item);
20
+ }
21
+ } else {
22
+ headers.set(key, value);
23
+ }
24
+ }
25
+
26
+ if (method === "GET" || method === "HEAD") {
27
+ return new Request(url, { method, headers });
28
+ }
29
+
30
+ const body = await readBody(req);
31
+ return new Request(url, { method, headers, body: new Uint8Array(body) });
32
+ }
33
+
34
+ function readBody(req: IncomingMessage): Promise<Buffer> {
35
+ return new Promise((resolvePromise, reject) => {
36
+ const chunks: Buffer[] = [];
37
+ let size = 0;
38
+
39
+ req.on("data", (chunk: Buffer) => {
40
+ size += chunk.length;
41
+ if (size > MAX_BODY_BYTES) {
42
+ req.destroy();
43
+ reject(new Error("request body melebihi batas transport"));
44
+ return;
45
+ }
46
+ chunks.push(chunk);
47
+ });
48
+ req.on("end", () => resolvePromise(Buffer.concat(chunks)));
49
+ req.on("error", (error) => reject(error));
50
+ });
51
+ }
52
+
53
+ /** Tulis Web `Response` ke `ServerResponse` Node. */
54
+ export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
55
+ res.statusCode = response.status;
56
+ response.headers.forEach((value, key) => {
57
+ res.setHeader(key, value);
58
+ });
59
+ const body = Buffer.from(await response.arrayBuffer());
60
+ res.end(body);
61
+ }
@@ -0,0 +1,77 @@
1
+ import { MoonsError } from "@moonsjs/core";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import type { ViteDevServer } from "vite";
5
+
6
+ export type Shell = (markup: string, payload: string, url: string) => string | Promise<string>;
7
+
8
+ export const ROOT_PLACEHOLDER = '<div id="root"></div>';
9
+
10
+ export const DEFAULT_TEMPLATE = [
11
+ "<!doctype html>",
12
+ '<html lang="id">',
13
+ "<head>",
14
+ ' <meta charset="utf-8" />',
15
+ ' <meta name="viewport" content="width=device-width, initial-scale=1" />',
16
+ " <title>Moons</title>",
17
+ "</head>",
18
+ "<body>",
19
+ ` ${ROOT_PLACEHOLDER}`,
20
+ ' <script type="module" src="/src/client/main.tsx"></script>',
21
+ "</body>",
22
+ "</html>",
23
+ "",
24
+ ].join("\n");
25
+
26
+ /** Inject markup SSR + payload devalue ke shell HTML (PRD §8.2, §17). */
27
+ export function injectShell(template: string, markup: string, payload: string): string {
28
+ if (!template.includes(ROOT_PLACEHOLDER)) {
29
+ throw new MoonsError({
30
+ code: "E_INTERNAL",
31
+ message: `shell HTML tidak memuat \`${ROOT_PLACEHOLDER}\``,
32
+ details: "index.html aplikasi wajib memuat elemen root tersebut agar SSR bisa meng-inject markup.",
33
+ suggestions: [
34
+ `Tambahkan ${ROOT_PLACEHOLDER} ke index.html`,
35
+ "Atau hapus index.html untuk memakai shell bawaan",
36
+ ],
37
+ });
38
+ }
39
+ const dataScript = `<script id="__moons_data" type="application/x-moons">${payload}</script>`;
40
+ return template.replace(ROOT_PLACEHOLDER, `<div id="root">${markup}</div>${dataScript}`);
41
+ }
42
+
43
+ /**
44
+ * Shell untuk dev: baca `index.html` dari disk setiap request (selalu segar)
45
+ * lalu lewatkan ke Vite `transformIndexHtml` (inject `/@vite/client`, dsb).
46
+ */
47
+ export function createDevShell(server: ViteDevServer, options: { templatePath: string }): Shell {
48
+ return async (markup, payload, url) => {
49
+ const template = existsSync(options.templatePath)
50
+ ? readFileSync(options.templatePath, "utf8")
51
+ : DEFAULT_TEMPLATE;
52
+ const html = injectShell(template, markup, payload);
53
+ return await server.transformIndexHtml(url, html);
54
+ };
55
+ }
56
+
57
+ /** Shell untuk produksi: baca `dist/client/index.html` sekali dari cwd. */
58
+ export function createFileShell(options: { clientDir?: string } = {}): Shell {
59
+ let cached: string | null = null;
60
+ return async (markup, payload) => {
61
+ if (cached === null) {
62
+ const clientDir = resolve(options.clientDir ?? process.cwd(), "dist/client");
63
+ const file = join(clientDir, "index.html");
64
+ if (!existsSync(file)) {
65
+ throw new MoonsError({
66
+ code: "E_INTERNAL",
67
+ file,
68
+ message: "`dist/client/index.html` tidak ditemukan",
69
+ details: "Build client harus dijalankan sebelum server produksi dinyalakan (PRD §18).",
70
+ suggestions: ["Jalankan `bun run build` lebih dulu"],
71
+ });
72
+ }
73
+ cached = readFileSync(file, "utf8");
74
+ }
75
+ return injectShell(cached, markup, payload);
76
+ };
77
+ }
@@ -0,0 +1,64 @@
1
+ import type { Server } from "node:http";
2
+
3
+ export interface ShutdownOptions {
4
+ /** Batas drain sebelum keluar paksa (default 10_000 ms). */
5
+ timeoutMs?: number;
6
+ log?: (message: string) => void;
7
+ /** Injectable untuk test; default `process.exit`. */
8
+ exit?: (code: number) => void;
9
+ }
10
+
11
+ /**
12
+ * Graceful shutdown (PRD §19.5): SIGTERM/SIGINT → stop menerima koneksi baru →
13
+ * drain request berjalan → exit 0. Melewati timeout → keluar paksa exit 1.
14
+ *
15
+ * Handler dipasang `once` oleh pemanggil, sehingga sinyal kedua memakai
16
+ * perilaku default (proses langsung berhenti).
17
+ */
18
+ export function installGracefulShutdown(
19
+ server: Server,
20
+ options: ShutdownOptions = {},
21
+ ): (signal: NodeJS.Signals) => void {
22
+ const timeoutMs = options.timeoutMs ?? 10_000;
23
+ const log = options.log ?? ((message: string) => console.log(message));
24
+ const exit = options.exit ?? ((code: number) => process.exit(code));
25
+
26
+ let shuttingDown = false;
27
+
28
+ return (signal: NodeJS.Signals): void => {
29
+ if (shuttingDown) {
30
+ return;
31
+ }
32
+ shuttingDown = true;
33
+ log(`[moons] menerima ${signal} — berhenti menerima koneksi, drain...`);
34
+
35
+ const timer = setTimeout(() => {
36
+ log(`[moons] drain melewati ${timeoutMs}ms — keluar paksa`);
37
+ const closeAll = (server as { closeAllConnections?: () => void }).closeAllConnections;
38
+ try {
39
+ closeAll?.call(server);
40
+ } catch {
41
+ // abaikan: keluar paksa tetap jalan
42
+ }
43
+ exit(1);
44
+ }, timeoutMs);
45
+ timer.unref?.();
46
+
47
+ try {
48
+ (server as { closeIdleConnections?: () => void }).closeIdleConnections?.();
49
+ } catch {
50
+ // abaikan: fallback timeout tetap ada
51
+ }
52
+
53
+ server.close((error?: Error) => {
54
+ clearTimeout(timer);
55
+ if (error !== undefined) {
56
+ log(`[moons] penutupan server gagal: ${error.message}`);
57
+ exit(1);
58
+ return;
59
+ }
60
+ log("[moons] shutdown selesai (drain penuh)");
61
+ exit(0);
62
+ });
63
+ };
64
+ }
@@ -0,0 +1,44 @@
1
+ import type { MoonsConfig, RouteDefinition, RouteModule } from "@moonsjs/core";
2
+ import { createRequestHandler } from "./handler";
3
+ import { DEFAULT_TEMPLATE, injectShell, type Shell } from "./shell";
4
+
5
+ export interface TestServerOptions {
6
+ routes: RouteDefinition[];
7
+ config: MoonsConfig;
8
+ routesDir: string;
9
+ loadRoute: Record<string, () => Promise<RouteModule>>;
10
+ /** Default: shell in-memory dari `DEFAULT_TEMPLATE` (tanpa file). */
11
+ shell?: Shell;
12
+ }
13
+
14
+ export interface TestServer {
15
+ handler: (request: Request) => Promise<Response>;
16
+ /** Dispatch request langsung ke handler — tanpa port/jaringan (§19.5). */
17
+ fetch(input: string, init?: RequestInit): Promise<Response>;
18
+ }
19
+
20
+ /**
21
+ * Utility test in-process (PRD §19.5): jalankan handler tanpa listen port.
22
+ * Dipakai test aplikasi user maupun test internal framework.
23
+ */
24
+ export function createTestServer(
25
+ options: TestServerOptions,
26
+ baseUrl = "http://localhost",
27
+ ): TestServer {
28
+ const shell: Shell =
29
+ options.shell ?? ((markup, payload) => injectShell(DEFAULT_TEMPLATE, markup, payload));
30
+
31
+ const handler = createRequestHandler({
32
+ routes: options.routes,
33
+ config: options.config,
34
+ routesDir: options.routesDir,
35
+ loadRoute: options.loadRoute,
36
+ shell,
37
+ });
38
+
39
+ return {
40
+ handler,
41
+ fetch: (input: string, init?: RequestInit) =>
42
+ handler(new Request(new URL(input, baseUrl), init)),
43
+ };
44
+ }