@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.
- package/dist/api.d.ts +8 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/{index-33p1r65s.js → index-1fnt137k.js} +50 -5
- package/dist/index-1fnt137k.js.map +14 -0
- package/dist/{index-3ec9vpek.js → index-zs92aaz4.js} +232 -24
- package/dist/index-zs92aaz4.js.map +11 -0
- package/dist/index.js +216 -19
- package/dist/index.js.map +5 -4
- package/dist/manifest.d.ts +6 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/payload-scan.d.ts +18 -0
- package/dist/payload-scan.d.ts.map +1 -0
- 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/handler.d.ts +16 -1
- package/dist/server/handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +561 -17
- package/dist/server/index.js.map +9 -6
- package/dist/server/isr.d.ts +25 -0
- package/dist/server/isr.d.ts.map +1 -0
- package/dist/server/node-server.d.ts +7 -1
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts.map +1 -1
- package/dist/server/prerender.d.ts +28 -0
- package/dist/server/prerender.d.ts.map +1 -0
- package/dist/server-only.d.ts +57 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/transform.d.ts +9 -1
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +1 -1
- package/dist/virtual-modules.d.ts +1 -0
- package/dist/virtual-modules.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api.ts +124 -0
- package/src/manifest.ts +42 -1
- package/src/payload-scan.ts +53 -0
- package/src/plugin.ts +137 -10
- package/src/server/dev.ts +14 -5
- package/src/server/handler.ts +317 -13
- package/src/server/index.ts +4 -0
- package/src/server/isr.ts +81 -0
- package/src/server/node-server.ts +120 -7
- package/src/server/node.ts +17 -0
- package/src/server/prerender.ts +256 -0
- package/src/server-only.ts +220 -0
- package/src/transform.ts +138 -8
- package/src/virtual-modules.ts +1 -0
- package/dist/index-33p1r65s.js.map +0 -14
- package/dist/index-3ec9vpek.js.map +0 -10
package/dist/server/index.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
2
|
NOT_FOUND_ROUTE_KEY,
|
|
3
|
-
ERROR_ROUTE_KEY,
|
|
4
3
|
ROOT_PLACEHOLDER2,
|
|
5
4
|
DEFAULT_TEMPLATE2,
|
|
6
5
|
injectShell2,
|
|
@@ -9,12 +8,15 @@ import {
|
|
|
9
8
|
toWebRequest2,
|
|
10
9
|
sendNodeResponse2,
|
|
11
10
|
installDevMiddleware2
|
|
12
|
-
} from "../index-
|
|
11
|
+
} from "../index-1fnt137k.js";
|
|
13
12
|
|
|
14
13
|
// src/server/handler.ts
|
|
15
14
|
import {
|
|
16
15
|
MoonsError,
|
|
17
16
|
composeMiddleware,
|
|
17
|
+
findApiDirMiddlewares,
|
|
18
|
+
findDirMiddlewares,
|
|
19
|
+
findErrorBoundary,
|
|
18
20
|
isNotFoundSignal,
|
|
19
21
|
isRedirectSignal,
|
|
20
22
|
resolveRoute,
|
|
@@ -22,8 +24,34 @@ import {
|
|
|
22
24
|
runLoaders
|
|
23
25
|
} from "@moonsjs/core";
|
|
24
26
|
import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
|
|
27
|
+
import { API_METHODS, resolveApiRoute } from "@moonsjs/core";
|
|
25
28
|
import { renderToMarkup } from "@moonsjs/react/ssr";
|
|
26
|
-
import { createRpcHandler, runWithContext, runWithRequestCache } from "@moonsjs/server";
|
|
29
|
+
import { createRpcHandler, runWithContext, runWithRequestCache, runWithResponseCollector, takeResponseMutations } from "@moonsjs/server";
|
|
30
|
+
|
|
31
|
+
// src/payload-scan.ts
|
|
32
|
+
var SECRET_KEY_PATTERN = /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|AUTH|API_KEY|ACCESS_KEY|SIGNING)/i;
|
|
33
|
+
var MIN_SECRET_LENGTH = 12;
|
|
34
|
+
function scanPayloadForSecrets(payload, options = {}) {
|
|
35
|
+
const findings = [];
|
|
36
|
+
const canary = options.canary;
|
|
37
|
+
if (canary !== undefined && canary.length > 0 && payload.includes(canary)) {
|
|
38
|
+
findings.push({ kind: "canary", label: "canary" });
|
|
39
|
+
}
|
|
40
|
+
for (const [key, value] of Object.entries(options.env ?? {})) {
|
|
41
|
+
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!SECRET_KEY_PATTERN.test(key) || /PUBLIC/i.test(key)) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (payload.includes(value)) {
|
|
48
|
+
findings.push({ kind: "env", label: key });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return findings;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/server/handler.ts
|
|
27
55
|
var RPC_PREFIX = "/_moons/fn/";
|
|
28
56
|
var HEALTH_PATH = "/_moons/health";
|
|
29
57
|
function createRequestHandler(options) {
|
|
@@ -31,6 +59,7 @@ function createRequestHandler(options) {
|
|
|
31
59
|
const rpc = createRpcHandler(config.security === undefined ? {} : { security: config.security });
|
|
32
60
|
const notFoundRoute = routes.find((route) => route.kind === "404");
|
|
33
61
|
const errorRoute = routes.find((route) => route.kind === "error");
|
|
62
|
+
const warnedPayloadLabels = new Set;
|
|
34
63
|
async function renderRoute(resolved, request, ctx) {
|
|
35
64
|
const entries = [...resolved.layouts, resolved.route];
|
|
36
65
|
const keys = entries.map((entry) => routeKeyFromFile(routesDir, entry.file));
|
|
@@ -49,8 +78,72 @@ function createRequestHandler(options) {
|
|
|
49
78
|
data,
|
|
50
79
|
...meta === undefined ? {} : { meta }
|
|
51
80
|
});
|
|
81
|
+
warnPayloadFindings(payload, options.payloadScan, warnedPayloadLabels);
|
|
52
82
|
return htmlResponse(await shell(markup, payload, request.url, meta), 200);
|
|
53
83
|
}
|
|
84
|
+
async function handleApiRequest(route, params, request, ctx) {
|
|
85
|
+
const load = options.loadApi?.[route.path];
|
|
86
|
+
if (load === undefined) {
|
|
87
|
+
return apiErrorResponse(new MoonsError({
|
|
88
|
+
code: "E_API_HANDLER_INVALID",
|
|
89
|
+
file: route.file,
|
|
90
|
+
message: `modul API \`${route.path}\` tidak bisa dimuat`,
|
|
91
|
+
details: "Peta `loadApi` tidak memuat route ini (build/dev tidak sinkron).",
|
|
92
|
+
suggestions: ["Jalankan ulang build / restart dev server"]
|
|
93
|
+
}), route);
|
|
94
|
+
}
|
|
95
|
+
let mod;
|
|
96
|
+
try {
|
|
97
|
+
mod = await load();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return apiErrorResponse(error, route);
|
|
100
|
+
}
|
|
101
|
+
if (mod === null || typeof mod !== "object") {
|
|
102
|
+
return apiErrorResponse(new MoonsError({
|
|
103
|
+
code: "E_API_HANDLER_INVALID",
|
|
104
|
+
file: route.file,
|
|
105
|
+
message: "modul API tidak mengekspor objek handler",
|
|
106
|
+
details: `Modul \`${route.path}\` harus mengekspor handler method (PRD Addendum D.1).`,
|
|
107
|
+
suggestions: ["Export `GET`/`POST`/… sebagai fungsi `(args) => Response`"]
|
|
108
|
+
}), route);
|
|
109
|
+
}
|
|
110
|
+
const record = mod;
|
|
111
|
+
const methods = API_METHODS.filter((method) => typeof record[method] === "function");
|
|
112
|
+
const requestMethod = request.method.toUpperCase();
|
|
113
|
+
let handler = record[requestMethod];
|
|
114
|
+
let stripBody = false;
|
|
115
|
+
if (typeof handler !== "function" && requestMethod === "HEAD" && typeof record["GET"] === "function") {
|
|
116
|
+
handler = record["GET"];
|
|
117
|
+
stripBody = true;
|
|
118
|
+
}
|
|
119
|
+
if (typeof handler !== "function") {
|
|
120
|
+
if (requestMethod === "OPTIONS") {
|
|
121
|
+
return new Response(null, { status: 204, headers: { allow: methods.join(", ") } });
|
|
122
|
+
}
|
|
123
|
+
return new Response("Method Not Allowed", {
|
|
124
|
+
status: 405,
|
|
125
|
+
headers: { allow: methods.join(", ") }
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
const result = await handler({ req: request, params, ctx });
|
|
130
|
+
if (!(result instanceof Response)) {
|
|
131
|
+
return apiErrorResponse(new MoonsError({
|
|
132
|
+
code: "E_API_HANDLER_INVALID",
|
|
133
|
+
file: route.file,
|
|
134
|
+
message: `handler \`${requestMethod}\` tidak mengembalikan \`Response\``,
|
|
135
|
+
details: "Handler API wajib mengembalikan `new Response(...)` (PRD Addendum D.1).",
|
|
136
|
+
suggestions: ["Kembalikan `new Response(body, { status, headers })`"]
|
|
137
|
+
}), route);
|
|
138
|
+
}
|
|
139
|
+
if (stripBody) {
|
|
140
|
+
return new Response(null, { status: result.status, headers: result.headers });
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return apiErrorResponse(error, route);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
54
147
|
async function notFoundResponse(request) {
|
|
55
148
|
if (notFoundRoute !== undefined) {
|
|
56
149
|
try {
|
|
@@ -67,16 +160,17 @@ function createRequestHandler(options) {
|
|
|
67
160
|
const html = await shell("<h1>404</h1><p>Halaman tidak ditemukan.</p>", payload, request.url);
|
|
68
161
|
return htmlResponse(html, 404);
|
|
69
162
|
}
|
|
70
|
-
async function routeErrorResponse(error, request) {
|
|
163
|
+
async function routeErrorResponse(error, request, resolved) {
|
|
71
164
|
console.error("[moons] route error:", error);
|
|
72
|
-
|
|
165
|
+
const boundary = (resolved === undefined ? null : findErrorBoundary(resolved.route, routes)) ?? errorRoute ?? null;
|
|
166
|
+
if (boundary !== null) {
|
|
73
167
|
try {
|
|
74
|
-
const key = routeKeyFromFile(routesDir,
|
|
168
|
+
const key = routeKeyFromFile(routesDir, boundary.file);
|
|
75
169
|
const [module] = await loadModules([key], loadRoute);
|
|
76
170
|
const isProduction = process.env["NODE_ENV"] === "production";
|
|
77
171
|
const serialized = isProduction ? undefined : serializeError(error);
|
|
78
172
|
const payload = encodeSsrPayload({
|
|
79
|
-
key
|
|
173
|
+
key,
|
|
80
174
|
params: {},
|
|
81
175
|
data: {},
|
|
82
176
|
...serialized === undefined ? {} : { error: serialized }
|
|
@@ -89,6 +183,62 @@ function createRequestHandler(options) {
|
|
|
89
183
|
}
|
|
90
184
|
return internalErrorResponse(error, { log: false });
|
|
91
185
|
}
|
|
186
|
+
async function loadDirMiddlewares(chain) {
|
|
187
|
+
const result = [];
|
|
188
|
+
for (const definition of chain) {
|
|
189
|
+
const key = routeKeyFromFile(routesDir, definition.file);
|
|
190
|
+
const load = options.loadRoute?.[key];
|
|
191
|
+
if (load === undefined) {
|
|
192
|
+
throw new MoonsError({
|
|
193
|
+
code: "E_MIDDLEWARE_INVALID",
|
|
194
|
+
file: definition.file,
|
|
195
|
+
message: "middleware direktori tidak bisa dimuat",
|
|
196
|
+
details: "Peta `loadRoute` tidak memuat `_middleware.ts` ini.",
|
|
197
|
+
suggestions: ["Restart dev server / jalankan ulang build"]
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const module = await load();
|
|
201
|
+
const handler = module.default;
|
|
202
|
+
if (typeof handler !== "function") {
|
|
203
|
+
throw new MoonsError({
|
|
204
|
+
code: "E_MIDDLEWARE_INVALID",
|
|
205
|
+
file: definition.file,
|
|
206
|
+
message: "`_middleware.ts` tidak meng-export default fungsi",
|
|
207
|
+
details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
|
|
208
|
+
suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"]
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
result.push(handler);
|
|
212
|
+
}
|
|
213
|
+
return result;
|
|
214
|
+
}
|
|
215
|
+
async function loadApiDirMiddlewares(chain) {
|
|
216
|
+
const result = [];
|
|
217
|
+
for (const definition of chain) {
|
|
218
|
+
const load = options.loadApiMiddleware?.[definition.file];
|
|
219
|
+
if (load === undefined) {
|
|
220
|
+
throw new MoonsError({
|
|
221
|
+
code: "E_MIDDLEWARE_INVALID",
|
|
222
|
+
file: definition.file,
|
|
223
|
+
message: "middleware API tidak bisa dimuat",
|
|
224
|
+
details: "Peta `loadApiMiddleware` tidak memuat `_middleware.ts` ini.",
|
|
225
|
+
suggestions: ["Restart dev server / jalankan ulang build"]
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
const module = await load();
|
|
229
|
+
if (typeof module.default !== "function") {
|
|
230
|
+
throw new MoonsError({
|
|
231
|
+
code: "E_MIDDLEWARE_INVALID",
|
|
232
|
+
file: definition.file,
|
|
233
|
+
message: "`_middleware.ts` tidak meng-export default fungsi",
|
|
234
|
+
details: "Middleware direktori mengikuti bentuk `(args) => Promise<Response>` (Addendum D.3).",
|
|
235
|
+
suggestions: ["Export default `async ({ req, ctx, next }) => await next()`"]
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
result.push(module.default);
|
|
239
|
+
}
|
|
240
|
+
return result;
|
|
241
|
+
}
|
|
92
242
|
return async function handler(request) {
|
|
93
243
|
try {
|
|
94
244
|
const url = new URL(request.url);
|
|
@@ -97,16 +247,35 @@ function createRequestHandler(options) {
|
|
|
97
247
|
}
|
|
98
248
|
const ctx = await config.context(request);
|
|
99
249
|
options.onContext?.(ctx);
|
|
250
|
+
if (request.headers.get("x-moons-prerender") === "1") {
|
|
251
|
+
ctx["__prerender"] = true;
|
|
252
|
+
}
|
|
100
253
|
const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
|
|
101
254
|
if (url.pathname.startsWith(RPC_PREFIX)) {
|
|
102
255
|
return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
|
|
103
256
|
}
|
|
257
|
+
if (options.apiRoutes !== undefined && options.apiRoutes.length > 0) {
|
|
258
|
+
const apiMatch = resolveApiRoute(url.pathname, options.apiRoutes);
|
|
259
|
+
if (apiMatch !== null) {
|
|
260
|
+
const apiChain = findApiDirMiddlewares(apiMatch.route.file, options.apiMiddlewares ?? []);
|
|
261
|
+
if (apiChain.length === 0) {
|
|
262
|
+
return await handleApiRequest(apiMatch.route, apiMatch.params, request, ctx);
|
|
263
|
+
}
|
|
264
|
+
const middlewares = await loadApiDirMiddlewares(apiChain);
|
|
265
|
+
return await composeMiddleware(middlewares, ctx, () => handleApiRequest(apiMatch.route, apiMatch.params, request, ctx), { code: "E_MIDDLEWARE_INVALID", label: "middleware API" })(request);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
104
268
|
const resolved = resolveRoute(url.pathname, routes);
|
|
105
269
|
if (resolved === null) {
|
|
106
270
|
return await notFoundResponse(request);
|
|
107
271
|
}
|
|
108
272
|
try {
|
|
109
|
-
|
|
273
|
+
const dirChain = findDirMiddlewares(resolved.route, routes);
|
|
274
|
+
if (dirChain.length === 0) {
|
|
275
|
+
return await renderRoute(resolved, request, ctx);
|
|
276
|
+
}
|
|
277
|
+
const middlewares = await loadDirMiddlewares(dirChain);
|
|
278
|
+
return await composeMiddleware(middlewares, ctx, () => renderRoute(resolved, request, ctx), { code: "E_MIDDLEWARE_INVALID", label: "middleware direktori" })(request);
|
|
110
279
|
} catch (error) {
|
|
111
280
|
if (isRedirectSignal(error)) {
|
|
112
281
|
return new Response(null, {
|
|
@@ -117,15 +286,46 @@ function createRequestHandler(options) {
|
|
|
117
286
|
if (isNotFoundSignal(error)) {
|
|
118
287
|
return await notFoundResponse(request);
|
|
119
288
|
}
|
|
120
|
-
return await routeErrorResponse(error, request);
|
|
289
|
+
return await routeErrorResponse(error, request, resolved);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
return await runWithResponseCollector(async () => {
|
|
293
|
+
let response;
|
|
294
|
+
try {
|
|
295
|
+
response = await runWithRequestCache(() => runWithContext(ctx, () => respond(request)));
|
|
296
|
+
} catch (error) {
|
|
297
|
+
response = internalErrorResponse(error);
|
|
121
298
|
}
|
|
299
|
+
return applyResponseMutations(response);
|
|
122
300
|
});
|
|
123
|
-
return await runWithRequestCache(() => runWithContext(ctx, () => respond(request)));
|
|
124
301
|
} catch (error) {
|
|
125
302
|
return internalErrorResponse(error);
|
|
126
303
|
}
|
|
127
304
|
};
|
|
128
305
|
}
|
|
306
|
+
function applyResponseMutations(response) {
|
|
307
|
+
const mutations = takeResponseMutations();
|
|
308
|
+
if (mutations.length === 0) {
|
|
309
|
+
return response;
|
|
310
|
+
}
|
|
311
|
+
const headers = new Headers(response.headers);
|
|
312
|
+
for (const mutation of mutations) {
|
|
313
|
+
if (mutation.kind === "header") {
|
|
314
|
+
if (mutation.append) {
|
|
315
|
+
headers.append(mutation.name, mutation.value);
|
|
316
|
+
} else {
|
|
317
|
+
headers.set(mutation.name, mutation.value);
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
headers.append("set-cookie", mutation.value);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return new Response(response.body, {
|
|
324
|
+
status: response.status,
|
|
325
|
+
statusText: response.statusText,
|
|
326
|
+
headers
|
|
327
|
+
});
|
|
328
|
+
}
|
|
129
329
|
async function loadModules(keys, loadRoute) {
|
|
130
330
|
return await Promise.all(keys.map(async (key) => {
|
|
131
331
|
const load = loadRoute[key];
|
|
@@ -169,6 +369,21 @@ async function runRouteMeta(pageModule, pageData, file) {
|
|
|
169
369
|
}
|
|
170
370
|
return result;
|
|
171
371
|
}
|
|
372
|
+
function apiErrorResponse(error, route) {
|
|
373
|
+
const isProduction = process.env["NODE_ENV"] === "production";
|
|
374
|
+
if (error instanceof MoonsError) {
|
|
375
|
+
console.error(`[moons] API ${route.path}: ${error.format()}`);
|
|
376
|
+
return new Response(isProduction ? "Terjadi kesalahan internal." : error.format(), {
|
|
377
|
+
status: 500,
|
|
378
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
console.error(`[moons] API ${route.path}:`, error);
|
|
382
|
+
return new Response(isProduction ? "Terjadi kesalahan internal." : String(error), {
|
|
383
|
+
status: 500,
|
|
384
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
385
|
+
});
|
|
386
|
+
}
|
|
172
387
|
function internalErrorResponse(error, options = {}) {
|
|
173
388
|
if (options.log !== false) {
|
|
174
389
|
console.error("[moons] request error:", error);
|
|
@@ -198,11 +413,71 @@ function healthResponse(method) {
|
|
|
198
413
|
function escapeHtml(text) {
|
|
199
414
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
200
415
|
}
|
|
416
|
+
function warnPayloadFindings(payload, scan, warned) {
|
|
417
|
+
if (scan === undefined) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
for (const finding of scanPayloadForSecrets(payload, scan)) {
|
|
421
|
+
const label = finding.kind === "canary" ? "canary" : `env(${finding.label})`;
|
|
422
|
+
if (warned.has(label)) {
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
warned.add(label);
|
|
426
|
+
console.warn(`[moons] peringatan dev: payload SSR memuat ${label} — jangan mengembalikan rahasia dari loader (PRD §2/§16.6).`);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
201
429
|
// src/server/node-server.ts
|
|
202
430
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
203
431
|
import { createServer } from "node:http";
|
|
204
432
|
import { extname, join, resolve, sep } from "node:path";
|
|
205
433
|
|
|
434
|
+
// src/server/isr.ts
|
|
435
|
+
import { renameSync, writeFileSync } from "node:fs";
|
|
436
|
+
function shouldRevalidate(input) {
|
|
437
|
+
const now = input.now ?? Date.now();
|
|
438
|
+
return now - input.mtimeMs >= input.revalidateSeconds * 1000;
|
|
439
|
+
}
|
|
440
|
+
var inflight = new Map;
|
|
441
|
+
function revalidatePrerendered(options) {
|
|
442
|
+
const existing = inflight.get(options.pathname);
|
|
443
|
+
if (existing !== undefined) {
|
|
444
|
+
return existing;
|
|
445
|
+
}
|
|
446
|
+
const task = runRevalidate(options).finally(() => {
|
|
447
|
+
inflight.delete(options.pathname);
|
|
448
|
+
});
|
|
449
|
+
inflight.set(options.pathname, task);
|
|
450
|
+
return task;
|
|
451
|
+
}
|
|
452
|
+
async function runRevalidate(options) {
|
|
453
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
454
|
+
try {
|
|
455
|
+
const response = await options.handler(new Request(`http://localhost${options.pathname}`, {
|
|
456
|
+
method: "GET",
|
|
457
|
+
headers: { "x-moons-prerender": "1" }
|
|
458
|
+
}));
|
|
459
|
+
if (response.status !== 200) {
|
|
460
|
+
log(`[moons] isr: lewati ${options.pathname} (status ${response.status})`);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const html = await response.text();
|
|
464
|
+
const findings = scanPayloadForSecrets(html, {
|
|
465
|
+
...options.canary === undefined ? {} : { canary: options.canary },
|
|
466
|
+
env: options.env ?? process.env
|
|
467
|
+
});
|
|
468
|
+
if (findings.length > 0) {
|
|
469
|
+
const labels = findings.map((finding) => finding.kind === "canary" ? "canary" : `env(${finding.label})`).join(", ");
|
|
470
|
+
log(`[moons] isr: TIDAK menulis ${options.pathname} — memuat ${labels}`);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
const tmp = `${options.file}.tmp-${process.pid}`;
|
|
474
|
+
writeFileSync(tmp, html);
|
|
475
|
+
renameSync(tmp, options.file);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
log(`[moons] isr: gagal regenerasi ${options.pathname}: ${String(error)}`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
206
481
|
// src/server/shutdown.ts
|
|
207
482
|
function installGracefulShutdown(server, options = {}) {
|
|
208
483
|
const timeoutMs = options.timeoutMs ?? 1e4;
|
|
@@ -281,6 +556,7 @@ function startNodeServer(handler, options = {}) {
|
|
|
281
556
|
if (req.method === "GET" || req.method === "HEAD") {
|
|
282
557
|
const staticFile = resolveStaticFile(clientDir, url.pathname);
|
|
283
558
|
if (staticFile !== null) {
|
|
559
|
+
scheduleRevalidation(staticFile, url.pathname, handler, options, clientDir);
|
|
284
560
|
const body = readFileSync(staticFile);
|
|
285
561
|
res.statusCode = 200;
|
|
286
562
|
res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
|
|
@@ -328,18 +604,92 @@ function resolveStaticFile(clientDir, pathname) {
|
|
|
328
604
|
} catch {
|
|
329
605
|
return null;
|
|
330
606
|
}
|
|
331
|
-
const
|
|
332
|
-
if (
|
|
333
|
-
return
|
|
607
|
+
const exact = safeWithin(clientDir, `.${decoded}`);
|
|
608
|
+
if (exact !== null && isFile(exact)) {
|
|
609
|
+
return exact;
|
|
334
610
|
}
|
|
335
|
-
|
|
336
|
-
|
|
611
|
+
const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
|
|
612
|
+
const manifest = readPrerenderManifest(clientDir);
|
|
613
|
+
if (manifest.paths.has(normalized)) {
|
|
614
|
+
const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
|
|
615
|
+
const candidate = safeWithin(clientDir, `./${target}`);
|
|
616
|
+
if (candidate !== null && isFile(candidate)) {
|
|
617
|
+
return candidate;
|
|
618
|
+
}
|
|
337
619
|
}
|
|
338
|
-
|
|
620
|
+
return null;
|
|
621
|
+
}
|
|
622
|
+
function safeWithin(clientDir, relative) {
|
|
623
|
+
const candidate = resolve(clientDir, relative);
|
|
624
|
+
if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
|
|
339
625
|
return null;
|
|
340
626
|
}
|
|
341
627
|
return candidate;
|
|
342
628
|
}
|
|
629
|
+
function isFile(path) {
|
|
630
|
+
return existsSync(path) && statSync(path).isFile();
|
|
631
|
+
}
|
|
632
|
+
var prerenderManifestCache = null;
|
|
633
|
+
function readPrerenderManifest(clientDir) {
|
|
634
|
+
const file = join(clientDir, "_moons_prerender.json");
|
|
635
|
+
const empty = { paths: new Set, revalidate: new Map };
|
|
636
|
+
try {
|
|
637
|
+
const mtimeMs = statSync(file).mtimeMs;
|
|
638
|
+
if (prerenderManifestCache !== null && prerenderManifestCache.file === file && prerenderManifestCache.mtimeMs === mtimeMs) {
|
|
639
|
+
return prerenderManifestCache.info;
|
|
640
|
+
}
|
|
641
|
+
const json = JSON.parse(readFileSync(file, "utf8"));
|
|
642
|
+
const paths = new Set;
|
|
643
|
+
const revalidate = new Map;
|
|
644
|
+
if (Array.isArray(json.paths)) {
|
|
645
|
+
for (const path of json.paths) {
|
|
646
|
+
if (typeof path === "string") {
|
|
647
|
+
paths.add(path);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (Array.isArray(json.entries)) {
|
|
652
|
+
for (const entry of json.entries) {
|
|
653
|
+
if (entry === null || typeof entry !== "object" || typeof entry["path"] !== "string") {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const path = entry["path"];
|
|
657
|
+
paths.add(path);
|
|
658
|
+
const seconds = entry["revalidate"];
|
|
659
|
+
if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) {
|
|
660
|
+
revalidate.set(path, seconds);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
const info = { paths, revalidate };
|
|
665
|
+
prerenderManifestCache = { file, mtimeMs, info };
|
|
666
|
+
return info;
|
|
667
|
+
} catch {
|
|
668
|
+
return empty;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
function scheduleRevalidation(staticFile, pathname, handler, options, clientDir) {
|
|
672
|
+
const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
|
|
673
|
+
const seconds = readPrerenderManifest(clientDir).revalidate.get(normalized);
|
|
674
|
+
if (seconds === undefined || seconds < 0) {
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
let mtimeMs;
|
|
678
|
+
try {
|
|
679
|
+
mtimeMs = statSync(staticFile).mtimeMs;
|
|
680
|
+
} catch {
|
|
681
|
+
return;
|
|
682
|
+
}
|
|
683
|
+
if (!shouldRevalidate({ mtimeMs, revalidateSeconds: seconds })) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
revalidatePrerendered({
|
|
687
|
+
pathname: normalized,
|
|
688
|
+
file: staticFile,
|
|
689
|
+
handler,
|
|
690
|
+
...options.canary === undefined ? {} : { canary: options.canary }
|
|
691
|
+
});
|
|
692
|
+
}
|
|
343
693
|
// src/server/test-server.ts
|
|
344
694
|
function createTestServer(options, baseUrl = "http://localhost") {
|
|
345
695
|
const shell = options.shell ?? ((markup, payload, _url, meta) => injectShell2(DEFAULT_TEMPLATE2, markup, payload, meta));
|
|
@@ -411,6 +761,199 @@ function createPluginRuntime(options) {
|
|
|
411
761
|
}
|
|
412
762
|
};
|
|
413
763
|
}
|
|
764
|
+
// src/server/prerender.ts
|
|
765
|
+
import { MoonsError as MoonsError2 } from "@moonsjs/core";
|
|
766
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
767
|
+
import { dirname, join as join2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
768
|
+
var PRERENDER_MANIFEST_FILE = "_moons_prerender.json";
|
|
769
|
+
async function runPrerender(options) {
|
|
770
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
771
|
+
const env = options.env ?? process.env;
|
|
772
|
+
let written = 0;
|
|
773
|
+
let skipped = 0;
|
|
774
|
+
const entries = [];
|
|
775
|
+
const pages = options.routes.filter((route) => route.kind === "page");
|
|
776
|
+
for (const route of pages) {
|
|
777
|
+
const key = routeKeyOf(options.routesDir, route.file);
|
|
778
|
+
const load = options.loadRoute[key];
|
|
779
|
+
if (load === undefined) {
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const module = await load();
|
|
783
|
+
const prerender = module.prerender;
|
|
784
|
+
const revalidate = module.revalidate;
|
|
785
|
+
if (revalidate !== undefined) {
|
|
786
|
+
if (typeof revalidate !== "number" || !Number.isFinite(revalidate) || revalidate < 0 || prerender !== true) {
|
|
787
|
+
throw new MoonsError2({
|
|
788
|
+
code: "E_ISR_INVALID",
|
|
789
|
+
file: route.file,
|
|
790
|
+
message: "`revalidate` tidak valid atau dipakai tanpa `prerender = true`",
|
|
791
|
+
details: "ISR berbasis file: `revalidate` (detik, number >= 0) + `prerender` (PRD Addendum E.1).",
|
|
792
|
+
suggestions: [
|
|
793
|
+
"Gunakan angka detik, mis. `export const revalidate = 60`",
|
|
794
|
+
"Tambahkan `export const prerender = true` pada page yang sama"
|
|
795
|
+
]
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
if (prerender !== true) {
|
|
800
|
+
if (prerender !== undefined && prerender !== false) {
|
|
801
|
+
throw prerenderError(route, "`prerender` harus boolean (`true`/`false`)");
|
|
802
|
+
}
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const pathnames = await resolvePathnames(route, module);
|
|
806
|
+
for (const pathname of pathnames) {
|
|
807
|
+
const response = await options.handler(new Request(`http://localhost${pathname}`, {
|
|
808
|
+
method: "GET",
|
|
809
|
+
headers: { "x-moons-prerender": "1" }
|
|
810
|
+
}));
|
|
811
|
+
if (response.status === 404) {
|
|
812
|
+
log(`[moons] prerender: lewati ${pathname} (notFound)`);
|
|
813
|
+
skipped += 1;
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (response.status >= 300 && response.status < 400) {
|
|
817
|
+
throw prerenderError(route, `\`redirect()\` tidak didukung saat prerender (${pathname})`);
|
|
818
|
+
}
|
|
819
|
+
if (response.status !== 200) {
|
|
820
|
+
throw prerenderError(route, `status ${response.status} saat merender ${pathname}`);
|
|
821
|
+
}
|
|
822
|
+
const html = await response.text();
|
|
823
|
+
const findings = scanPayloadForSecrets(html, {
|
|
824
|
+
...options.canary === undefined ? {} : { canary: options.canary },
|
|
825
|
+
env
|
|
826
|
+
});
|
|
827
|
+
if (findings.length > 0) {
|
|
828
|
+
const labels = findings.map((finding) => finding.kind === "canary" ? "canary" : `env(${finding.label})`).join(", ");
|
|
829
|
+
throw new MoonsError2({
|
|
830
|
+
code: "E_PRERENDER_LEAK",
|
|
831
|
+
file: route.file,
|
|
832
|
+
message: `hasil prerender \`${pathname}\` memuat ${labels}`,
|
|
833
|
+
details: "HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali (PRD Addendum D.5).",
|
|
834
|
+
suggestions: [
|
|
835
|
+
"Jangan mengembalikan nilai env rahasia dari loader yang di-prerender",
|
|
836
|
+
"Matikan `prerender` untuk route ini atau pindahkan datanya ke serverFn request-time"
|
|
837
|
+
]
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const file = outputFile(options.clientDir, pathname);
|
|
841
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
842
|
+
writeFileSync2(file, html);
|
|
843
|
+
entries.push(typeof revalidate === "number" && revalidate >= 0 ? { path: pathname, revalidate } : { path: pathname });
|
|
844
|
+
written += 1;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
writeFileSync2(join2(options.clientDir, PRERENDER_MANIFEST_FILE), `${JSON.stringify({ version: 2, entries }, null, 2)}
|
|
848
|
+
`);
|
|
849
|
+
log(`[moons] prerender selesai: ${written} file (${skipped} dilewati)`);
|
|
850
|
+
return { written, skipped };
|
|
851
|
+
}
|
|
852
|
+
function routeKeyOf(routesDir, file) {
|
|
853
|
+
const relative = file.startsWith(routesDir) ? file.slice(routesDir.length + 1) : file;
|
|
854
|
+
return relative.split("\\").join("/").replace(/\.(tsx|ts)$/, "");
|
|
855
|
+
}
|
|
856
|
+
async function resolvePathnames(route, module) {
|
|
857
|
+
const isDynamic = route.params.length > 0;
|
|
858
|
+
if (!isDynamic) {
|
|
859
|
+
return [route.path];
|
|
860
|
+
}
|
|
861
|
+
const paths = module.paths;
|
|
862
|
+
if (typeof paths !== "function") {
|
|
863
|
+
throw new MoonsError2({
|
|
864
|
+
code: "E_PRERENDER_NO_PATHS",
|
|
865
|
+
file: route.file,
|
|
866
|
+
message: "route dynamic ber-`prerender = true` tanpa `export const paths`",
|
|
867
|
+
details: "Prerender butuh daftar param untuk merender tiap path (PRD Addendum D.5).",
|
|
868
|
+
suggestions: [
|
|
869
|
+
'Tambahkan `export const paths = () => [{ id: "…" }]`',
|
|
870
|
+
"Atau hapus `prerender` agar dirender SSR saat request"
|
|
871
|
+
]
|
|
872
|
+
});
|
|
873
|
+
}
|
|
874
|
+
const raw = await paths();
|
|
875
|
+
if (!Array.isArray(raw)) {
|
|
876
|
+
throw prerenderError(route, "`paths()` harus mengembalikan array");
|
|
877
|
+
}
|
|
878
|
+
const expected = new Set(route.params);
|
|
879
|
+
const result = [];
|
|
880
|
+
for (const item of raw) {
|
|
881
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
882
|
+
throw prerenderError(route, "item `paths()` harus objek param string");
|
|
883
|
+
}
|
|
884
|
+
const record = item;
|
|
885
|
+
const keys = Object.keys(record);
|
|
886
|
+
for (const name of expected) {
|
|
887
|
+
const value = record[name];
|
|
888
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
889
|
+
throw prerenderError(route, `param \`${name}\` wajib string non-kosong`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
for (const key of keys) {
|
|
893
|
+
if (!expected.has(key)) {
|
|
894
|
+
throw prerenderError(route, `param \`${key}\` tidak ada di pola route`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
result.push(buildPathname(route, record));
|
|
898
|
+
}
|
|
899
|
+
return result;
|
|
900
|
+
}
|
|
901
|
+
function buildPathname(route, params) {
|
|
902
|
+
const segments = route.path.split("/").filter((segment) => segment.length > 0);
|
|
903
|
+
const out = [];
|
|
904
|
+
let catchallName = null;
|
|
905
|
+
for (const segment of segments) {
|
|
906
|
+
if (segment === "*") {
|
|
907
|
+
catchallName = route.params[route.params.length - 1] ?? null;
|
|
908
|
+
const value = catchallName === null ? "" : params[catchallName] ?? "";
|
|
909
|
+
for (const part of value.split("/")) {
|
|
910
|
+
if (part.length === 0) {
|
|
911
|
+
continue;
|
|
912
|
+
}
|
|
913
|
+
out.push(encodeSegment(route, part));
|
|
914
|
+
}
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (segment.startsWith(":")) {
|
|
918
|
+
out.push(encodeSegment(route, params[segment.slice(1)] ?? ""));
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
out.push(segment);
|
|
922
|
+
}
|
|
923
|
+
return `/${out.join("/")}`;
|
|
924
|
+
}
|
|
925
|
+
function encodeSegment(route, value) {
|
|
926
|
+
if (value.length === 0 || /^\.+$/.test(value)) {
|
|
927
|
+
throw prerenderError(route, `nilai param \`${value}\` tidak valid untuk path file`);
|
|
928
|
+
}
|
|
929
|
+
return encodeURIComponent(value);
|
|
930
|
+
}
|
|
931
|
+
function outputFile(clientDir, pathname) {
|
|
932
|
+
const parts = pathname.split("/").filter((part) => part.length > 0);
|
|
933
|
+
const file = resolve2(join2(clientDir, ...parts, "index.html"));
|
|
934
|
+
const base = resolve2(clientDir);
|
|
935
|
+
if (file !== base && !file.startsWith(base + sep2)) {
|
|
936
|
+
throw new MoonsError2({
|
|
937
|
+
code: "E_PRERENDER_INVALID",
|
|
938
|
+
message: `path prerender keluar dari direktori client (\`${pathname}\`)`,
|
|
939
|
+
details: "Nama file harus berada di dalam `dist/client`.",
|
|
940
|
+
suggestions: ["Periksa nilai param `paths` — jangan memakai `..`"]
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
return file;
|
|
944
|
+
}
|
|
945
|
+
function prerenderError(route, message) {
|
|
946
|
+
return new MoonsError2({
|
|
947
|
+
code: "E_PRERENDER_INVALID",
|
|
948
|
+
file: route.file,
|
|
949
|
+
message,
|
|
950
|
+
details: "Kontrak prerender mengikuti PRD Addendum D.5.",
|
|
951
|
+
suggestions: [
|
|
952
|
+
"Perbaiki bentuk `paths`/param agar cocok dengan pola route",
|
|
953
|
+
"Jangan memakai `redirect()` pada route yang di-prerender"
|
|
954
|
+
]
|
|
955
|
+
});
|
|
956
|
+
}
|
|
414
957
|
export {
|
|
415
958
|
DEFAULT_TEMPLATE2 as DEFAULT_TEMPLATE,
|
|
416
959
|
ROOT_PLACEHOLDER2 as ROOT_PLACEHOLDER,
|
|
@@ -423,9 +966,10 @@ export {
|
|
|
423
966
|
installDevMiddleware2 as installDevMiddleware,
|
|
424
967
|
installGracefulShutdown,
|
|
425
968
|
resolveStaticFile,
|
|
969
|
+
runPrerender,
|
|
426
970
|
sendNodeResponse2 as sendNodeResponse,
|
|
427
971
|
startNodeServer,
|
|
428
972
|
toWebRequest2 as toWebRequest
|
|
429
973
|
};
|
|
430
974
|
|
|
431
|
-
//# debugId=
|
|
975
|
+
//# debugId=122CDE526B35801364756E2164756E21
|