@moonsjs/vite 0.1.5 → 0.1.7

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.
@@ -151,7 +151,7 @@ export function createRequestHandler(
151
151
  code: "E_API_HANDLER_INVALID",
152
152
  file: route.file,
153
153
  message: "modul API tidak mengekspor objek handler",
154
- details: `Modul \`${route.path}\` harus mengekspor handler method (PRD Addendum D.1).`,
154
+ details: `Modul \`${route.path}\` harus mengekspor handler method.`,
155
155
  suggestions: ["Export `GET`/`POST`/… sebagai fungsi `(args) => Response`"],
156
156
  }),
157
157
  route,
@@ -194,7 +194,7 @@ export function createRequestHandler(
194
194
  code: "E_API_HANDLER_INVALID",
195
195
  file: route.file,
196
196
  message: `handler \`${requestMethod}\` tidak mengembalikan \`Response\``,
197
- details: "Handler API wajib mengembalikan `new Response(...)` (PRD Addendum D.1).",
197
+ details: "Handler API wajib mengembalikan `new Response(...)`.",
198
198
  suggestions: ["Kembalikan `new Response(body, { status, headers })`"],
199
199
  }),
200
200
  route,
@@ -303,7 +303,7 @@ export function createRequestHandler(
303
303
  code: "E_MIDDLEWARE_INVALID",
304
304
  file: definition.file,
305
305
  message: "`_middleware.ts` tidak meng-export default fungsi",
306
- details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
306
+ details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>`.",
307
307
  suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"],
308
308
  });
309
309
  }
@@ -334,7 +334,7 @@ export function createRequestHandler(
334
334
  code: "E_MIDDLEWARE_INVALID",
335
335
  file: definition.file,
336
336
  message: "`_middleware.ts` tidak meng-export default fungsi",
337
- details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
337
+ details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>`.",
338
338
  suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"],
339
339
  });
340
340
  }
@@ -497,7 +497,7 @@ async function runRouteMeta(
497
497
  code: "E_INTERNAL",
498
498
  file,
499
499
  message: "export `meta` harus berupa fungsi `({ data }) => RouteMeta`",
500
- details: "Bentuk `meta` mengikuti PRD §11.2 (Addendum B.1).",
500
+ details: "Bentuk `meta` mengikuti ",
501
501
  suggestions: [
502
502
  "Ubah menjadi `export const meta = ({ data }) => ({ title: \"...\" })`",
503
503
  "Atau hapus export `meta`",
@@ -510,7 +510,7 @@ async function runRouteMeta(
510
510
  code: "E_INTERNAL",
511
511
  file,
512
512
  message: "`meta()` harus mengembalikan objek RouteMeta",
513
- details: "Nilai yang dikembalikan dipakai untuk <title>/description/og (Addendum B.1).",
513
+ details: "Nilai yang dikembalikan dipakai untuk <title>/description/og.",
514
514
  suggestions: ["Kembalikan objek, mis. `({ title: \"Halaman\" })`"],
515
515
  });
516
516
  }
@@ -602,7 +602,7 @@ function warnPayloadFindings(
602
602
  }
603
603
  warned.add(label);
604
604
  console.warn(
605
- `[moons] peringatan dev: payload SSR memuat ${label} — jangan mengembalikan rahasia dari loader (PRD §2/§16.6).`,
605
+ `[moons] peringatan dev: payload SSR memuat ${label} — jangan mengembalikan rahasia dari loader.`,
606
606
  );
607
607
  }
608
608
  }
@@ -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");
@@ -125,7 +129,7 @@ export function resolveStaticFile(clientDir: string, pathname: string): string |
125
129
  // statis — bukan shell `index.html` biasa (yang berarti bypass SSR).
126
130
  const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
127
131
  const manifest = readPrerenderManifest(clientDir);
128
- if (manifest.has(normalized)) {
132
+ if (manifest.paths.has(normalized)) {
129
133
  const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
130
134
  const candidate = safeWithin(clientDir, `./${target}`);
131
135
  if (candidate !== null && isFile(candidate)) {
@@ -148,22 +152,91 @@ function isFile(path: string): boolean {
148
152
  return existsSync(path) && statSync(path).isFile();
149
153
  }
150
154
 
151
- let prerenderManifestCache: { file: string; mtimeMs: number; paths: Set<string> } | null = null;
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;
152
163
 
153
- function readPrerenderManifest(clientDir: string): Set<string> {
164
+ function readPrerenderManifest(clientDir: string): PrerenderManifestInfo {
154
165
  const file = join(clientDir, "_moons_prerender.json");
166
+ const empty: PrerenderManifestInfo = { paths: new Set(), revalidate: new Map() };
155
167
  try {
156
168
  const mtimeMs = statSync(file).mtimeMs;
157
- if (prerenderManifestCache !== null && prerenderManifestCache.file === file && prerenderManifestCache.mtimeMs === mtimeMs) {
158
- return prerenderManifestCache.paths;
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
+ }
159
190
  }
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;
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;
166
208
  } catch {
167
- return new Set();
209
+ return empty;
168
210
  }
169
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
+
@@ -36,7 +36,7 @@ export async function runPrerender(options: PrerenderOptions): Promise<Prerender
36
36
  const env = options.env ?? process.env;
37
37
  let written = 0;
38
38
  let skipped = 0;
39
- const writtenPaths: string[] = [];
39
+ const entries: Array<{ path: string; revalidate?: number }> = [];
40
40
 
41
41
  const pages = options.routes.filter((route) => route.kind === "page");
42
42
  for (const route of pages) {
@@ -47,6 +47,28 @@ export async function runPrerender(options: PrerenderOptions): Promise<Prerender
47
47
  }
48
48
  const module = (await load()) as RouteModule;
49
49
  const prerender = module.prerender;
50
+ const revalidate = module.revalidate;
51
+
52
+ // Addendum E.1: `revalidate` hanya sah bersama `prerender`.
53
+ if (revalidate !== undefined) {
54
+ if (
55
+ typeof revalidate !== "number" ||
56
+ !Number.isFinite(revalidate) ||
57
+ revalidate < 0 ||
58
+ prerender !== true
59
+ ) {
60
+ throw new MoonsError({
61
+ code: "E_ISR_INVALID",
62
+ file: route.file,
63
+ message: "`revalidate` tidak valid atau dipakai tanpa `prerender = true`",
64
+ details: "ISR berbasis file: `revalidate` (detik, number >= 0) + `prerender`.",
65
+ suggestions: [
66
+ "Gunakan angka detik, mis. `export const revalidate = 60`",
67
+ "Tambahkan `export const prerender = true` pada page yang sama",
68
+ ],
69
+ });
70
+ }
71
+ }
50
72
 
51
73
  if (prerender !== true) {
52
74
  if (prerender !== undefined && prerender !== false) {
@@ -90,7 +112,7 @@ export async function runPrerender(options: PrerenderOptions): Promise<Prerender
90
112
  file: route.file,
91
113
  message: `hasil prerender \`${pathname}\` memuat ${labels}`,
92
114
  details:
93
- "HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali (PRD Addendum D.5).",
115
+ "HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali.",
94
116
  suggestions: [
95
117
  "Jangan mengembalikan nilai env rahasia dari loader yang di-prerender",
96
118
  "Matikan `prerender` untuk route ini atau pindahkan datanya ke serverFn request-time",
@@ -101,15 +123,19 @@ export async function runPrerender(options: PrerenderOptions): Promise<Prerender
101
123
  const file = outputFile(options.clientDir, pathname);
102
124
  mkdirSync(dirname(file), { recursive: true });
103
125
  writeFileSync(file, html);
104
- writtenPaths.push(pathname);
126
+ entries.push(
127
+ typeof revalidate === "number" && revalidate >= 0
128
+ ? { path: pathname, revalidate }
129
+ : { path: pathname },
130
+ );
105
131
  written += 1;
106
132
  }
107
133
  }
108
134
 
109
- // Manifest agar static server tahu path mana yang boleh dilayani statis.
135
+ // Manifest v2 (Addendum E.1): entries + revalidate; reader tetap menerima v1.
110
136
  writeFileSync(
111
137
  join(options.clientDir, PRERENDER_MANIFEST_FILE),
112
- `${JSON.stringify({ paths: writtenPaths }, null, 2)}\n`,
138
+ `${JSON.stringify({ version: 2, entries }, null, 2)}\n`,
113
139
  );
114
140
 
115
141
  log(`[moons] prerender selesai: ${written} file (${skipped} dilewati)`);
@@ -132,7 +158,7 @@ async function resolvePathnames(route: RouteDefinition, module: RouteModule): Pr
132
158
  code: "E_PRERENDER_NO_PATHS",
133
159
  file: route.file,
134
160
  message: "route dynamic ber-`prerender = true` tanpa `export const paths`",
135
- details: "Prerender butuh daftar param untuk merender tiap path (PRD Addendum D.5).",
161
+ details: "Prerender butuh daftar param untuk merender tiap path.",
136
162
  suggestions: [
137
163
  'Tambahkan `export const paths = () => [{ id: "…" }]`',
138
164
  "Atau hapus `prerender` agar dirender SSR saat request",
@@ -221,7 +247,7 @@ function prerenderError(route: RouteDefinition, message: string): MoonsError {
221
247
  code: "E_PRERENDER_INVALID",
222
248
  file: route.file,
223
249
  message,
224
- details: "Kontrak prerender mengikuti PRD Addendum D.5.",
250
+ details: "Kontrak prerender mengikuti.",
225
251
  suggestions: [
226
252
  "Perbaiki bentuk `paths`/param agar cocok dengan pola route",
227
253
  "Jangan memakai `redirect()` pada route yang di-prerender",
@@ -120,7 +120,7 @@ export function createFileShell(options: { clientDir?: string } = {}): Shell {
120
120
  code: "E_INTERNAL",
121
121
  file,
122
122
  message: "`dist/client/index.html` tidak ditemukan",
123
- details: "Build client harus dijalankan sebelum server produksi dinyalakan (PRD §18).",
123
+ details: "Build client harus dijalankan sebelum server produksi dinyalakan.",
124
124
  suggestions: ["Jalankan `bun run build` lebih dulu"],
125
125
  });
126
126
  }
@@ -211,7 +211,7 @@ export function serverOnlyImportError(input: {
211
211
  file: input.relativeFile,
212
212
  line: input.line,
213
213
  message: `mengimpor${input.dynamic === true ? " (dynamic)" : ""} \`${input.specifier}\` — paket server-only \`${input.packageName}\``,
214
- details: `${sourceLabel}\nPaket server-only tidak boleh masuk bundle client (PRD Addendum C).`,
214
+ details: `${sourceLabel}\nPaket server-only tidak boleh masuk bundle client.`,
215
215
  suggestions: [
216
216
  "Pindahkan operasi tersebut ke `*.server.ts` dan panggil lewat serverFn",
217
217
  "Atau ganti/hapus import tersebut di modul client",
package/src/transform.ts CHANGED
@@ -542,7 +542,7 @@ export function renderServerFnStub(
542
542
  annotation: fn.name,
543
543
  message: `schema ${kind} serverFn \`${fn.name}\` merujuk deklarasi lokal \`${reference}\``,
544
544
  details:
545
- `\`${reference}\` dibuang dari stub client (PRD §12.2/§12.3), sehingga nilainya \`undefined\` di browser (crash hydration).\n\n` +
545
+ `\`${reference}\` dibuang dari stub client, sehingga nilainya \`undefined\` di browser (crash hydration).\n\n` +
546
546
  "Sebelum (ditolak):\n" +
547
547
  ` const ${reference} = z.object({ … }); // deklarasi lokal di file yang sama\n` +
548
548
  ` export const ${fn.name} = serverFn({ ${kind}: ${reference}, … });\n\n` +
@@ -572,7 +572,7 @@ export function renderServerFnStub(
572
572
  code: "E_SERVER_IMPORT_IN_CLIENT",
573
573
  file: filename,
574
574
  message: `tidak bisa me-resolve \`${reExport.source}\` untuk re-export`,
575
- details: "Re-export dari `*.server.ts` harus bisa di-resolve statis (PRD §12.6).",
575
+ details: "Re-export dari `*.server.ts` harus bisa di-resolve statis.",
576
576
  suggestions: ["Perbaiki path re-export", "Hindari re-export dinamis dari file server"],
577
577
  });
578
578
  }
@@ -595,7 +595,7 @@ export function renderServerFnStub(
595
595
  code: "E_SERVER_IMPORT_IN_CLIENT",
596
596
  file: filename,
597
597
  message: `\`export *\` dari \`${specifier}\` tidak bisa di-resolve`,
598
- details: "`export *` dari `*.server.ts` wajib bisa di-expand statis (PRD §12.6).",
598
+ details: "`export *` dari `*.server.ts` wajib bisa di-expand statis.",
599
599
  suggestions: ["Perbaiki path re-export", "Ganti menjadi named re-export"],
600
600
  });
601
601
  }
@@ -707,7 +707,7 @@ export function nonContractServerFileError(file: string, projectRoot: string): M
707
707
  return new MoonsError({
708
708
  code: "E_SERVER_IMPORT_IN_CLIENT",
709
709
  file: toRelative(projectRoot, file),
710
- message: "file server memakai ekstensi di luar kontrak §12",
710
+ message: "file server memakai ekstensi di luar kontrak `*.server.ts`",
711
711
  details:
712
712
  "Transform zero-trust hanya menangani `*.server.ts`/`*.server.tsx`; file server dengan ekstensi lain tidak bisa di-stub dan tidak boleh masuk graph client.",
713
713
  suggestions: [
@@ -747,7 +747,7 @@ export async function checkClientModuleImports(
747
747
  line,
748
748
  message: `mengimpor \`${specifier}\` — \`${relApi}\` adalah modul API server-only`,
749
749
  details:
750
- "`src/api/**` hanya berjalan di server (endpoint HTTP) dan tidak pernah masuk bundle client (PRD Addendum D.1).",
750
+ "`src/api/**` hanya berjalan di server (endpoint HTTP) dan tidak pernah masuk bundle client.",
751
751
  suggestions: [
752
752
  "Pindahkan operasi tersebut ke `*.server.ts` dan panggil lewat serverFn",
753
753
  "Jangan mengimpor file `src/api` dari route/komponen client",
@@ -892,7 +892,7 @@ export async function checkClientModuleImports(
892
892
 
893
893
  if (statement.type === "ExportAllDeclaration") {
894
894
  if (resolvedTarget === null) {
895
- reject(line, `\`export *\` dari \`${targetRelative}\` tidak bisa di-resolve`, "Re-export dari `*.server.ts` wajib bisa di-resolve statis (PRD §12.6).");
895
+ reject(line, `\`export *\` dari \`${targetRelative}\` tidak bisa di-resolve`, "Re-export dari `*.server.ts` wajib bisa di-resolve statis.");
896
896
  }
897
897
  continue; // aman: target menjadi stub
898
898
  }
@@ -908,7 +908,7 @@ export async function checkClientModuleImports(
908
908
  const name = ((item["local"] as AstNode | undefined)?.["name"] as string) ?? "";
909
909
  const kind = (item["importKind"] as string | undefined) ?? (statement["importKind"] as string | undefined);
910
910
  if (kind === "type") {
911
- reject(line, `mengimpor type \`${name}\` dari ${targetRelative}`, "Import dari file server tidak diizinkan di modul client (PRD §12.4).");
911
+ reject(line, `mengimpor type \`${name}\` dari ${targetRelative}`, "Import dari file server tidak diizinkan di modul client.");
912
912
  }
913
913
  if (name.length > 0 && !(await isAllowedServerFnImport(resolvedTarget, specifier, name))) {
914
914
  reject(
@@ -1029,7 +1029,7 @@ export async function checkClientModuleImports(
1029
1029
 
1030
1030
  const targetRelative =
1031
1031
  resolvedTarget === null ? request.text : toRelative(projectRoot, resolvedTarget);
1032
- reject(request.node.loc?.start.line ?? 1, `mengimpor (dynamic) \`${request.text}\` dari ${targetRelative}`, "Client tidak boleh melakukan dynamic import ke `*.server.ts` (PRD §12.7).");
1032
+ reject(request.node.loc?.start.line ?? 1, `mengimpor (dynamic) \`${request.text}\` dari ${targetRelative}`, "Client tidak boleh melakukan dynamic import ke `*.server.ts`.");
1033
1033
  }
1034
1034
 
1035
1035
  // `import.meta.glob` yang mencakup `*.server.ts` (§12.7) — audit adversarial M6.
@@ -1060,7 +1060,7 @@ export async function checkClientModuleImports(
1060
1060
  file: relativeFile,
1061
1061
  line: node.loc?.start.line ?? 1,
1062
1062
  message: `mengimpor (glob) \`${pattern}\` di modul client`,
1063
- details: "`import.meta.glob` yang mencakup `*.server.ts` dilarang di client (PRD §12.7).",
1063
+ details: "`import.meta.glob` yang mencakup `*.server.ts` dilarang di client.",
1064
1064
  suggestions: [
1065
1065
  "Import statis `serverFn` dari `*.server.ts` (hanya itu yang boleh menyeberang), atau",
1066
1066
  "Pindahkan kebutuhan data ke dalam server function",
@@ -1196,7 +1196,7 @@ export function checkRouteModuleExports(context: RouteExportCheckContext): void
1196
1196
  ? ["default", "loader"]
1197
1197
  : base === "_error.tsx" || base === "_404.tsx"
1198
1198
  ? ["default"]
1199
- : ["default", "loader", "meta", "prerender", "paths"];
1199
+ : ["default", "loader", "meta", "prerender", "paths", "revalidate"];
1200
1200
  const allowedText = `Hanya ${allowed.map((name) => `\`${name}\``).join(", ")} yang boleh di-export.`;
1201
1201
  const relativeFile = toRelative(projectRoot, file);
1202
1202
  const ast = parseModule(code, file);
@@ -1270,7 +1270,7 @@ export function checkRouteModuleExports(context: RouteExportCheckContext): void
1270
1270
  code: "E_ROUTE_EXPORT_INVALID",
1271
1271
  file: relativeFile,
1272
1272
  message: "modul route wajib mengekspor `default`",
1273
- details: "Setiap file route harus punya `export default` berupa komponen React (PRD §11.2).",
1273
+ details: "Setiap file route harus punya `export default` berupa komponen React.",
1274
1274
  suggestions: ["Tambahkan `export default function Page() { ... }`"],
1275
1275
  });
1276
1276
  }