@moonsjs/vite 0.1.3 → 0.1.5

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 (60) hide show
  1. package/dist/api.d.ts +8 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/{index-1sm9pvvd.js → index-1fnt137k.js} +106 -18
  4. package/dist/index-1fnt137k.js.map +14 -0
  5. package/dist/{index-3ec9vpek.js → index-e7qz0epf.js} +232 -24
  6. package/dist/index-e7qz0epf.js.map +11 -0
  7. package/dist/index.js +220 -19
  8. package/dist/index.js.map +5 -4
  9. package/dist/manifest.d.ts +6 -1
  10. package/dist/manifest.d.ts.map +1 -1
  11. package/dist/payload-scan.d.ts +18 -0
  12. package/dist/payload-scan.d.ts.map +1 -0
  13. package/dist/plugin.d.ts.map +1 -1
  14. package/dist/server/dev.d.ts +4 -0
  15. package/dist/server/dev.d.ts.map +1 -1
  16. package/dist/server/handler.d.ts +21 -1
  17. package/dist/server/handler.d.ts.map +1 -1
  18. package/dist/server/index.d.ts +6 -0
  19. package/dist/server/index.d.ts.map +1 -1
  20. package/dist/server/index.js +584 -28
  21. package/dist/server/index.js.map +10 -7
  22. package/dist/server/node-server.d.ts +4 -1
  23. package/dist/server/node-server.d.ts.map +1 -1
  24. package/dist/server/node.d.ts.map +1 -1
  25. package/dist/server/plugins.d.ts +26 -0
  26. package/dist/server/plugins.d.ts.map +1 -0
  27. package/dist/server/prerender.d.ts +28 -0
  28. package/dist/server/prerender.d.ts.map +1 -0
  29. package/dist/server/shell.d.ts +3 -3
  30. package/dist/server/shell.d.ts.map +1 -1
  31. package/dist/server/shutdown.d.ts +6 -0
  32. package/dist/server/shutdown.d.ts.map +1 -1
  33. package/dist/server/test-server.d.ts.map +1 -1
  34. package/dist/server-only.d.ts +57 -0
  35. package/dist/server-only.d.ts.map +1 -0
  36. package/dist/transform.d.ts +9 -1
  37. package/dist/transform.d.ts.map +1 -1
  38. package/dist/transform.js +1 -1
  39. package/dist/virtual-modules.d.ts +1 -0
  40. package/dist/virtual-modules.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/api.ts +124 -0
  43. package/src/manifest.ts +42 -1
  44. package/src/payload-scan.ts +53 -0
  45. package/src/plugin.ts +137 -10
  46. package/src/server/dev.ts +48 -12
  47. package/src/server/handler.ts +373 -14
  48. package/src/server/index.ts +6 -0
  49. package/src/server/node-server.ts +70 -16
  50. package/src/server/node.ts +17 -0
  51. package/src/server/plugins.ts +85 -0
  52. package/src/server/prerender.ts +230 -0
  53. package/src/server/shell.ts +62 -8
  54. package/src/server/shutdown.ts +19 -2
  55. package/src/server/test-server.ts +2 -1
  56. package/src/server-only.ts +220 -0
  57. package/src/transform.ts +138 -8
  58. package/src/virtual-modules.ts +1 -0
  59. package/dist/index-1sm9pvvd.js.map +0 -14
  60. package/dist/index-3ec9vpek.js.map +0 -10
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  NOT_FOUND_ROUTE_KEY,
3
- ERROR_ROUTE_KEY,
4
3
  ROOT_PLACEHOLDER2,
5
4
  DEFAULT_TEMPLATE2,
6
5
  injectShell2,
@@ -9,19 +8,50 @@ import {
9
8
  toWebRequest2,
10
9
  sendNodeResponse2,
11
10
  installDevMiddleware2
12
- } from "../index-1sm9pvvd.js";
11
+ } from "../index-1fnt137k.js";
13
12
 
14
13
  // src/server/handler.ts
15
14
  import {
16
15
  MoonsError,
17
16
  composeMiddleware,
17
+ findApiDirMiddlewares,
18
+ findDirMiddlewares,
19
+ findErrorBoundary,
20
+ isNotFoundSignal,
21
+ isRedirectSignal,
18
22
  resolveRoute,
19
23
  routeKeyFromFile,
20
24
  runLoaders
21
25
  } from "@moonsjs/core";
22
26
  import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
27
+ import { API_METHODS, resolveApiRoute } from "@moonsjs/core";
23
28
  import { renderToMarkup } from "@moonsjs/react/ssr";
24
- import { createRpcHandler, runWithContext } from "@moonsjs/server";
29
+ import { createRpcHandler, runWithContext, runWithRequestCache, runWithResponseCollector, takeResponseMutations } from "@moonsjs/server";
30
+
31
+ // src/payload-scan.ts
32
+ var SECRET_KEY_PATTERN = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|AUTH|API_KEY|ACCESS_KEY|SIGNING)/i;
33
+ var MIN_SECRET_LENGTH = 12;
34
+ function scanPayloadForSecrets(payload, options = {}) {
35
+ const findings = [];
36
+ const canary = options.canary;
37
+ if (canary !== undefined && canary.length > 0 && payload.includes(canary)) {
38
+ findings.push({ kind: "canary", label: "canary" });
39
+ }
40
+ for (const [key, value] of Object.entries(options.env ?? {})) {
41
+ if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
42
+ continue;
43
+ }
44
+ if (!SECRET_KEY_PATTERN.test(key) || /PUBLIC/i.test(key)) {
45
+ continue;
46
+ }
47
+ if (payload.includes(value)) {
48
+ findings.push({ kind: "env", label: key });
49
+ }
50
+ }
51
+ return findings;
52
+ }
53
+
54
+ // src/server/handler.ts
25
55
  var RPC_PREFIX = "/_moons/fn/";
26
56
  var HEALTH_PATH = "/_moons/health";
27
57
  function createRequestHandler(options) {
@@ -29,6 +59,7 @@ function createRequestHandler(options) {
29
59
  const rpc = createRpcHandler(config.security === undefined ? {} : { security: config.security });
30
60
  const notFoundRoute = routes.find((route) => route.kind === "404");
31
61
  const errorRoute = routes.find((route) => route.kind === "error");
62
+ const warnedPayloadLabels = new Set;
32
63
  async function renderRoute(resolved, request, ctx) {
33
64
  const entries = [...resolved.layouts, resolved.route];
34
65
  const keys = entries.map((entry) => routeKeyFromFile(routesDir, entry.file));
@@ -38,14 +69,80 @@ function createRequestHandler(options) {
38
69
  const pageModule = modules[modules.length - 1];
39
70
  const entry = { page: pageKey, layouts: keys.slice(0, -1) };
40
71
  const searchParams = Object.fromEntries(new URL(request.url).searchParams);
72
+ const meta = await runRouteMeta(pageModule, data[pageKey], resolved.route.file);
41
73
  const markup = renderToMarkup(entry, { page: pageModule, layouts: modules.slice(0, -1) }, resolved.params, data, searchParams);
42
74
  const payload = encodeSsrPayload({
43
75
  key: pageKey,
44
76
  params: resolved.params,
45
77
  searchParams,
46
- data
78
+ data,
79
+ ...meta === undefined ? {} : { meta }
47
80
  });
48
- return htmlResponse(await shell(markup, payload, request.url), 200);
81
+ warnPayloadFindings(payload, options.payloadScan, warnedPayloadLabels);
82
+ return htmlResponse(await shell(markup, payload, request.url, meta), 200);
83
+ }
84
+ async function handleApiRequest(route, params, request, ctx) {
85
+ const load = options.loadApi?.[route.path];
86
+ if (load === undefined) {
87
+ return apiErrorResponse(new MoonsError({
88
+ code: "E_API_HANDLER_INVALID",
89
+ file: route.file,
90
+ message: `modul API \`${route.path}\` tidak bisa dimuat`,
91
+ details: "Peta `loadApi` tidak memuat route ini (build/dev tidak sinkron).",
92
+ suggestions: ["Jalankan ulang build / restart dev server"]
93
+ }), route);
94
+ }
95
+ let mod;
96
+ try {
97
+ mod = await load();
98
+ } catch (error) {
99
+ return apiErrorResponse(error, route);
100
+ }
101
+ if (mod === null || typeof mod !== "object") {
102
+ return apiErrorResponse(new MoonsError({
103
+ code: "E_API_HANDLER_INVALID",
104
+ file: route.file,
105
+ message: "modul API tidak mengekspor objek handler",
106
+ details: `Modul \`${route.path}\` harus mengekspor handler method (PRD Addendum D.1).`,
107
+ suggestions: ["Export `GET`/`POST`/… sebagai fungsi `(args) => Response`"]
108
+ }), route);
109
+ }
110
+ const record = mod;
111
+ const methods = API_METHODS.filter((method) => typeof record[method] === "function");
112
+ const requestMethod = request.method.toUpperCase();
113
+ let handler = record[requestMethod];
114
+ let stripBody = false;
115
+ if (typeof handler !== "function" && requestMethod === "HEAD" && typeof record["GET"] === "function") {
116
+ handler = record["GET"];
117
+ stripBody = true;
118
+ }
119
+ if (typeof handler !== "function") {
120
+ if (requestMethod === "OPTIONS") {
121
+ return new Response(null, { status: 204, headers: { allow: methods.join(", ") } });
122
+ }
123
+ return new Response("Method Not Allowed", {
124
+ status: 405,
125
+ headers: { allow: methods.join(", ") }
126
+ });
127
+ }
128
+ try {
129
+ const result = await handler({ req: request, params, ctx });
130
+ if (!(result instanceof Response)) {
131
+ return apiErrorResponse(new MoonsError({
132
+ code: "E_API_HANDLER_INVALID",
133
+ file: route.file,
134
+ message: `handler \`${requestMethod}\` tidak mengembalikan \`Response\``,
135
+ details: "Handler API wajib mengembalikan `new Response(...)` (PRD Addendum D.1).",
136
+ suggestions: ["Kembalikan `new Response(body, { status, headers })`"]
137
+ }), route);
138
+ }
139
+ if (stripBody) {
140
+ return new Response(null, { status: result.status, headers: result.headers });
141
+ }
142
+ return result;
143
+ } catch (error) {
144
+ return apiErrorResponse(error, route);
145
+ }
49
146
  }
50
147
  async function notFoundResponse(request) {
51
148
  if (notFoundRoute !== undefined) {
@@ -63,16 +160,17 @@ function createRequestHandler(options) {
63
160
  const html = await shell("<h1>404</h1><p>Halaman tidak ditemukan.</p>", payload, request.url);
64
161
  return htmlResponse(html, 404);
65
162
  }
66
- async function routeErrorResponse(error, request) {
163
+ async function routeErrorResponse(error, request, resolved) {
67
164
  console.error("[moons] route error:", error);
68
- if (errorRoute !== undefined) {
165
+ const boundary = (resolved === undefined ? null : findErrorBoundary(resolved.route, routes)) ?? errorRoute ?? null;
166
+ if (boundary !== null) {
69
167
  try {
70
- const key = routeKeyFromFile(routesDir, errorRoute.file);
168
+ const key = routeKeyFromFile(routesDir, boundary.file);
71
169
  const [module] = await loadModules([key], loadRoute);
72
170
  const isProduction = process.env["NODE_ENV"] === "production";
73
171
  const serialized = isProduction ? undefined : serializeError(error);
74
172
  const payload = encodeSsrPayload({
75
- key: ERROR_ROUTE_KEY,
173
+ key,
76
174
  params: {},
77
175
  data: {},
78
176
  ...serialized === undefined ? {} : { error: serialized }
@@ -85,6 +183,62 @@ function createRequestHandler(options) {
85
183
  }
86
184
  return internalErrorResponse(error, { log: false });
87
185
  }
186
+ async function loadDirMiddlewares(chain) {
187
+ const result = [];
188
+ for (const definition of chain) {
189
+ const key = routeKeyFromFile(routesDir, definition.file);
190
+ const load = options.loadRoute?.[key];
191
+ if (load === undefined) {
192
+ throw new MoonsError({
193
+ code: "E_MIDDLEWARE_INVALID",
194
+ file: definition.file,
195
+ message: "middleware direktori tidak bisa dimuat",
196
+ details: "Peta `loadRoute` tidak memuat `_middleware.ts` ini.",
197
+ suggestions: ["Restart dev server / jalankan ulang build"]
198
+ });
199
+ }
200
+ const module = await load();
201
+ const handler = module.default;
202
+ if (typeof handler !== "function") {
203
+ throw new MoonsError({
204
+ code: "E_MIDDLEWARE_INVALID",
205
+ file: definition.file,
206
+ message: "`_middleware.ts` tidak meng-export default fungsi",
207
+ details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
208
+ suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"]
209
+ });
210
+ }
211
+ result.push(handler);
212
+ }
213
+ return result;
214
+ }
215
+ async function loadApiDirMiddlewares(chain) {
216
+ const result = [];
217
+ for (const definition of chain) {
218
+ const load = options.loadApiMiddleware?.[definition.file];
219
+ if (load === undefined) {
220
+ throw new MoonsError({
221
+ code: "E_MIDDLEWARE_INVALID",
222
+ file: definition.file,
223
+ message: "middleware API tidak bisa dimuat",
224
+ details: "Peta `loadApiMiddleware` tidak memuat `_middleware.ts` ini.",
225
+ suggestions: ["Restart dev server / jalankan ulang build"]
226
+ });
227
+ }
228
+ const module = await load();
229
+ if (typeof module.default !== "function") {
230
+ throw new MoonsError({
231
+ code: "E_MIDDLEWARE_INVALID",
232
+ file: definition.file,
233
+ message: "`_middleware.ts` tidak meng-export default fungsi",
234
+ details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
235
+ suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"]
236
+ });
237
+ }
238
+ result.push(module.default);
239
+ }
240
+ return result;
241
+ }
88
242
  return async function handler(request) {
89
243
  try {
90
244
  const url = new URL(request.url);
@@ -92,26 +246,86 @@ function createRequestHandler(options) {
92
246
  return healthResponse(request.method);
93
247
  }
94
248
  const ctx = await config.context(request);
249
+ options.onContext?.(ctx);
250
+ if (request.headers.get("x-moons-prerender") === "1") {
251
+ ctx["__prerender"] = true;
252
+ }
95
253
  const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
96
254
  if (url.pathname.startsWith(RPC_PREFIX)) {
97
255
  return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
98
256
  }
257
+ if (options.apiRoutes !== undefined && options.apiRoutes.length > 0) {
258
+ const apiMatch = resolveApiRoute(url.pathname, options.apiRoutes);
259
+ if (apiMatch !== null) {
260
+ const apiChain = findApiDirMiddlewares(apiMatch.route.file, options.apiMiddlewares ?? []);
261
+ if (apiChain.length === 0) {
262
+ return await handleApiRequest(apiMatch.route, apiMatch.params, request, ctx);
263
+ }
264
+ const middlewares = await loadApiDirMiddlewares(apiChain);
265
+ return await composeMiddleware(middlewares, ctx, () => handleApiRequest(apiMatch.route, apiMatch.params, request, ctx), { code: "E_MIDDLEWARE_INVALID", label: "middleware API" })(request);
266
+ }
267
+ }
99
268
  const resolved = resolveRoute(url.pathname, routes);
100
269
  if (resolved === null) {
101
270
  return await notFoundResponse(request);
102
271
  }
103
272
  try {
104
- return await renderRoute(resolved, request, ctx);
273
+ const dirChain = findDirMiddlewares(resolved.route, routes);
274
+ if (dirChain.length === 0) {
275
+ return await renderRoute(resolved, request, ctx);
276
+ }
277
+ const middlewares = await loadDirMiddlewares(dirChain);
278
+ return await composeMiddleware(middlewares, ctx, () => renderRoute(resolved, request, ctx), { code: "E_MIDDLEWARE_INVALID", label: "middleware direktori" })(request);
279
+ } catch (error) {
280
+ if (isRedirectSignal(error)) {
281
+ return new Response(null, {
282
+ status: error.status,
283
+ headers: { location: new URL(error.to, request.url).toString() }
284
+ });
285
+ }
286
+ if (isNotFoundSignal(error)) {
287
+ return await notFoundResponse(request);
288
+ }
289
+ return await routeErrorResponse(error, request, resolved);
290
+ }
291
+ });
292
+ return await runWithResponseCollector(async () => {
293
+ let response;
294
+ try {
295
+ response = await runWithRequestCache(() => runWithContext(ctx, () => respond(request)));
105
296
  } catch (error) {
106
- return await routeErrorResponse(error, request);
297
+ response = internalErrorResponse(error);
107
298
  }
299
+ return applyResponseMutations(response);
108
300
  });
109
- return await runWithContext(ctx, () => respond(request));
110
301
  } catch (error) {
111
302
  return internalErrorResponse(error);
112
303
  }
113
304
  };
114
305
  }
306
+ function applyResponseMutations(response) {
307
+ const mutations = takeResponseMutations();
308
+ if (mutations.length === 0) {
309
+ return response;
310
+ }
311
+ const headers = new Headers(response.headers);
312
+ for (const mutation of mutations) {
313
+ if (mutation.kind === "header") {
314
+ if (mutation.append) {
315
+ headers.append(mutation.name, mutation.value);
316
+ } else {
317
+ headers.set(mutation.name, mutation.value);
318
+ }
319
+ } else {
320
+ headers.append("set-cookie", mutation.value);
321
+ }
322
+ }
323
+ return new Response(response.body, {
324
+ status: response.status,
325
+ statusText: response.statusText,
326
+ headers
327
+ });
328
+ }
115
329
  async function loadModules(keys, loadRoute) {
116
330
  return await Promise.all(keys.map(async (key) => {
117
331
  const load = loadRoute[key];
@@ -126,6 +340,50 @@ async function loadModules(keys, loadRoute) {
126
340
  return await load();
127
341
  }));
128
342
  }
343
+ async function runRouteMeta(pageModule, pageData, file) {
344
+ const meta = pageModule.meta;
345
+ if (meta === undefined) {
346
+ return;
347
+ }
348
+ if (typeof meta !== "function") {
349
+ throw new MoonsError({
350
+ code: "E_INTERNAL",
351
+ file,
352
+ message: "export `meta` harus berupa fungsi `({ data }) => RouteMeta`",
353
+ details: "Bentuk `meta` mengikuti PRD §11.2 (Addendum B.1).",
354
+ suggestions: [
355
+ 'Ubah menjadi `export const meta = ({ data }) => ({ title: "..." })`',
356
+ "Atau hapus export `meta`"
357
+ ]
358
+ });
359
+ }
360
+ const result = await meta({ data: pageData });
361
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
362
+ throw new MoonsError({
363
+ code: "E_INTERNAL",
364
+ file,
365
+ message: "`meta()` harus mengembalikan objek RouteMeta",
366
+ details: "Nilai yang dikembalikan dipakai untuk <title>/description/og (Addendum B.1).",
367
+ suggestions: ['Kembalikan objek, mis. `({ title: "Halaman" })`']
368
+ });
369
+ }
370
+ return result;
371
+ }
372
+ function apiErrorResponse(error, route) {
373
+ const isProduction = process.env["NODE_ENV"] === "production";
374
+ if (error instanceof MoonsError) {
375
+ console.error(`[moons] API ${route.path}: ${error.format()}`);
376
+ return new Response(isProduction ? "Terjadi kesalahan internal." : error.format(), {
377
+ status: 500,
378
+ headers: { "content-type": "text/plain; charset=utf-8" }
379
+ });
380
+ }
381
+ console.error(`[moons] API ${route.path}:`, error);
382
+ return new Response(isProduction ? "Terjadi kesalahan internal." : String(error), {
383
+ status: 500,
384
+ headers: { "content-type": "text/plain; charset=utf-8" }
385
+ });
386
+ }
129
387
  function internalErrorResponse(error, options = {}) {
130
388
  if (options.log !== false) {
131
389
  console.error("[moons] request error:", error);
@@ -155,6 +413,19 @@ function healthResponse(method) {
155
413
  function escapeHtml(text) {
156
414
  return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
157
415
  }
416
+ function warnPayloadFindings(payload, scan, warned) {
417
+ if (scan === undefined) {
418
+ return;
419
+ }
420
+ for (const finding of scanPayloadForSecrets(payload, scan)) {
421
+ const label = finding.kind === "canary" ? "canary" : `env(${finding.label})`;
422
+ if (warned.has(label)) {
423
+ continue;
424
+ }
425
+ warned.add(label);
426
+ console.warn(`[moons] peringatan dev: payload SSR memuat ${label} — jangan mengembalikan rahasia dari loader (PRD §2/§16.6).`);
427
+ }
428
+ }
158
429
  // src/server/node-server.ts
159
430
  import { existsSync, readFileSync, statSync } from "node:fs";
160
431
  import { createServer } from "node:http";
@@ -184,13 +455,22 @@ function installGracefulShutdown(server, options = {}) {
184
455
  try {
185
456
  server.closeIdleConnections?.();
186
457
  } catch {}
187
- server.close((error) => {
188
- clearTimeout(timer);
458
+ server.close(async (error) => {
189
459
  if (error !== undefined) {
460
+ clearTimeout(timer);
190
461
  log(`[moons] penutupan server gagal: ${error.message}`);
191
462
  exit(1);
192
463
  return;
193
464
  }
465
+ if (options.onShutdown !== undefined) {
466
+ timer.ref?.();
467
+ try {
468
+ await options.onShutdown();
469
+ } catch (hookError) {
470
+ log(`[moons] onShutdown gagal: ${String(hookError)}`);
471
+ }
472
+ }
473
+ clearTimeout(timer);
194
474
  log("[moons] shutdown selesai (drain penuh)");
195
475
  exit(0);
196
476
  });
@@ -246,13 +526,24 @@ function startNodeServer(handler, options = {}) {
246
526
  }
247
527
  })();
248
528
  });
249
- server.listen(port, () => {
250
- const address = server.address();
251
- const boundPort = typeof address === "object" && address !== null ? address.port : port;
252
- console.log(`[moons] server listening on http://localhost:${boundPort}`);
253
- });
529
+ const listenAfterStart = () => {
530
+ server.listen(port, () => {
531
+ const address = server.address();
532
+ const boundPort = typeof address === "object" && address !== null ? address.port : port;
533
+ console.log(`[moons] server listening on http://localhost:${boundPort}`);
534
+ options.plugins?.ready();
535
+ });
536
+ };
537
+ if (options.plugins !== undefined) {
538
+ options.plugins.start().then(listenAfterStart);
539
+ } else {
540
+ listenAfterStart();
541
+ }
254
542
  if (options.handleSignals !== false) {
255
- const shutdown = installGracefulShutdown(server, options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs });
543
+ const shutdown = installGracefulShutdown(server, {
544
+ ...options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs },
545
+ ...options.plugins === undefined ? {} : { onShutdown: () => options.plugins?.shutdown() }
546
+ });
256
547
  process.once("SIGTERM", () => shutdown("SIGTERM"));
257
548
  process.once("SIGINT", () => shutdown("SIGINT"));
258
549
  }
@@ -265,21 +556,50 @@ function resolveStaticFile(clientDir, pathname) {
265
556
  } catch {
266
557
  return null;
267
558
  }
268
- const candidate = resolve(clientDir, `.${decoded}`);
269
- if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
270
- return null;
559
+ const exact = safeWithin(clientDir, `.${decoded}`);
560
+ if (exact !== null && isFile(exact)) {
561
+ return exact;
271
562
  }
272
- if (!existsSync(candidate)) {
273
- return null;
563
+ const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
564
+ const manifest = readPrerenderManifest(clientDir);
565
+ if (manifest.has(normalized)) {
566
+ const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
567
+ const candidate = safeWithin(clientDir, `./${target}`);
568
+ if (candidate !== null && isFile(candidate)) {
569
+ return candidate;
570
+ }
274
571
  }
275
- if (!statSync(candidate).isFile()) {
572
+ return null;
573
+ }
574
+ function safeWithin(clientDir, relative) {
575
+ const candidate = resolve(clientDir, relative);
576
+ if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
276
577
  return null;
277
578
  }
278
579
  return candidate;
279
580
  }
581
+ function isFile(path) {
582
+ return existsSync(path) && statSync(path).isFile();
583
+ }
584
+ var prerenderManifestCache = null;
585
+ function readPrerenderManifest(clientDir) {
586
+ const file = join(clientDir, "_moons_prerender.json");
587
+ try {
588
+ const mtimeMs = statSync(file).mtimeMs;
589
+ if (prerenderManifestCache !== null && prerenderManifestCache.file === file && prerenderManifestCache.mtimeMs === mtimeMs) {
590
+ return prerenderManifestCache.paths;
591
+ }
592
+ const json = JSON.parse(readFileSync(file, "utf8"));
593
+ const paths = new Set(Array.isArray(json.paths) ? json.paths.filter((path) => typeof path === "string") : []);
594
+ prerenderManifestCache = { file, mtimeMs, paths };
595
+ return paths;
596
+ } catch {
597
+ return new Set;
598
+ }
599
+ }
280
600
  // src/server/test-server.ts
281
601
  function createTestServer(options, baseUrl = "http://localhost") {
282
- const shell = options.shell ?? ((markup, payload) => injectShell2(DEFAULT_TEMPLATE2, markup, payload));
602
+ const shell = options.shell ?? ((markup, payload, _url, meta) => injectShell2(DEFAULT_TEMPLATE2, markup, payload, meta));
283
603
  const handler = createRequestHandler({
284
604
  routes: options.routes,
285
605
  config: options.config,
@@ -292,20 +612,256 @@ function createTestServer(options, baseUrl = "http://localhost") {
292
612
  fetch: (input, init) => handler(new Request(new URL(input, baseUrl), init))
293
613
  };
294
614
  }
615
+ // src/server/plugins.ts
616
+ import { MOONS_VERSION } from "@moonsjs/core";
617
+ function createPluginRuntime(options) {
618
+ const log = options.log ?? ((message) => console.warn(message));
619
+ const plugins = options.config.plugins ?? [];
620
+ const state = { ready: false, version: MOONS_VERSION };
621
+ const app = state;
622
+ const extensions = [];
623
+ const register = (fn) => {
624
+ extensions.push(fn);
625
+ };
626
+ for (const plugin of plugins) {
627
+ for (const route of options.routes) {
628
+ const frozen = Object.freeze({
629
+ ...route,
630
+ params: [...route.params]
631
+ });
632
+ plugin.addRoute?.(frozen);
633
+ }
634
+ for (const middleware of options.middlewares) {
635
+ plugin.addMiddleware?.(middleware);
636
+ }
637
+ plugin.extendContext?.(register);
638
+ }
639
+ return {
640
+ app,
641
+ async start() {
642
+ for (const plugin of plugins) {
643
+ await plugin.onStart?.(app);
644
+ }
645
+ },
646
+ async ready() {
647
+ state.ready = true;
648
+ for (const plugin of plugins) {
649
+ await plugin.onReady?.(app);
650
+ }
651
+ },
652
+ async shutdown() {
653
+ for (const plugin of plugins) {
654
+ try {
655
+ await plugin.onShutdown?.(app);
656
+ } catch (error) {
657
+ log(`[moons] plugin \`${plugin.name}\` gagal saat onShutdown: ${String(error)}`);
658
+ }
659
+ }
660
+ },
661
+ applyExtensions(ctx) {
662
+ for (const extend of extensions) {
663
+ const result = extend(ctx);
664
+ if (result !== null && typeof result === "object" && !Array.isArray(result)) {
665
+ Object.assign(ctx, result);
666
+ }
667
+ }
668
+ }
669
+ };
670
+ }
671
+ // src/server/prerender.ts
672
+ import { MoonsError as MoonsError2 } from "@moonsjs/core";
673
+ import { mkdirSync, writeFileSync } from "node:fs";
674
+ import { dirname, join as join2, resolve as resolve2, sep as sep2 } from "node:path";
675
+ var PRERENDER_MANIFEST_FILE = "_moons_prerender.json";
676
+ async function runPrerender(options) {
677
+ const log = options.log ?? ((message) => console.log(message));
678
+ const env = options.env ?? process.env;
679
+ let written = 0;
680
+ let skipped = 0;
681
+ const writtenPaths = [];
682
+ const pages = options.routes.filter((route) => route.kind === "page");
683
+ for (const route of pages) {
684
+ const key = routeKeyOf(options.routesDir, route.file);
685
+ const load = options.loadRoute[key];
686
+ if (load === undefined) {
687
+ continue;
688
+ }
689
+ const module = await load();
690
+ const prerender = module.prerender;
691
+ if (prerender !== true) {
692
+ if (prerender !== undefined && prerender !== false) {
693
+ throw prerenderError(route, "`prerender` harus boolean (`true`/`false`)");
694
+ }
695
+ continue;
696
+ }
697
+ const pathnames = await resolvePathnames(route, module);
698
+ for (const pathname of pathnames) {
699
+ const response = await options.handler(new Request(`http://localhost${pathname}`, {
700
+ method: "GET",
701
+ headers: { "x-moons-prerender": "1" }
702
+ }));
703
+ if (response.status === 404) {
704
+ log(`[moons] prerender: lewati ${pathname} (notFound)`);
705
+ skipped += 1;
706
+ continue;
707
+ }
708
+ if (response.status >= 300 && response.status < 400) {
709
+ throw prerenderError(route, `\`redirect()\` tidak didukung saat prerender (${pathname})`);
710
+ }
711
+ if (response.status !== 200) {
712
+ throw prerenderError(route, `status ${response.status} saat merender ${pathname}`);
713
+ }
714
+ const html = await response.text();
715
+ const findings = scanPayloadForSecrets(html, {
716
+ ...options.canary === undefined ? {} : { canary: options.canary },
717
+ env
718
+ });
719
+ if (findings.length > 0) {
720
+ const labels = findings.map((finding) => finding.kind === "canary" ? "canary" : `env(${finding.label})`).join(", ");
721
+ throw new MoonsError2({
722
+ code: "E_PRERENDER_LEAK",
723
+ file: route.file,
724
+ message: `hasil prerender \`${pathname}\` memuat ${labels}`,
725
+ details: "HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali (PRD Addendum D.5).",
726
+ suggestions: [
727
+ "Jangan mengembalikan nilai env rahasia dari loader yang di-prerender",
728
+ "Matikan `prerender` untuk route ini atau pindahkan datanya ke serverFn request-time"
729
+ ]
730
+ });
731
+ }
732
+ const file = outputFile(options.clientDir, pathname);
733
+ mkdirSync(dirname(file), { recursive: true });
734
+ writeFileSync(file, html);
735
+ writtenPaths.push(pathname);
736
+ written += 1;
737
+ }
738
+ }
739
+ writeFileSync(join2(options.clientDir, PRERENDER_MANIFEST_FILE), `${JSON.stringify({ paths: writtenPaths }, null, 2)}
740
+ `);
741
+ log(`[moons] prerender selesai: ${written} file (${skipped} dilewati)`);
742
+ return { written, skipped };
743
+ }
744
+ function routeKeyOf(routesDir, file) {
745
+ const relative = file.startsWith(routesDir) ? file.slice(routesDir.length + 1) : file;
746
+ return relative.split("\\").join("/").replace(/\.(tsx|ts)$/, "");
747
+ }
748
+ async function resolvePathnames(route, module) {
749
+ const isDynamic = route.params.length > 0;
750
+ if (!isDynamic) {
751
+ return [route.path];
752
+ }
753
+ const paths = module.paths;
754
+ if (typeof paths !== "function") {
755
+ throw new MoonsError2({
756
+ code: "E_PRERENDER_NO_PATHS",
757
+ file: route.file,
758
+ message: "route dynamic ber-`prerender = true` tanpa `export const paths`",
759
+ details: "Prerender butuh daftar param untuk merender tiap path (PRD Addendum D.5).",
760
+ suggestions: [
761
+ 'Tambahkan `export const paths = () => [{ id: "…" }]`',
762
+ "Atau hapus `prerender` agar dirender SSR saat request"
763
+ ]
764
+ });
765
+ }
766
+ const raw = await paths();
767
+ if (!Array.isArray(raw)) {
768
+ throw prerenderError(route, "`paths()` harus mengembalikan array");
769
+ }
770
+ const expected = new Set(route.params);
771
+ const result = [];
772
+ for (const item of raw) {
773
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
774
+ throw prerenderError(route, "item `paths()` harus objek param string");
775
+ }
776
+ const record = item;
777
+ const keys = Object.keys(record);
778
+ for (const name of expected) {
779
+ const value = record[name];
780
+ if (typeof value !== "string" || value.length === 0) {
781
+ throw prerenderError(route, `param \`${name}\` wajib string non-kosong`);
782
+ }
783
+ }
784
+ for (const key of keys) {
785
+ if (!expected.has(key)) {
786
+ throw prerenderError(route, `param \`${key}\` tidak ada di pola route`);
787
+ }
788
+ }
789
+ result.push(buildPathname(route, record));
790
+ }
791
+ return result;
792
+ }
793
+ function buildPathname(route, params) {
794
+ const segments = route.path.split("/").filter((segment) => segment.length > 0);
795
+ const out = [];
796
+ let catchallName = null;
797
+ for (const segment of segments) {
798
+ if (segment === "*") {
799
+ catchallName = route.params[route.params.length - 1] ?? null;
800
+ const value = catchallName === null ? "" : params[catchallName] ?? "";
801
+ for (const part of value.split("/")) {
802
+ if (part.length === 0) {
803
+ continue;
804
+ }
805
+ out.push(encodeSegment(route, part));
806
+ }
807
+ continue;
808
+ }
809
+ if (segment.startsWith(":")) {
810
+ out.push(encodeSegment(route, params[segment.slice(1)] ?? ""));
811
+ continue;
812
+ }
813
+ out.push(segment);
814
+ }
815
+ return `/${out.join("/")}`;
816
+ }
817
+ function encodeSegment(route, value) {
818
+ if (value.length === 0 || /^\.+$/.test(value)) {
819
+ throw prerenderError(route, `nilai param \`${value}\` tidak valid untuk path file`);
820
+ }
821
+ return encodeURIComponent(value);
822
+ }
823
+ function outputFile(clientDir, pathname) {
824
+ const parts = pathname.split("/").filter((part) => part.length > 0);
825
+ const file = resolve2(join2(clientDir, ...parts, "index.html"));
826
+ const base = resolve2(clientDir);
827
+ if (file !== base && !file.startsWith(base + sep2)) {
828
+ throw new MoonsError2({
829
+ code: "E_PRERENDER_INVALID",
830
+ message: `path prerender keluar dari direktori client (\`${pathname}\`)`,
831
+ details: "Nama file harus berada di dalam `dist/client`.",
832
+ suggestions: ["Periksa nilai param `paths` — jangan memakai `..`"]
833
+ });
834
+ }
835
+ return file;
836
+ }
837
+ function prerenderError(route, message) {
838
+ return new MoonsError2({
839
+ code: "E_PRERENDER_INVALID",
840
+ file: route.file,
841
+ message,
842
+ details: "Kontrak prerender mengikuti PRD Addendum D.5.",
843
+ suggestions: [
844
+ "Perbaiki bentuk `paths`/param agar cocok dengan pola route",
845
+ "Jangan memakai `redirect()` pada route yang di-prerender"
846
+ ]
847
+ });
848
+ }
295
849
  export {
296
850
  DEFAULT_TEMPLATE2 as DEFAULT_TEMPLATE,
297
851
  ROOT_PLACEHOLDER2 as ROOT_PLACEHOLDER,
298
852
  createDevShell2 as createDevShell,
299
853
  createFileShell2 as createFileShell,
854
+ createPluginRuntime,
300
855
  createRequestHandler,
301
856
  createTestServer,
302
857
  injectShell2 as injectShell,
303
858
  installDevMiddleware2 as installDevMiddleware,
304
859
  installGracefulShutdown,
305
860
  resolveStaticFile,
861
+ runPrerender,
306
862
  sendNodeResponse2 as sendNodeResponse,
307
863
  startNodeServer,
308
864
  toWebRequest2 as toWebRequest
309
865
  };
310
866
 
311
- //# debugId=EE41489A37A603CE64756E2164756E21
867
+ //# debugId=C7C001D56BF6021C64756E2164756E21