@moonsjs/vite 0.1.5 → 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.
@@ -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` (PRD Addendum E.1).",
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) {
@@ -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)`);
package/src/transform.ts CHANGED
@@ -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);