@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,19 +1,30 @@
1
1
  import {
2
2
  MoonsError,
3
3
  composeMiddleware,
4
+ findApiDirMiddlewares,
5
+ findDirMiddlewares,
6
+ findErrorBoundary,
7
+ isNotFoundSignal,
8
+ isRedirectSignal,
4
9
  resolveRoute,
5
10
  routeKeyFromFile,
6
11
  runLoaders,
12
+ type ApiMethod,
13
+ type ApiMiddlewareDefinition,
14
+ type ApiRouteDefinition,
15
+ type Middleware,
7
16
  type MoonsConfig,
8
17
  type ResolvedRoute,
9
18
  type RouteDefinition,
10
19
  type RouteModule,
11
20
  } from "@moonsjs/core";
12
21
  import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
22
+ import { API_METHODS, resolveApiRoute } from "@moonsjs/core";
13
23
  import { renderToMarkup } from "@moonsjs/react/ssr";
14
24
  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";
25
+ import { createRpcHandler, runWithContext, runWithRequestCache, runWithResponseCollector, takeResponseMutations } from "@moonsjs/server";
26
+ import { NOT_FOUND_ROUTE_KEY } from "../manifest";
27
+ import { scanPayloadForSecrets, type PayloadScanOptions } from "../payload-scan";
17
28
  import type { Shell } from "./shell";
18
29
 
19
30
  const RPC_PREFIX = "/_moons/fn/";
@@ -28,6 +39,25 @@ export interface RequestHandlerOptions {
28
39
  /** Peta import() modul route, key = routeKeyFromFile. */
29
40
  loadRoute: Record<string, () => Promise<RouteModule>>;
30
41
  shell: Shell;
42
+ /**
43
+ * Dipanggil setelah `config.context` per request (Addendum B.4: extender
44
+ * `extendContext` plugin di-merge ke ctx).
45
+ */
46
+ onContext?: (ctx: Ctx) => void;
47
+ /**
48
+ * A1: pemindaian payload SSR (dev) — peringatan sekali per label bila payload
49
+ * memuat canary/nilai env mirip-rahasia. Production TIDAK mengisi opsi ini
50
+ * (tanpa biaya runtime). Lihat `payload-scan.ts`.
51
+ */
52
+ payloadScan?: PayloadScanOptions;
53
+ /** Endpoint API `src/api` (Addendum D.1) — hasil scan plugin. */
54
+ apiRoutes?: ApiRouteDefinition[];
55
+ /** Peta import() modul API, key = route.path. */
56
+ loadApi?: Record<string, () => Promise<unknown>>;
57
+ /** Middleware direktori pohon `src/api` (Addendum D.3). */
58
+ apiMiddlewares?: ApiMiddlewareDefinition[];
59
+ /** Peta import() `_middleware.ts` pohon api, key = file path. */
60
+ loadApiMiddleware?: Record<string, () => Promise<unknown>>;
31
61
  }
32
62
 
33
63
  /**
@@ -36,13 +66,14 @@ export interface RequestHandlerOptions {
36
66
  */
37
67
  export function createRequestHandler(
38
68
  options: RequestHandlerOptions,
39
- ): (request: Request) => Promise<Response> {
40
- const { routes, config, routesDir, loadRoute, shell } = options;
69
+ ): (request: Request) => Promise<Response> { const { routes, config, routesDir, loadRoute, shell } = options;
41
70
  const rpc = createRpcHandler(
42
71
  config.security === undefined ? {} : { security: config.security },
43
72
  );
44
73
  const notFoundRoute = routes.find((route) => route.kind === "404");
45
74
  const errorRoute = routes.find((route) => route.kind === "error");
75
+ /** A1: dedupe peringatan payload (sekali per label). */
76
+ const warnedPayloadLabels = new Set<string>();
46
77
 
47
78
  async function renderRoute(
48
79
  resolved: ResolvedRoute,
@@ -63,6 +94,8 @@ export function createRequestHandler(
63
94
  const entry: ClientRouteEntry = { page: pageKey, layouts: keys.slice(0, -1) };
64
95
  // Addendum A.3: query string ikut ke payload agar server/client identik.
65
96
  const searchParams = Object.fromEntries(new URL(request.url).searchParams);
97
+ // Addendum B.1: eksekusi `meta()` page (data = hasil loader page itu sendiri).
98
+ const meta = await runRouteMeta(pageModule, data[pageKey], resolved.route.file);
66
99
 
67
100
  const markup = renderToMarkup(
68
101
  entry,
@@ -76,9 +109,104 @@ export function createRequestHandler(
76
109
  params: resolved.params,
77
110
  searchParams,
78
111
  data,
112
+ ...(meta === undefined ? {} : { meta }),
79
113
  });
80
114
 
81
- return htmlResponse(await shell(markup, payload, request.url), 200);
115
+ // A1: pindai payload SSR (dev saja — `payloadScan` tidak diisi production).
116
+ warnPayloadFindings(payload, options.payloadScan, warnedPayloadLabels);
117
+
118
+ return htmlResponse(await shell(markup, payload, request.url, meta), 200);
119
+ }
120
+
121
+ /** Addendum D.1: dispatch endpoint `src/api` (405/HEAD/OPTIONS + error). */
122
+ async function handleApiRequest(
123
+ route: ApiRouteDefinition,
124
+ params: Record<string, string>,
125
+ request: Request,
126
+ ctx: Ctx,
127
+ ): Promise<Response> {
128
+ const load = options.loadApi?.[route.path];
129
+ if (load === undefined) {
130
+ return apiErrorResponse(
131
+ new MoonsError({
132
+ code: "E_API_HANDLER_INVALID",
133
+ file: route.file,
134
+ message: `modul API \`${route.path}\` tidak bisa dimuat`,
135
+ details: "Peta `loadApi` tidak memuat route ini (build/dev tidak sinkron).",
136
+ suggestions: ["Jalankan ulang build / restart dev server"],
137
+ }),
138
+ route,
139
+ );
140
+ }
141
+
142
+ let mod: unknown;
143
+ try {
144
+ mod = await load();
145
+ } catch (error) {
146
+ return apiErrorResponse(error, route);
147
+ }
148
+ if (mod === null || typeof mod !== "object") {
149
+ return apiErrorResponse(
150
+ new MoonsError({
151
+ code: "E_API_HANDLER_INVALID",
152
+ file: route.file,
153
+ message: "modul API tidak mengekspor objek handler",
154
+ details: `Modul \`${route.path}\` harus mengekspor handler method (PRD Addendum D.1).`,
155
+ suggestions: ["Export `GET`/`POST`/… sebagai fungsi `(args) => Response`"],
156
+ }),
157
+ route,
158
+ );
159
+ }
160
+
161
+ const record = mod as Record<string, unknown>;
162
+ const methods: ApiMethod[] = API_METHODS.filter(
163
+ (method) => typeof record[method] === "function",
164
+ );
165
+ const requestMethod = request.method.toUpperCase();
166
+ type ApiHandlerFn = (args: {
167
+ req: Request;
168
+ params: Record<string, string>;
169
+ ctx: Ctx;
170
+ }) => Response | Promise<Response>;
171
+ let handler = record[requestMethod] as ApiHandlerFn | undefined;
172
+ let stripBody = false;
173
+
174
+ // D.7 #2: HEAD otomatis dari GET; OPTIONS otomatis → 204 + Allow.
175
+ if (typeof handler !== "function" && requestMethod === "HEAD" && typeof record["GET"] === "function") {
176
+ handler = record["GET"] as ApiHandlerFn;
177
+ stripBody = true;
178
+ }
179
+ if (typeof handler !== "function") {
180
+ if (requestMethod === "OPTIONS") {
181
+ return new Response(null, { status: 204, headers: { allow: methods.join(", ") } });
182
+ }
183
+ return new Response("Method Not Allowed", {
184
+ status: 405,
185
+ headers: { allow: methods.join(", ") },
186
+ });
187
+ }
188
+
189
+ try {
190
+ const result = await handler({ req: request, params, ctx });
191
+ if (!(result instanceof Response)) {
192
+ return apiErrorResponse(
193
+ new MoonsError({
194
+ code: "E_API_HANDLER_INVALID",
195
+ file: route.file,
196
+ message: `handler \`${requestMethod}\` tidak mengembalikan \`Response\``,
197
+ details: "Handler API wajib mengembalikan `new Response(...)` (PRD Addendum D.1).",
198
+ suggestions: ["Kembalikan `new Response(body, { status, headers })`"],
199
+ }),
200
+ route,
201
+ );
202
+ }
203
+ if (stripBody) {
204
+ return new Response(null, { status: result.status, headers: result.headers });
205
+ }
206
+ return result;
207
+ } catch (error) {
208
+ return apiErrorResponse(error, route);
209
+ }
82
210
  }
83
211
 
84
212
  /** §11.2: halaman `_404.tsx` kustom (standalone), fallback bawaan. */
@@ -110,20 +238,28 @@ export function createRequestHandler(
110
238
  }
111
239
 
112
240
  /**
113
- * §11.2: halaman `_error.tsx` kustom dengan `RouteErrorProvider`.
114
- * Production tidak men-serialisasi error apa pun (§16.6); dev mengirim aslinya.
241
+ * §11.2/D.3: `_error.tsx` kustom (boundary terdekat dari route; fallback
242
+ * root). Production tidak men-serialisasi error apa pun (§16.6).
115
243
  */
116
- async function routeErrorResponse(error: unknown, request: Request): Promise<Response> {
244
+ async function routeErrorResponse(
245
+ error: unknown,
246
+ request: Request,
247
+ resolved?: ResolvedRoute,
248
+ ): Promise<Response> {
117
249
  console.error("[moons] route error:", error);
118
250
 
119
- if (errorRoute !== undefined) {
251
+ const boundary =
252
+ (resolved === undefined ? null : findErrorBoundary(resolved.route, routes)) ??
253
+ errorRoute ??
254
+ null;
255
+ if (boundary !== null) {
120
256
  try {
121
- const key = routeKeyFromFile(routesDir, errorRoute.file);
257
+ const key = routeKeyFromFile(routesDir, boundary.file);
122
258
  const [module] = await loadModules([key], loadRoute);
123
259
  const isProduction = process.env["NODE_ENV"] === "production";
124
260
  const serialized = isProduction ? undefined : serializeError(error);
125
261
  const payload = encodeSsrPayload({
126
- key: ERROR_ROUTE_KEY,
262
+ key,
127
263
  params: {},
128
264
  data: {},
129
265
  ...(serialized === undefined ? {} : { error: serialized }),
@@ -145,6 +281,68 @@ export function createRequestHandler(
145
281
  return internalErrorResponse(error, { log: false });
146
282
  }
147
283
 
284
+ /** Muat middleware direktori (D.3) → `Middleware[]`; error bentuk jelas. */
285
+ async function loadDirMiddlewares(chain: ReadonlyArray<RouteDefinition>): Promise<Middleware[]> {
286
+ const result: Middleware[] = [];
287
+ for (const definition of chain) {
288
+ const key = routeKeyFromFile(routesDir, definition.file);
289
+ const load = options.loadRoute?.[key];
290
+ if (load === undefined) {
291
+ throw new MoonsError({
292
+ code: "E_MIDDLEWARE_INVALID",
293
+ file: definition.file,
294
+ message: "middleware direktori tidak bisa dimuat",
295
+ details: "Peta `loadRoute` tidak memuat `_middleware.ts` ini.",
296
+ suggestions: ["Restart dev server / jalankan ulang build"],
297
+ });
298
+ }
299
+ const module = (await load()) as { default?: unknown };
300
+ const handler = module.default;
301
+ if (typeof handler !== "function") {
302
+ throw new MoonsError({
303
+ code: "E_MIDDLEWARE_INVALID",
304
+ file: definition.file,
305
+ message: "`_middleware.ts` tidak meng-export default fungsi",
306
+ details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
307
+ suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"],
308
+ });
309
+ }
310
+ result.push(handler as Middleware);
311
+ }
312
+ return result;
313
+ }
314
+
315
+ /** Muat middleware pohon `src/api` (D.3). */
316
+ async function loadApiDirMiddlewares(
317
+ chain: ReadonlyArray<ApiMiddlewareDefinition>,
318
+ ): Promise<Middleware[]> {
319
+ const result: Middleware[] = [];
320
+ for (const definition of chain) {
321
+ const load = options.loadApiMiddleware?.[definition.file];
322
+ if (load === undefined) {
323
+ throw new MoonsError({
324
+ code: "E_MIDDLEWARE_INVALID",
325
+ file: definition.file,
326
+ message: "middleware API tidak bisa dimuat",
327
+ details: "Peta `loadApiMiddleware` tidak memuat `_middleware.ts` ini.",
328
+ suggestions: ["Restart dev server / jalankan ulang build"],
329
+ });
330
+ }
331
+ const module = (await load()) as { default?: unknown };
332
+ if (typeof module.default !== "function") {
333
+ throw new MoonsError({
334
+ code: "E_MIDDLEWARE_INVALID",
335
+ file: definition.file,
336
+ message: "`_middleware.ts` tidak meng-export default fungsi",
337
+ details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
338
+ suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"],
339
+ });
340
+ }
341
+ result.push(module.default as Middleware);
342
+ }
343
+ return result;
344
+ }
345
+
148
346
  return async function handler(request: Request): Promise<Response> {
149
347
  try {
150
348
  const url = new URL(request.url);
@@ -159,29 +357,112 @@ export function createRequestHandler(
159
357
  // batas runtime ini. Tanpa cast, tsc app user yang memuat `@moonsjs/vite/server`
160
358
  // (mis. `createTestServer` §19.5) melihat `unknown` vs `Ctx` global app.
161
359
  const ctx = (await config.context(request)) as Ctx;
360
+ options.onContext?.(ctx);
361
+ // Addendum D.5: tandai request prerender build-time (aditif §13.1).
362
+ if (request.headers.get("x-moons-prerender") === "1") {
363
+ (ctx as Record<string, unknown>)["__prerender"] = true;
364
+ }
162
365
 
163
366
  const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
164
367
  if (url.pathname.startsWith(RPC_PREFIX)) {
165
368
  return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
166
369
  }
370
+ // Addendum D.1: endpoint HTTP mentah `src/api` (semua method).
371
+ if (options.apiRoutes !== undefined && options.apiRoutes.length > 0) {
372
+ const apiMatch = resolveApiRoute(url.pathname, options.apiRoutes);
373
+ if (apiMatch !== null) {
374
+ // Addendum D.3: middleware direktori pohon api.
375
+ const apiChain = findApiDirMiddlewares(
376
+ apiMatch.route.file,
377
+ options.apiMiddlewares ?? [],
378
+ );
379
+ if (apiChain.length === 0) {
380
+ return await handleApiRequest(apiMatch.route, apiMatch.params, request, ctx);
381
+ }
382
+ const middlewares = await loadApiDirMiddlewares(apiChain);
383
+ return await composeMiddleware(
384
+ middlewares,
385
+ ctx,
386
+ () => handleApiRequest(apiMatch.route, apiMatch.params, request, ctx),
387
+ { code: "E_MIDDLEWARE_INVALID", label: "middleware API" },
388
+ )(request);
389
+ }
390
+ }
167
391
  const resolved = resolveRoute(url.pathname, routes);
168
392
  if (resolved === null) {
169
393
  return await notFoundResponse(request);
170
394
  }
171
395
  try {
172
- return await renderRoute(resolved, request, ctx);
396
+ // Addendum D.3: middleware direktori (root→leaf) untuk page.
397
+ const dirChain = findDirMiddlewares(resolved.route, routes);
398
+ if (dirChain.length === 0) {
399
+ return await renderRoute(resolved, request, ctx);
400
+ }
401
+ const middlewares = await loadDirMiddlewares(dirChain);
402
+ return await composeMiddleware(
403
+ middlewares,
404
+ ctx,
405
+ () => renderRoute(resolved, request, ctx),
406
+ { code: "E_MIDDLEWARE_INVALID", label: "middleware direktori" },
407
+ )(request);
173
408
  } catch (error) {
174
- return await routeErrorResponse(error, request);
409
+ // Addendum B.2: sinyal routing dari loader/meta.
410
+ if (isRedirectSignal(error)) {
411
+ return new Response(null, {
412
+ status: error.status,
413
+ headers: { location: new URL(error.to, request.url).toString() },
414
+ });
415
+ }
416
+ if (isNotFoundSignal(error)) {
417
+ return await notFoundResponse(request);
418
+ }
419
+ return await routeErrorResponse(error, request, resolved);
175
420
  }
176
421
  });
177
422
 
178
- return await runWithContext(ctx, () => respond(request));
423
+ // Addendum D.2: kolektor mutasi respons aktif selama request; helper
424
+ // (setHeader/setCookie) menulis ke store, diterapkan ke Response akhir —
425
+ // termasuk jalur error (mutasi yang sudah dipush tetap dihormati).
426
+ return await runWithResponseCollector(async () => {
427
+ let response: Response;
428
+ try {
429
+ response = await runWithRequestCache(() => runWithContext(ctx, () => respond(request)));
430
+ } catch (error) {
431
+ response = internalErrorResponse(error);
432
+ }
433
+ return applyResponseMutations(response);
434
+ });
179
435
  } catch (error) {
180
436
  return internalErrorResponse(error);
181
437
  }
182
438
  };
183
439
  }
184
440
 
441
+ /** Terapkan mutasi `setHeader`/`appendHeader`/`setCookie` ke respons (D.2). */
442
+ function applyResponseMutations(response: Response): Response {
443
+ const mutations = takeResponseMutations();
444
+ if (mutations.length === 0) {
445
+ return response;
446
+ }
447
+ const headers = new Headers(response.headers);
448
+ for (const mutation of mutations) {
449
+ if (mutation.kind === "header") {
450
+ if (mutation.append) {
451
+ headers.append(mutation.name, mutation.value);
452
+ } else {
453
+ headers.set(mutation.name, mutation.value);
454
+ }
455
+ } else {
456
+ headers.append("set-cookie", mutation.value);
457
+ }
458
+ }
459
+ return new Response(response.body, {
460
+ status: response.status,
461
+ statusText: response.statusText,
462
+ headers,
463
+ });
464
+ }
465
+
185
466
  async function loadModules(
186
467
  keys: string[],
187
468
  loadRoute: Record<string, () => Promise<RouteModule>>,
@@ -202,6 +483,59 @@ async function loadModules(
202
483
  );
203
484
  }
204
485
 
486
+ async function runRouteMeta(
487
+ pageModule: RouteModule,
488
+ pageData: unknown,
489
+ file: string,
490
+ ): Promise<Record<string, unknown> | undefined> {
491
+ const meta = (pageModule as { meta?: unknown }).meta;
492
+ if (meta === undefined) {
493
+ return undefined;
494
+ }
495
+ if (typeof meta !== "function") {
496
+ throw new MoonsError({
497
+ code: "E_INTERNAL",
498
+ file,
499
+ message: "export `meta` harus berupa fungsi `({ data }) => RouteMeta`",
500
+ details: "Bentuk `meta` mengikuti PRD §11.2 (Addendum B.1).",
501
+ suggestions: [
502
+ "Ubah menjadi `export const meta = ({ data }) => ({ title: \"...\" })`",
503
+ "Atau hapus export `meta`",
504
+ ],
505
+ });
506
+ }
507
+ const result = await (meta as (args: { data: unknown }) => unknown)({ data: pageData });
508
+ if (result === null || typeof result !== "object" || Array.isArray(result)) {
509
+ throw new MoonsError({
510
+ code: "E_INTERNAL",
511
+ file,
512
+ message: "`meta()` harus mengembalikan objek RouteMeta",
513
+ details: "Nilai yang dikembalikan dipakai untuk <title>/description/og (Addendum B.1).",
514
+ suggestions: ["Kembalikan objek, mis. `({ title: \"Halaman\" })`"],
515
+ });
516
+ }
517
+ return result as Record<string, unknown>;
518
+ }
519
+
520
+ /**
521
+ * Error endpoint API (Addendum D.1): detail hanya di non-production (§16.6).
522
+ */
523
+ function apiErrorResponse(error: unknown, route: ApiRouteDefinition): Response {
524
+ const isProduction = process.env["NODE_ENV"] === "production";
525
+ if (error instanceof MoonsError) {
526
+ console.error(`[moons] API ${route.path}: ${error.format()}`);
527
+ return new Response(isProduction ? "Terjadi kesalahan internal." : error.format(), {
528
+ status: 500,
529
+ headers: { "content-type": "text/plain; charset=utf-8" },
530
+ });
531
+ }
532
+ console.error(`[moons] API ${route.path}:`, error);
533
+ return new Response(isProduction ? "Terjadi kesalahan internal." : String(error), {
534
+ status: 500,
535
+ headers: { "content-type": "text/plain; charset=utf-8" },
536
+ });
537
+ }
538
+
205
539
  function internalErrorResponse(error: unknown, options: { log?: boolean } = {}): Response {
206
540
  if (options.log !== false) {
207
541
  console.error("[moons] request error:", error);
@@ -247,3 +581,28 @@ function escapeHtml(text: string): string {
247
581
  .replaceAll(">", "&gt;")
248
582
  .replaceAll('"', "&quot;");
249
583
  }
584
+
585
+ /**
586
+ * A1: peringatan dev untuk payload SSR yang memuat canary/nilai env
587
+ * mirip-rahasia (sekali per label). Tidak pernah aktif di production karena
588
+ * `payloadScan` hanya diisi runtime dev.
589
+ */
590
+ function warnPayloadFindings(
591
+ payload: string,
592
+ scan: PayloadScanOptions | undefined,
593
+ warned: Set<string>,
594
+ ): void {
595
+ if (scan === undefined) {
596
+ return;
597
+ }
598
+ for (const finding of scanPayloadForSecrets(payload, scan)) {
599
+ const label = finding.kind === "canary" ? "canary" : `env(${finding.label})`;
600
+ if (warned.has(label)) {
601
+ continue;
602
+ }
603
+ warned.add(label);
604
+ console.warn(
605
+ `[moons] peringatan dev: payload SSR memuat ${label} — jangan mengembalikan rahasia dari loader (PRD §2/§16.6).`,
606
+ );
607
+ }
608
+ }
@@ -10,3 +10,9 @@ export { createTestServer } from "./test-server";
10
10
  export type { TestServer, TestServerOptions } from "./test-server";
11
11
  export { sendNodeResponse, toWebRequest } from "./node";
12
12
  export { installDevMiddleware } from "./dev";
13
+ export { createPluginRuntime } from "./plugins";
14
+ export type { PluginRuntime, PluginRuntimeOptions } from "./plugins";
15
+ export { runPrerender } from "./prerender";
16
+ export type { PrerenderOptions, PrerenderResult } from "./prerender";
17
+ export type { PayloadScanOptions } from "../payload-scan";
18
+ export type { ApiHandlerArgs, ApiMethod, ApiRouteDefinition } from "@moonsjs/core";
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer, type Server } from "node:http";
3
3
  import { extname, join, resolve, sep } from "node:path";
4
4
  import { sendNodeResponse, toWebRequest } from "./node";
5
+ import type { PluginRuntime } from "./plugins";
5
6
  import { installGracefulShutdown } from "./shutdown";
6
7
 
7
8
  const MIME_TYPES: Record<string, string> = {
@@ -33,6 +34,8 @@ export interface NodeServerOptions {
33
34
  handleSignals?: boolean;
34
35
  /** Batas drain shutdown (default 10_000 ms). */
35
36
  shutdownTimeoutMs?: number;
37
+ /** Lifecycle plugin (Addendum B.4) — onStart/onReady/onShutdown. */
38
+ plugins?: PluginRuntime;
36
39
  }
37
40
 
38
41
  /**
@@ -73,17 +76,28 @@ export function startNodeServer(
73
76
  })();
74
77
  });
75
78
 
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
- });
79
+ // Addendum B.4: onStart sebelum listen, onReady setelah listen.
80
+ const listenAfterStart = (): void => {
81
+ server.listen(port, () => {
82
+ const address = server.address();
83
+ const boundPort = typeof address === "object" && address !== null ? address.port : port;
84
+ console.log(`[moons] server listening on http://localhost:${boundPort}`);
85
+ void options.plugins?.ready();
86
+ });
87
+ };
88
+ if (options.plugins !== undefined) {
89
+ void options.plugins.start().then(listenAfterStart);
90
+ } else {
91
+ listenAfterStart();
92
+ }
81
93
 
82
94
  if (options.handleSignals !== false) {
83
- const shutdown = installGracefulShutdown(
84
- server,
85
- options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs },
86
- );
95
+ const shutdown = installGracefulShutdown(server, {
96
+ ...(options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs }),
97
+ ...(options.plugins === undefined
98
+ ? {}
99
+ : { onShutdown: () => options.plugins?.shutdown() }),
100
+ });
87
101
  // `once`: sinyal kedua memakai perilaku default (proses langsung berhenti).
88
102
  process.once("SIGTERM", () => shutdown("SIGTERM"));
89
103
  process.once("SIGINT", () => shutdown("SIGINT"));
@@ -92,7 +106,7 @@ export function startNodeServer(
92
106
  return server;
93
107
  }
94
108
 
95
- /** Resolve file static dengan proteksi path traversal. */
109
+ /** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
96
110
  export function resolveStaticFile(clientDir: string, pathname: string): string | null {
97
111
  let decoded: string;
98
112
  try {
@@ -101,15 +115,55 @@ export function resolveStaticFile(clientDir: string, pathname: string): string |
101
115
  return null;
102
116
  }
103
117
 
104
- const candidate = resolve(clientDir, `.${decoded}`);
105
- if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
106
- return null;
118
+ // 1) File persis (aset: js/css/png/…, atau HTML yang diminta eksplisit).
119
+ const exact = safeWithin(clientDir, `.${decoded}`);
120
+ if (exact !== null && isFile(exact)) {
121
+ return exact;
107
122
  }
108
- if (!existsSync(candidate)) {
109
- return null;
123
+
124
+ // 2) Hasil prerender: hanya path yang tercatat di manifest yang dilayani
125
+ // statis — bukan shell `index.html` biasa (yang berarti bypass SSR).
126
+ const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
127
+ const manifest = readPrerenderManifest(clientDir);
128
+ if (manifest.has(normalized)) {
129
+ const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
130
+ const candidate = safeWithin(clientDir, `./${target}`);
131
+ if (candidate !== null && isFile(candidate)) {
132
+ return candidate;
133
+ }
110
134
  }
111
- if (!statSync(candidate).isFile()) {
135
+
136
+ return null;
137
+ }
138
+
139
+ function safeWithin(clientDir: string, relative: string): string | null {
140
+ const candidate = resolve(clientDir, relative);
141
+ if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
112
142
  return null;
113
143
  }
114
144
  return candidate;
115
145
  }
146
+
147
+ function isFile(path: string): boolean {
148
+ return existsSync(path) && statSync(path).isFile();
149
+ }
150
+
151
+ let prerenderManifestCache: { file: string; mtimeMs: number; paths: Set<string> } | null = null;
152
+
153
+ function readPrerenderManifest(clientDir: string): Set<string> {
154
+ const file = join(clientDir, "_moons_prerender.json");
155
+ try {
156
+ const mtimeMs = statSync(file).mtimeMs;
157
+ if (prerenderManifestCache !== null && prerenderManifestCache.file === file && prerenderManifestCache.mtimeMs === mtimeMs) {
158
+ return prerenderManifestCache.paths;
159
+ }
160
+ const json = JSON.parse(readFileSync(file, "utf8")) as { paths?: unknown };
161
+ const paths = new Set(
162
+ Array.isArray(json.paths) ? json.paths.filter((path): path is string => typeof path === "string") : [],
163
+ );
164
+ prerenderManifestCache = { file, mtimeMs, paths };
165
+ return paths;
166
+ } catch {
167
+ return new Set();
168
+ }
169
+ }
@@ -54,8 +54,25 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
54
54
  export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
55
55
  res.statusCode = response.status;
56
56
  response.headers.forEach((value, key) => {
57
+ // `set-cookie` ditangani terpisah: forEach menggabungkan nilai ganda
58
+ // menjadi satu string (cookie kedua hilang — D.2).
59
+ if (key === "set-cookie") {
60
+ return;
61
+ }
57
62
  res.setHeader(key, value);
58
63
  });
64
+ const getSetCookie = (response.headers as { getSetCookie?: () => string[] }).getSetCookie;
65
+ if (typeof getSetCookie === "function") {
66
+ const cookies = getSetCookie.call(response.headers);
67
+ if (cookies.length > 0) {
68
+ res.setHeader("set-cookie", cookies);
69
+ }
70
+ } else {
71
+ const single = response.headers.get("set-cookie");
72
+ if (single !== null) {
73
+ res.setHeader("set-cookie", single);
74
+ }
75
+ }
59
76
  const body = Buffer.from(await response.arrayBuffer());
60
77
  res.end(body);
61
78
  }