@moonsjs/vite 0.1.10 → 0.1.12
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.
- package/dist/devtools.d.ts +30 -0
- package/dist/devtools.d.ts.map +1 -0
- package/dist/{index-qz9sc74p.js → index-h66m8653.js} +91 -1
- package/dist/{index-qz9sc74p.js.map → index-h66m8653.js.map} +5 -4
- package/dist/index.js +20 -4
- package/dist/index.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/server/dev.d.ts +3 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +126 -45
- package/dist/server/index.js.map +6 -5
- package/dist/server/isr.d.ts +8 -2
- package/dist/server/isr.d.ts.map +1 -1
- package/dist/server/node-server.d.ts +12 -0
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/storage.d.ts +10 -0
- package/dist/server/storage.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/devtools.ts +127 -0
- package/src/plugin.ts +28 -2
- package/src/server/dev.ts +26 -1
- package/src/server/index.ts +1 -0
- package/src/server/isr.ts +16 -2
- package/src/server/node-server.ts +77 -34
- package/src/server/storage.ts +53 -0
package/src/plugin.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { createHash } from "node:crypto";
|
|
3
|
-
import { resolve as resolvePath } from "node:path";
|
|
3
|
+
import { relative as relativePath, resolve as resolvePath } from "node:path";
|
|
4
4
|
import {
|
|
5
|
+
MOONS_VERSION,
|
|
5
6
|
MoonsError,
|
|
6
7
|
fnRegistryKey,
|
|
7
8
|
generateTypes,
|
|
@@ -423,6 +424,30 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
423
424
|
configureServer(server) {
|
|
424
425
|
installDevMiddleware(server, {
|
|
425
426
|
templatePath: resolvePath(root, "index.html"),
|
|
427
|
+
// Addendum I.4 (W4): overlay devtools — data segar per request.
|
|
428
|
+
devtools: () => {
|
|
429
|
+
const toRelative = (file: string): string =>
|
|
430
|
+
file.startsWith(root) ? relativePath(root, file).split("\\").join("/") : file;
|
|
431
|
+
return {
|
|
432
|
+
routes: [...routes]
|
|
433
|
+
.sort((a, b) => a.path.localeCompare(b.path) || a.kind.localeCompare(b.kind))
|
|
434
|
+
.map((route) => ({ path: route.path, kind: route.kind, file: toRelative(route.file) })),
|
|
435
|
+
apiRoutes: [...apiRoutes]
|
|
436
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
437
|
+
.map((route) => ({ path: route.path, kind: "api", file: toRelative(route.file) })),
|
|
438
|
+
serverFns: [...analysisCache.values()]
|
|
439
|
+
.flatMap((analysis) =>
|
|
440
|
+
analysis.serverFns.map((fn) => ({
|
|
441
|
+
file: toRelative(analysis.file),
|
|
442
|
+
name: fn.name,
|
|
443
|
+
hash: fn.hash,
|
|
444
|
+
})),
|
|
445
|
+
)
|
|
446
|
+
.sort((a, b) => a.file.localeCompare(b.file) || a.name.localeCompare(b.name)),
|
|
447
|
+
canary: options.leakTest?.canary !== undefined,
|
|
448
|
+
version: MOONS_VERSION,
|
|
449
|
+
};
|
|
450
|
+
},
|
|
426
451
|
// A1: pemindaian payload SSR hanya di dev (warning), tanpa biaya di production.
|
|
427
452
|
payloadScan: {
|
|
428
453
|
...(options.leakTest?.canary === undefined ? {} : { canary: options.leakTest.canary }),
|
|
@@ -433,6 +458,7 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
|
|
|
433
458
|
? {}
|
|
434
459
|
: { apiPrefix: `/${apiDir.split(/[\\/]/).filter((part) => part.length > 0).pop() ?? "api"}` }),
|
|
435
460
|
});
|
|
461
|
+
server.config.logger.info("[moons] devtools: /__moons/devtools");
|
|
436
462
|
},
|
|
437
463
|
|
|
438
464
|
async handleHotUpdate(context) {
|
|
@@ -541,7 +567,7 @@ function renderServerEntryBuild(
|
|
|
541
567
|
" onContext: (ctx) => plugins.applyExtensions(ctx),",
|
|
542
568
|
"});",
|
|
543
569
|
"",
|
|
544
|
-
`startNodeServer(handler, { plugins, headers: config.headers${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
|
|
570
|
+
`startNodeServer(handler, { plugins, headers: config.headers, storage: config.isr?.storage${canary === undefined ? "" : `, canary: ${JSON.stringify(canary)}`} });`,
|
|
545
571
|
"",
|
|
546
572
|
].join("\n");
|
|
547
573
|
}
|
package/src/server/dev.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { PluginRuntime } from "./plugins";
|
|
|
6
6
|
import type { Shell } from "./shell";
|
|
7
7
|
import { createDevShell } from "./shell";
|
|
8
8
|
import { sendNodeResponse, toWebRequest } from "./node";
|
|
9
|
+
import { emptyDevtoolsData, renderDevtoolsHtml, type DevtoolsData } from "../devtools";
|
|
9
10
|
|
|
10
11
|
interface DevApp {
|
|
11
12
|
handler: (request: Request) => Promise<Response>;
|
|
@@ -26,7 +27,13 @@ type DevServerEntry = {
|
|
|
26
27
|
*/
|
|
27
28
|
export function installDevMiddleware(
|
|
28
29
|
server: ViteDevServer,
|
|
29
|
-
options: {
|
|
30
|
+
options: {
|
|
31
|
+
templatePath: string;
|
|
32
|
+
payloadScan?: PayloadScanOptions;
|
|
33
|
+
apiPrefix?: string;
|
|
34
|
+
/** Addendum I.4: datasource devtools overlay (dipanggil per request). */
|
|
35
|
+
devtools?: () => DevtoolsData;
|
|
36
|
+
},
|
|
30
37
|
): void {
|
|
31
38
|
let appPromise: Promise<DevApp> | null = null;
|
|
32
39
|
|
|
@@ -48,6 +55,24 @@ export function installDevMiddleware(
|
|
|
48
55
|
});
|
|
49
56
|
|
|
50
57
|
server.middlewares.use((req, res, next) => {
|
|
58
|
+
// Addendum I.4 (W4): overlay dev-only — selalu data segar per request.
|
|
59
|
+
if (
|
|
60
|
+
(req.method === "GET" || req.method === "HEAD") &&
|
|
61
|
+
new URL(req.url ?? "/", "http://localhost").pathname === "/__moons/devtools"
|
|
62
|
+
) {
|
|
63
|
+
try {
|
|
64
|
+
const data = options.devtools?.() ?? emptyDevtoolsData();
|
|
65
|
+
res.statusCode = 200;
|
|
66
|
+
res.setHeader("content-type", "text/html; charset=utf-8");
|
|
67
|
+
res.end(req.method === "HEAD" ? undefined : renderDevtoolsHtml(data));
|
|
68
|
+
} catch (error) {
|
|
69
|
+
res.statusCode = 500;
|
|
70
|
+
res.setHeader("content-type", "text/plain; charset=utf-8");
|
|
71
|
+
res.end(`[moons] devtools gagal dirender: ${String(error)}`);
|
|
72
|
+
}
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
if (!shouldHandle(req, options.apiPrefix)) {
|
|
52
77
|
next();
|
|
53
78
|
return;
|
package/src/server/index.ts
CHANGED
|
@@ -16,3 +16,4 @@ export { runPrerender } from "./prerender";
|
|
|
16
16
|
export type { PrerenderOptions, PrerenderResult } from "./prerender";
|
|
17
17
|
export type { PayloadScanOptions } from "../payload-scan";
|
|
18
18
|
export type { ApiHandlerArgs, ApiMethod, ApiRouteDefinition } from "@moonsjs/core";
|
|
19
|
+
export { createFileStorage, contentTypeForPathname } from "./storage";
|
package/src/server/isr.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import type { PrerenderStorage } from "@moonsjs/core";
|
|
2
3
|
import { scanPayloadForSecrets } from "../payload-scan";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -18,8 +19,13 @@ export function shouldRevalidate(input: {
|
|
|
18
19
|
export interface RevalidateOptions {
|
|
19
20
|
/** Path URL ternormalisasi (mis. "/blog/hello"). */
|
|
20
21
|
pathname: string;
|
|
21
|
-
/** File HTML yang akan ditimpa. */
|
|
22
|
-
file
|
|
22
|
+
/** File HTML yang akan ditimpa (default file storage). */
|
|
23
|
+
file?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Storage alternatif (Addendum I). Bila diisi, `file` diabaikan dan hasil
|
|
26
|
+
* regenerasi ditulis lewat `storage.write(pathname, body)`.
|
|
27
|
+
*/
|
|
28
|
+
storage?: PrerenderStorage;
|
|
23
29
|
handler: (request: Request) => Promise<Response>;
|
|
24
30
|
canary?: string;
|
|
25
31
|
env?: Record<string, string | undefined>;
|
|
@@ -67,6 +73,14 @@ async function runRevalidate(options: RevalidateOptions): Promise<void> {
|
|
|
67
73
|
log(`[moons] isr: TIDAK menulis ${options.pathname} — memuat ${labels}`);
|
|
68
74
|
return;
|
|
69
75
|
}
|
|
76
|
+
if (options.storage !== undefined) {
|
|
77
|
+
await options.storage.write(options.pathname, new TextEncoder().encode(html));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (options.file === undefined) {
|
|
81
|
+
log(`[moons] isr: lewati ${options.pathname} — tanpa file/storage tujuan`);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
70
84
|
const tmp = `${options.file}.tmp-${process.pid}`;
|
|
71
85
|
writeFileSync(tmp, html);
|
|
72
86
|
renameSync(tmp, options.file);
|
|
@@ -1,7 +1,9 @@
|
|
|
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 type { PrerenderStorage } from "@moonsjs/core";
|
|
4
5
|
import { revalidatePrerendered, shouldRevalidate } from "./isr";
|
|
6
|
+
import { contentTypeForPathname } from "./storage";
|
|
5
7
|
import { sendNodeResponse, toWebRequest } from "./node";
|
|
6
8
|
import type { PluginRuntime } from "./plugins";
|
|
7
9
|
import { installGracefulShutdown } from "./shutdown";
|
|
@@ -44,6 +46,12 @@ export interface NodeServerOptions {
|
|
|
44
46
|
* prerender yang disajikan langsung (static host memakai `_headers`).
|
|
45
47
|
*/
|
|
46
48
|
headers?: Record<string, string>;
|
|
49
|
+
/**
|
|
50
|
+
* Storage bersama untuk file prerender (Addendum I, ISR multi-instance).
|
|
51
|
+
* Bila diisi, penyajian file prerender dan penulisan hasil regenerasi
|
|
52
|
+
* memakai storage ini; default: file sistem lokal.
|
|
53
|
+
*/
|
|
54
|
+
storage?: PrerenderStorage;
|
|
47
55
|
}
|
|
48
56
|
|
|
49
57
|
/**
|
|
@@ -63,19 +71,50 @@ export function startNodeServer(
|
|
|
63
71
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
64
72
|
|
|
65
73
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
74
|
+
// Addendum I: storage bersama (bila dikonfigurasi) menggantikan file lokal.
|
|
75
|
+
if (options.storage !== undefined) {
|
|
76
|
+
const entry = await options.storage.read(url.pathname);
|
|
77
|
+
if (entry !== null) {
|
|
78
|
+
scheduleRevalidation({
|
|
79
|
+
pathname: url.pathname,
|
|
80
|
+
mtimeMs: entry.mtimeMs,
|
|
81
|
+
storage: options.storage,
|
|
82
|
+
handler,
|
|
83
|
+
options,
|
|
84
|
+
clientDir,
|
|
85
|
+
});
|
|
86
|
+
res.statusCode = 200;
|
|
87
|
+
res.setHeader("content-type", contentTypeForPathname(url.pathname));
|
|
88
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
89
|
+
if (!res.hasHeader(name)) {
|
|
90
|
+
res.setHeader(name, value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
res.end(req.method === "HEAD" ? undefined : Buffer.from(entry.body));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
} else {
|
|
97
|
+
const staticFile = resolveStaticFile(clientDir, url.pathname);
|
|
98
|
+
if (staticFile !== null) {
|
|
99
|
+
scheduleRevalidation({
|
|
100
|
+
pathname: url.pathname,
|
|
101
|
+
mtimeMs: statSync(staticFile).mtimeMs,
|
|
102
|
+
file: staticFile,
|
|
103
|
+
handler,
|
|
104
|
+
options,
|
|
105
|
+
clientDir,
|
|
106
|
+
});
|
|
107
|
+
const body = readFileSync(staticFile);
|
|
108
|
+
res.statusCode = 200;
|
|
109
|
+
res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
|
|
110
|
+
for (const [name, value] of Object.entries(options.headers ?? {})) {
|
|
111
|
+
if (!res.hasHeader(name)) {
|
|
112
|
+
res.setHeader(name, value);
|
|
113
|
+
}
|
|
75
114
|
}
|
|
115
|
+
res.end(req.method === "HEAD" ? undefined : body);
|
|
116
|
+
return;
|
|
76
117
|
}
|
|
77
|
-
res.end(req.method === "HEAD" ? undefined : body);
|
|
78
|
-
return;
|
|
79
118
|
}
|
|
80
119
|
}
|
|
81
120
|
|
|
@@ -122,6 +161,15 @@ export function startNodeServer(
|
|
|
122
161
|
|
|
123
162
|
/** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
|
|
124
163
|
export function resolveStaticFile(clientDir: string, pathname: string): string | null {
|
|
164
|
+
const target = staticFileTarget(clientDir, pathname);
|
|
165
|
+
return target !== null && isFile(target) ? target : null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Target file untuk `pathname` (tanpa cek keberadaan) — dipakai penyajian,
|
|
170
|
+
* `createFileStorage().write`, dan test. Proteksi path traversal tetap.
|
|
171
|
+
*/
|
|
172
|
+
export function staticFileTarget(clientDir: string, pathname: string): string | null {
|
|
125
173
|
let decoded: string;
|
|
126
174
|
try {
|
|
127
175
|
decoded = decodeURIComponent(pathname);
|
|
@@ -141,10 +189,7 @@ export function resolveStaticFile(clientDir: string, pathname: string): string |
|
|
|
141
189
|
const manifest = readPrerenderManifest(clientDir);
|
|
142
190
|
if (manifest.paths.has(normalized)) {
|
|
143
191
|
const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
|
|
144
|
-
|
|
145
|
-
if (candidate !== null && isFile(candidate)) {
|
|
146
|
-
return candidate;
|
|
147
|
-
}
|
|
192
|
+
return safeWithin(clientDir, `./${target}`);
|
|
148
193
|
}
|
|
149
194
|
|
|
150
195
|
return null;
|
|
@@ -221,32 +266,30 @@ function readPrerenderManifest(clientDir: string): PrerenderManifestInfo {
|
|
|
221
266
|
}
|
|
222
267
|
|
|
223
268
|
/** ISR: jika file prerender kedaluwarsa, regenerasi background (SWR). */
|
|
224
|
-
function scheduleRevalidation(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
269
|
+
function scheduleRevalidation(input: {
|
|
270
|
+
pathname: string;
|
|
271
|
+
mtimeMs: number;
|
|
272
|
+
file?: string;
|
|
273
|
+
storage?: PrerenderStorage;
|
|
274
|
+
handler: (request: Request) => Promise<Response>;
|
|
275
|
+
options: NodeServerOptions;
|
|
276
|
+
clientDir: string;
|
|
277
|
+
}): void {
|
|
278
|
+
const normalized = input.pathname.length > 1 ? input.pathname.replace(/\/+$/, "") : input.pathname;
|
|
279
|
+
const seconds = readPrerenderManifest(input.clientDir).revalidate.get(normalized);
|
|
233
280
|
if (seconds === undefined || seconds < 0) {
|
|
234
281
|
return;
|
|
235
282
|
}
|
|
236
|
-
|
|
237
|
-
try {
|
|
238
|
-
mtimeMs = statSync(staticFile).mtimeMs;
|
|
239
|
-
} catch {
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
if (!shouldRevalidate({ mtimeMs, revalidateSeconds: seconds })) {
|
|
283
|
+
if (!shouldRevalidate({ mtimeMs: input.mtimeMs, revalidateSeconds: seconds })) {
|
|
243
284
|
return;
|
|
244
285
|
}
|
|
245
286
|
void revalidatePrerendered({
|
|
246
287
|
pathname: normalized,
|
|
247
|
-
file:
|
|
248
|
-
|
|
249
|
-
|
|
288
|
+
...(input.file === undefined ? {} : { file: input.file }),
|
|
289
|
+
...(input.storage === undefined ? {} : { storage: input.storage }),
|
|
290
|
+
handler: input.handler,
|
|
291
|
+
...(input.options.canary === undefined ? {} : { canary: input.options.canary }),
|
|
292
|
+
env: process.env,
|
|
250
293
|
});
|
|
251
294
|
}
|
|
252
295
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, extname } from "node:path";
|
|
3
|
+
import type { PrerenderStorage, PrerenderStorageEntry } from "@moonsjs/core";
|
|
4
|
+
import { resolveStaticFile, staticFileTarget } from "./node-server";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Addendum I: storage file sistem (default) untuk file prerender/ISR.
|
|
8
|
+
* Read hanya melayani path yang benar-benar ada (manifest-aware);
|
|
9
|
+
* write atomik (tmp + rename) dan membuat direktori bila perlu.
|
|
10
|
+
*/
|
|
11
|
+
export function createFileStorage(clientDir: string): PrerenderStorage {
|
|
12
|
+
return {
|
|
13
|
+
async read(pathname: string): Promise<PrerenderStorageEntry | null> {
|
|
14
|
+
const file = resolveStaticFile(clientDir, pathname);
|
|
15
|
+
if (file === null) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const stat = statSync(file);
|
|
19
|
+
return { body: readFileSync(file), mtimeMs: stat.mtimeMs };
|
|
20
|
+
},
|
|
21
|
+
async write(pathname: string, body: Uint8Array): Promise<void> {
|
|
22
|
+
const target = staticFileTarget(clientDir, pathname);
|
|
23
|
+
if (target === null) {
|
|
24
|
+
throw new Error(`[moons] isr: path tidak valid untuk ditulis: ${pathname}`);
|
|
25
|
+
}
|
|
26
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
27
|
+
const tmp = `${target}.tmp-${process.pid}`;
|
|
28
|
+
writeFileSync(tmp, body);
|
|
29
|
+
renameSync(tmp, target);
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ekstensi pathname (untuk `content-type`) tanpa membaca storage. */
|
|
35
|
+
export function contentTypeForPathname(pathname: string): string {
|
|
36
|
+
const ext = extname(pathname.split("?")[0] ?? "").toLowerCase();
|
|
37
|
+
return MIME[ext] ?? MIME[".html"] ?? "text/html; charset=utf-8";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const MIME: Record<string, string> = {
|
|
41
|
+
".html": "text/html; charset=utf-8",
|
|
42
|
+
".js": "text/javascript; charset=utf-8",
|
|
43
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
44
|
+
".css": "text/css; charset=utf-8",
|
|
45
|
+
".json": "application/json; charset=utf-8",
|
|
46
|
+
".txt": "text/plain; charset=utf-8",
|
|
47
|
+
".svg": "image/svg+xml",
|
|
48
|
+
".ico": "image/x-icon",
|
|
49
|
+
".png": "image/png",
|
|
50
|
+
".webp": "image/webp",
|
|
51
|
+
".woff": "font/woff",
|
|
52
|
+
".woff2": "font/woff2",
|
|
53
|
+
};
|