@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/src/server/handler.ts
CHANGED
|
@@ -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 {
|
|
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:
|
|
124
|
-
* Production tidak men-serialisasi error apa pun (§16.6)
|
|
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(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(">", ">")
|
|
303
582
|
.replaceAll('"', """);
|
|
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
|
+
}
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
+
|