@moonsjs/vite 0.1.4 → 0.1.6

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 (52) hide show
  1. package/dist/api.d.ts +8 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/{index-33p1r65s.js → index-1fnt137k.js} +50 -5
  4. package/dist/index-1fnt137k.js.map +14 -0
  5. package/dist/{index-3ec9vpek.js → index-zs92aaz4.js} +232 -24
  6. package/dist/index-zs92aaz4.js.map +11 -0
  7. package/dist/index.js +216 -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 +3 -0
  15. package/dist/server/dev.d.ts.map +1 -1
  16. package/dist/server/handler.d.ts +16 -1
  17. package/dist/server/handler.d.ts.map +1 -1
  18. package/dist/server/index.d.ts +4 -0
  19. package/dist/server/index.d.ts.map +1 -1
  20. package/dist/server/index.js +561 -17
  21. package/dist/server/index.js.map +9 -6
  22. package/dist/server/isr.d.ts +25 -0
  23. package/dist/server/isr.d.ts.map +1 -0
  24. package/dist/server/node-server.d.ts +7 -1
  25. package/dist/server/node-server.d.ts.map +1 -1
  26. package/dist/server/node.d.ts.map +1 -1
  27. package/dist/server/prerender.d.ts +28 -0
  28. package/dist/server/prerender.d.ts.map +1 -0
  29. package/dist/server-only.d.ts +57 -0
  30. package/dist/server-only.d.ts.map +1 -0
  31. package/dist/transform.d.ts +9 -1
  32. package/dist/transform.d.ts.map +1 -1
  33. package/dist/transform.js +1 -1
  34. package/dist/virtual-modules.d.ts +1 -0
  35. package/dist/virtual-modules.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/api.ts +124 -0
  38. package/src/manifest.ts +42 -1
  39. package/src/payload-scan.ts +53 -0
  40. package/src/plugin.ts +137 -10
  41. package/src/server/dev.ts +14 -5
  42. package/src/server/handler.ts +317 -13
  43. package/src/server/index.ts +4 -0
  44. package/src/server/isr.ts +81 -0
  45. package/src/server/node-server.ts +120 -7
  46. package/src/server/node.ts +17 -0
  47. package/src/server/prerender.ts +256 -0
  48. package/src/server-only.ts +220 -0
  49. package/src/transform.ts +138 -8
  50. package/src/virtual-modules.ts +1 -0
  51. package/dist/index-33p1r65s.js.map +0 -14
  52. package/dist/index-3ec9vpek.js.map +0 -10
@@ -1,21 +1,30 @@
1
1
  import {
2
2
  MoonsError,
3
3
  composeMiddleware,
4
+ findApiDirMiddlewares,
5
+ findDirMiddlewares,
6
+ findErrorBoundary,
4
7
  isNotFoundSignal,
5
8
  isRedirectSignal,
6
9
  resolveRoute,
7
10
  routeKeyFromFile,
8
11
  runLoaders,
12
+ type ApiMethod,
13
+ type ApiMiddlewareDefinition,
14
+ type ApiRouteDefinition,
15
+ type Middleware,
9
16
  type MoonsConfig,
10
17
  type ResolvedRoute,
11
18
  type RouteDefinition,
12
19
  type RouteModule,
13
20
  } from "@moonsjs/core";
14
21
  import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
22
+ import { API_METHODS, resolveApiRoute } from "@moonsjs/core";
15
23
  import { renderToMarkup } from "@moonsjs/react/ssr";
16
24
  import type { ClientRouteEntry, RouteModuleShape } from "@moonsjs/react/internal";
17
- import { createRpcHandler, runWithContext, runWithRequestCache } from "@moonsjs/server";
18
- 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";
19
28
  import type { Shell } from "./shell";
20
29
 
21
30
  const RPC_PREFIX = "/_moons/fn/";
@@ -35,6 +44,20 @@ export interface RequestHandlerOptions {
35
44
  * `extendContext` plugin di-merge ke ctx).
36
45
  */
37
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>>;
38
61
  }
39
62
 
40
63
  /**
@@ -43,13 +66,14 @@ export interface RequestHandlerOptions {
43
66
  */
44
67
  export function createRequestHandler(
45
68
  options: RequestHandlerOptions,
46
- ): (request: Request) => Promise<Response> {
47
- const { routes, config, routesDir, loadRoute, shell } = options;
69
+ ): (request: Request) => Promise<Response> { const { routes, config, routesDir, loadRoute, shell } = options;
48
70
  const rpc = createRpcHandler(
49
71
  config.security === undefined ? {} : { security: config.security },
50
72
  );
51
73
  const notFoundRoute = routes.find((route) => route.kind === "404");
52
74
  const errorRoute = routes.find((route) => route.kind === "error");
75
+ /** A1: dedupe peringatan payload (sekali per label). */
76
+ const warnedPayloadLabels = new Set<string>();
53
77
 
54
78
  async function renderRoute(
55
79
  resolved: ResolvedRoute,
@@ -88,9 +112,103 @@ export function createRequestHandler(
88
112
  ...(meta === undefined ? {} : { meta }),
89
113
  });
90
114
 
115
+ // A1: pindai payload SSR (dev saja — `payloadScan` tidak diisi production).
116
+ warnPayloadFindings(payload, options.payloadScan, warnedPayloadLabels);
117
+
91
118
  return htmlResponse(await shell(markup, payload, request.url, meta), 200);
92
119
  }
93
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
+ }
210
+ }
211
+
94
212
  /** §11.2: halaman `_404.tsx` kustom (standalone), fallback bawaan. */
95
213
  async function notFoundResponse(request: Request): Promise<Response> {
96
214
  if (notFoundRoute !== undefined) {
@@ -120,20 +238,28 @@ export function createRequestHandler(
120
238
  }
121
239
 
122
240
  /**
123
- * §11.2: halaman `_error.tsx` kustom dengan `RouteErrorProvider`.
124
- * 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).
125
243
  */
126
- 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> {
127
249
  console.error("[moons] route error:", error);
128
250
 
129
- if (errorRoute !== undefined) {
251
+ const boundary =
252
+ (resolved === undefined ? null : findErrorBoundary(resolved.route, routes)) ??
253
+ errorRoute ??
254
+ null;
255
+ if (boundary !== null) {
130
256
  try {
131
- const key = routeKeyFromFile(routesDir, errorRoute.file);
257
+ const key = routeKeyFromFile(routesDir, boundary.file);
132
258
  const [module] = await loadModules([key], loadRoute);
133
259
  const isProduction = process.env["NODE_ENV"] === "production";
134
260
  const serialized = isProduction ? undefined : serializeError(error);
135
261
  const payload = encodeSsrPayload({
136
- key: ERROR_ROUTE_KEY,
262
+ key,
137
263
  params: {},
138
264
  data: {},
139
265
  ...(serialized === undefined ? {} : { error: serialized }),
@@ -155,6 +281,68 @@ export function createRequestHandler(
155
281
  return internalErrorResponse(error, { log: false });
156
282
  }
157
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
+
158
346
  return async function handler(request: Request): Promise<Response> {
159
347
  try {
160
348
  const url = new URL(request.url);
@@ -170,17 +358,53 @@ export function createRequestHandler(
170
358
  // (mis. `createTestServer` §19.5) melihat `unknown` vs `Ctx` global app.
171
359
  const ctx = (await config.context(request)) as Ctx;
172
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
+ }
173
365
 
174
366
  const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
175
367
  if (url.pathname.startsWith(RPC_PREFIX)) {
176
368
  return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
177
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
+ }
178
391
  const resolved = resolveRoute(url.pathname, routes);
179
392
  if (resolved === null) {
180
393
  return await notFoundResponse(request);
181
394
  }
182
395
  try {
183
- 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);
184
408
  } catch (error) {
185
409
  // Addendum B.2: sinyal routing dari loader/meta.
186
410
  if (isRedirectSignal(error)) {
@@ -192,17 +416,53 @@ export function createRequestHandler(
192
416
  if (isNotFoundSignal(error)) {
193
417
  return await notFoundResponse(request);
194
418
  }
195
- return await routeErrorResponse(error, request);
419
+ return await routeErrorResponse(error, request, resolved);
196
420
  }
197
421
  });
198
422
 
199
- return await runWithRequestCache(() => 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
+ });
200
435
  } catch (error) {
201
436
  return internalErrorResponse(error);
202
437
  }
203
438
  };
204
439
  }
205
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
+
206
466
  async function loadModules(
207
467
  keys: string[],
208
468
  loadRoute: Record<string, () => Promise<RouteModule>>,
@@ -257,6 +517,25 @@ async function runRouteMeta(
257
517
  return result as Record<string, unknown>;
258
518
  }
259
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
+
260
539
  function internalErrorResponse(error: unknown, options: { log?: boolean } = {}): Response {
261
540
  if (options.log !== false) {
262
541
  console.error("[moons] request error:", error);
@@ -302,3 +581,28 @@ function escapeHtml(text: string): string {
302
581
  .replaceAll(">", "&gt;")
303
582
  .replaceAll('"', "&quot;");
304
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
+ }
@@ -12,3 +12,7 @@ export { sendNodeResponse, toWebRequest } from "./node";
12
12
  export { installDevMiddleware } from "./dev";
13
13
  export { createPluginRuntime } from "./plugins";
14
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";
@@ -0,0 +1,81 @@
1
+ import { renameSync, writeFileSync } from "node:fs";
2
+ import { scanPayloadForSecrets } from "../payload-scan";
3
+
4
+ /**
5
+ * ISR / revalidate on-demand (PRD Addendum E.1): file prerender yang kedaluwarsa
6
+ * disajikan apa adanya (stale-while-revalidate) sambil diregenerasi via handler
7
+ * SSR in-process. Tulis atomik + guard kebocoran fail-closed.
8
+ */
9
+ export function shouldRevalidate(input: {
10
+ mtimeMs: number;
11
+ revalidateSeconds: number;
12
+ now?: number;
13
+ }): boolean {
14
+ const now = input.now ?? Date.now();
15
+ return now - input.mtimeMs >= input.revalidateSeconds * 1000;
16
+ }
17
+
18
+ export interface RevalidateOptions {
19
+ /** Path URL ternormalisasi (mis. "/blog/hello"). */
20
+ pathname: string;
21
+ /** File HTML yang akan ditimpa. */
22
+ file: string;
23
+ handler: (request: Request) => Promise<Response>;
24
+ canary?: string;
25
+ env?: Record<string, string | undefined>;
26
+ log?: (message: string) => void;
27
+ }
28
+
29
+ const inflight = new Map<string, Promise<void>>();
30
+
31
+ /** Regenerasi ter-dedupe per path (panggilan paralel berbagi promise). */
32
+ export function revalidatePrerendered(options: RevalidateOptions): Promise<void> {
33
+ const existing = inflight.get(options.pathname);
34
+ if (existing !== undefined) {
35
+ return existing;
36
+ }
37
+ const task = runRevalidate(options).finally(() => {
38
+ inflight.delete(options.pathname);
39
+ });
40
+ inflight.set(options.pathname, task);
41
+ return task;
42
+ }
43
+
44
+ async function runRevalidate(options: RevalidateOptions): Promise<void> {
45
+ const log = options.log ?? ((message: string) => console.log(message));
46
+ try {
47
+ const response = await options.handler(
48
+ new Request(`http://localhost${options.pathname}`, {
49
+ method: "GET",
50
+ headers: { "x-moons-prerender": "1" },
51
+ }),
52
+ );
53
+ if (response.status !== 200) {
54
+ log(`[moons] isr: lewati ${options.pathname} (status ${response.status})`);
55
+ return;
56
+ }
57
+ const html = await response.text();
58
+ const findings = scanPayloadForSecrets(html, {
59
+ ...(options.canary === undefined ? {} : { canary: options.canary }),
60
+ env: options.env ?? process.env,
61
+ });
62
+ if (findings.length > 0) {
63
+ const labels = findings
64
+ .map((finding) => (finding.kind === "canary" ? "canary" : `env(${finding.label})`))
65
+ .join(", ");
66
+ // Fail-closed: jangan menimpa file statis dengan konten bocor.
67
+ log(`[moons] isr: TIDAK menulis ${options.pathname} — memuat ${labels}`);
68
+ return;
69
+ }
70
+ const tmp = `${options.file}.tmp-${process.pid}`;
71
+ writeFileSync(tmp, html);
72
+ renameSync(tmp, options.file);
73
+ } catch (error) {
74
+ log(`[moons] isr: gagal regenerasi ${options.pathname}: ${String(error)}`);
75
+ }
76
+ }
77
+
78
+ /** Test helper: bersihkan dedupe in-flight. */
79
+ export function __resetIsrInflight(): void {
80
+ inflight.clear();
81
+ }
@@ -1,6 +1,7 @@
1
1
  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
+ import { revalidatePrerendered, shouldRevalidate } from "./isr";
4
5
  import { sendNodeResponse, toWebRequest } from "./node";
5
6
  import type { PluginRuntime } from "./plugins";
6
7
  import { installGracefulShutdown } from "./shutdown";
@@ -36,6 +37,8 @@ export interface NodeServerOptions {
36
37
  shutdownTimeoutMs?: number;
37
38
  /** Lifecycle plugin (Addendum B.4) — onStart/onReady/onShutdown. */
38
39
  plugins?: PluginRuntime;
40
+ /** Canary `leakTest.canary` untuk guard ISR (Addendum E.1). */
41
+ canary?: string;
39
42
  }
40
43
 
41
44
  /**
@@ -57,6 +60,7 @@ export function startNodeServer(
57
60
  if (req.method === "GET" || req.method === "HEAD") {
58
61
  const staticFile = resolveStaticFile(clientDir, url.pathname);
59
62
  if (staticFile !== null) {
63
+ scheduleRevalidation(staticFile, url.pathname, handler, options, clientDir);
60
64
  const body = readFileSync(staticFile);
61
65
  res.statusCode = 200;
62
66
  res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
@@ -106,7 +110,7 @@ export function startNodeServer(
106
110
  return server;
107
111
  }
108
112
 
109
- /** Resolve file static dengan proteksi path traversal. */
113
+ /** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
110
114
  export function resolveStaticFile(clientDir: string, pathname: string): string | null {
111
115
  let decoded: string;
112
116
  try {
@@ -115,15 +119,124 @@ export function resolveStaticFile(clientDir: string, pathname: string): string |
115
119
  return null;
116
120
  }
117
121
 
118
- const candidate = resolve(clientDir, `.${decoded}`);
119
- if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
120
- return null;
122
+ // 1) File persis (aset: js/css/png/…, atau HTML yang diminta eksplisit).
123
+ const exact = safeWithin(clientDir, `.${decoded}`);
124
+ if (exact !== null && isFile(exact)) {
125
+ return exact;
121
126
  }
122
- if (!existsSync(candidate)) {
123
- return null;
127
+
128
+ // 2) Hasil prerender: hanya path yang tercatat di manifest yang dilayani
129
+ // statis — bukan shell `index.html` biasa (yang berarti bypass SSR).
130
+ const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
131
+ const manifest = readPrerenderManifest(clientDir);
132
+ if (manifest.paths.has(normalized)) {
133
+ const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
134
+ const candidate = safeWithin(clientDir, `./${target}`);
135
+ if (candidate !== null && isFile(candidate)) {
136
+ return candidate;
137
+ }
124
138
  }
125
- if (!statSync(candidate).isFile()) {
139
+
140
+ return null;
141
+ }
142
+
143
+ function safeWithin(clientDir: string, relative: string): string | null {
144
+ const candidate = resolve(clientDir, relative);
145
+ if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
126
146
  return null;
127
147
  }
128
148
  return candidate;
129
149
  }
150
+
151
+ function isFile(path: string): boolean {
152
+ return existsSync(path) && statSync(path).isFile();
153
+ }
154
+
155
+ export interface PrerenderManifestInfo {
156
+ paths: Set<string>;
157
+ revalidate: Map<string, number>;
158
+ }
159
+
160
+ let prerenderManifestCache:
161
+ | { file: string; mtimeMs: number; info: PrerenderManifestInfo }
162
+ | null = null;
163
+
164
+ function readPrerenderManifest(clientDir: string): PrerenderManifestInfo {
165
+ const file = join(clientDir, "_moons_prerender.json");
166
+ const empty: PrerenderManifestInfo = { paths: new Set(), revalidate: new Map() };
167
+ try {
168
+ const mtimeMs = statSync(file).mtimeMs;
169
+ if (
170
+ prerenderManifestCache !== null &&
171
+ prerenderManifestCache.file === file &&
172
+ prerenderManifestCache.mtimeMs === mtimeMs
173
+ ) {
174
+ return prerenderManifestCache.info;
175
+ }
176
+ const json = JSON.parse(readFileSync(file, "utf8")) as {
177
+ paths?: unknown;
178
+ version?: unknown;
179
+ entries?: unknown;
180
+ };
181
+ const paths = new Set<string>();
182
+ const revalidate = new Map<string, number>();
183
+ if (Array.isArray(json.paths)) {
184
+ // Manifest v1 (D.5) — tanpa revalidate.
185
+ for (const path of json.paths) {
186
+ if (typeof path === "string") {
187
+ paths.add(path);
188
+ }
189
+ }
190
+ }
191
+ if (Array.isArray(json.entries)) {
192
+ // Manifest v2 (E.1).
193
+ for (const entry of json.entries as Array<Record<string, unknown>>) {
194
+ if (entry === null || typeof entry !== "object" || typeof entry["path"] !== "string") {
195
+ continue;
196
+ }
197
+ const path = entry["path"] as string;
198
+ paths.add(path);
199
+ const seconds = entry["revalidate"];
200
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
201
+ revalidate.set(path, seconds);
202
+ }
203
+ }
204
+ }
205
+ const info = { paths, revalidate };
206
+ prerenderManifestCache = { file, mtimeMs, info };
207
+ return info;
208
+ } catch {
209
+ return empty;
210
+ }
211
+ }
212
+
213
+ /** ISR: jika file prerender kedaluwarsa, regenerasi background (SWR). */
214
+ function scheduleRevalidation(
215
+ staticFile: string,
216
+ pathname: string,
217
+ handler: (request: Request) => Promise<Response>,
218
+ options: NodeServerOptions,
219
+ clientDir: string,
220
+ ): void {
221
+ const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
222
+ const seconds = readPrerenderManifest(clientDir).revalidate.get(normalized);
223
+ if (seconds === undefined || seconds < 0) {
224
+ return;
225
+ }
226
+ let mtimeMs: number;
227
+ try {
228
+ mtimeMs = statSync(staticFile).mtimeMs;
229
+ } catch {
230
+ return;
231
+ }
232
+ if (!shouldRevalidate({ mtimeMs, revalidateSeconds: seconds })) {
233
+ return;
234
+ }
235
+ void revalidatePrerendered({
236
+ pathname: normalized,
237
+ file: staticFile,
238
+ handler,
239
+ ...(options.canary === undefined ? {} : { canary: options.canary }),
240
+ });
241
+ }
242
+