@moonsjs/vite 0.1.3 → 0.1.5
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-1sm9pvvd.js → index-1fnt137k.js} +106 -18
- package/dist/index-1fnt137k.js.map +14 -0
- package/dist/{index-3ec9vpek.js → index-e7qz0epf.js} +232 -24
- package/dist/index-e7qz0epf.js.map +11 -0
- package/dist/index.js +220 -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 +4 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/handler.d.ts +21 -1
- package/dist/server/handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +584 -28
- package/dist/server/index.js.map +10 -7
- package/dist/server/node-server.d.ts +4 -1
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts.map +1 -1
- package/dist/server/plugins.d.ts +26 -0
- package/dist/server/plugins.d.ts.map +1 -0
- package/dist/server/prerender.d.ts +28 -0
- package/dist/server/prerender.d.ts.map +1 -0
- package/dist/server/shell.d.ts +3 -3
- package/dist/server/shell.d.ts.map +1 -1
- package/dist/server/shutdown.d.ts +6 -0
- package/dist/server/shutdown.d.ts.map +1 -1
- package/dist/server/test-server.d.ts.map +1 -1
- 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 +48 -12
- package/src/server/handler.ts +373 -14
- package/src/server/index.ts +6 -0
- package/src/server/node-server.ts +70 -16
- package/src/server/node.ts +17 -0
- package/src/server/plugins.ts +85 -0
- package/src/server/prerender.ts +230 -0
- package/src/server/shell.ts +62 -8
- package/src/server/shutdown.ts +19 -2
- package/src/server/test-server.ts +2 -1
- package/src/server-only.ts +220 -0
- package/src/transform.ts +138 -8
- package/src/virtual-modules.ts +1 -0
- package/dist/index-1sm9pvvd.js.map +0 -14
- package/dist/index-3ec9vpek.js.map +0 -10
package/src/server/handler.ts
CHANGED
|
@@ -1,19 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MoonsError,
|
|
3
3
|
composeMiddleware,
|
|
4
|
+
findApiDirMiddlewares,
|
|
5
|
+
findDirMiddlewares,
|
|
6
|
+
findErrorBoundary,
|
|
7
|
+
isNotFoundSignal,
|
|
8
|
+
isRedirectSignal,
|
|
4
9
|
resolveRoute,
|
|
5
10
|
routeKeyFromFile,
|
|
6
11
|
runLoaders,
|
|
12
|
+
type ApiMethod,
|
|
13
|
+
type ApiMiddlewareDefinition,
|
|
14
|
+
type ApiRouteDefinition,
|
|
15
|
+
type Middleware,
|
|
7
16
|
type MoonsConfig,
|
|
8
17
|
type ResolvedRoute,
|
|
9
18
|
type RouteDefinition,
|
|
10
19
|
type RouteModule,
|
|
11
20
|
} from "@moonsjs/core";
|
|
12
21
|
import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
|
|
22
|
+
import { API_METHODS, resolveApiRoute } from "@moonsjs/core";
|
|
13
23
|
import { renderToMarkup } from "@moonsjs/react/ssr";
|
|
14
24
|
import type { ClientRouteEntry, RouteModuleShape } from "@moonsjs/react/internal";
|
|
15
|
-
import { createRpcHandler, runWithContext } from "@moonsjs/server";
|
|
16
|
-
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";
|
|
17
28
|
import type { Shell } from "./shell";
|
|
18
29
|
|
|
19
30
|
const RPC_PREFIX = "/_moons/fn/";
|
|
@@ -28,6 +39,25 @@ export interface RequestHandlerOptions {
|
|
|
28
39
|
/** Peta import() modul route, key = routeKeyFromFile. */
|
|
29
40
|
loadRoute: Record<string, () => Promise<RouteModule>>;
|
|
30
41
|
shell: Shell;
|
|
42
|
+
/**
|
|
43
|
+
* Dipanggil setelah `config.context` per request (Addendum B.4: extender
|
|
44
|
+
* `extendContext` plugin di-merge ke ctx).
|
|
45
|
+
*/
|
|
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>>;
|
|
31
61
|
}
|
|
32
62
|
|
|
33
63
|
/**
|
|
@@ -36,13 +66,14 @@ export interface RequestHandlerOptions {
|
|
|
36
66
|
*/
|
|
37
67
|
export function createRequestHandler(
|
|
38
68
|
options: RequestHandlerOptions,
|
|
39
|
-
): (request: Request) => Promise<Response> {
|
|
40
|
-
const { routes, config, routesDir, loadRoute, shell } = options;
|
|
69
|
+
): (request: Request) => Promise<Response> { const { routes, config, routesDir, loadRoute, shell } = options;
|
|
41
70
|
const rpc = createRpcHandler(
|
|
42
71
|
config.security === undefined ? {} : { security: config.security },
|
|
43
72
|
);
|
|
44
73
|
const notFoundRoute = routes.find((route) => route.kind === "404");
|
|
45
74
|
const errorRoute = routes.find((route) => route.kind === "error");
|
|
75
|
+
/** A1: dedupe peringatan payload (sekali per label). */
|
|
76
|
+
const warnedPayloadLabels = new Set<string>();
|
|
46
77
|
|
|
47
78
|
async function renderRoute(
|
|
48
79
|
resolved: ResolvedRoute,
|
|
@@ -63,6 +94,8 @@ export function createRequestHandler(
|
|
|
63
94
|
const entry: ClientRouteEntry = { page: pageKey, layouts: keys.slice(0, -1) };
|
|
64
95
|
// Addendum A.3: query string ikut ke payload agar server/client identik.
|
|
65
96
|
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
|
97
|
+
// Addendum B.1: eksekusi `meta()` page (data = hasil loader page itu sendiri).
|
|
98
|
+
const meta = await runRouteMeta(pageModule, data[pageKey], resolved.route.file);
|
|
66
99
|
|
|
67
100
|
const markup = renderToMarkup(
|
|
68
101
|
entry,
|
|
@@ -76,9 +109,104 @@ export function createRequestHandler(
|
|
|
76
109
|
params: resolved.params,
|
|
77
110
|
searchParams,
|
|
78
111
|
data,
|
|
112
|
+
...(meta === undefined ? {} : { meta }),
|
|
79
113
|
});
|
|
80
114
|
|
|
81
|
-
|
|
115
|
+
// A1: pindai payload SSR (dev saja — `payloadScan` tidak diisi production).
|
|
116
|
+
warnPayloadFindings(payload, options.payloadScan, warnedPayloadLabels);
|
|
117
|
+
|
|
118
|
+
return htmlResponse(await shell(markup, payload, request.url, meta), 200);
|
|
119
|
+
}
|
|
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
|
+
}
|
|
82
210
|
}
|
|
83
211
|
|
|
84
212
|
/** §11.2: halaman `_404.tsx` kustom (standalone), fallback bawaan. */
|
|
@@ -110,20 +238,28 @@ export function createRequestHandler(
|
|
|
110
238
|
}
|
|
111
239
|
|
|
112
240
|
/**
|
|
113
|
-
* §11.2:
|
|
114
|
-
* 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).
|
|
115
243
|
*/
|
|
116
|
-
async function routeErrorResponse(
|
|
244
|
+
async function routeErrorResponse(
|
|
245
|
+
error: unknown,
|
|
246
|
+
request: Request,
|
|
247
|
+
resolved?: ResolvedRoute,
|
|
248
|
+
): Promise<Response> {
|
|
117
249
|
console.error("[moons] route error:", error);
|
|
118
250
|
|
|
119
|
-
|
|
251
|
+
const boundary =
|
|
252
|
+
(resolved === undefined ? null : findErrorBoundary(resolved.route, routes)) ??
|
|
253
|
+
errorRoute ??
|
|
254
|
+
null;
|
|
255
|
+
if (boundary !== null) {
|
|
120
256
|
try {
|
|
121
|
-
const key = routeKeyFromFile(routesDir,
|
|
257
|
+
const key = routeKeyFromFile(routesDir, boundary.file);
|
|
122
258
|
const [module] = await loadModules([key], loadRoute);
|
|
123
259
|
const isProduction = process.env["NODE_ENV"] === "production";
|
|
124
260
|
const serialized = isProduction ? undefined : serializeError(error);
|
|
125
261
|
const payload = encodeSsrPayload({
|
|
126
|
-
key
|
|
262
|
+
key,
|
|
127
263
|
params: {},
|
|
128
264
|
data: {},
|
|
129
265
|
...(serialized === undefined ? {} : { error: serialized }),
|
|
@@ -145,6 +281,68 @@ export function createRequestHandler(
|
|
|
145
281
|
return internalErrorResponse(error, { log: false });
|
|
146
282
|
}
|
|
147
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
|
+
|
|
148
346
|
return async function handler(request: Request): Promise<Response> {
|
|
149
347
|
try {
|
|
150
348
|
const url = new URL(request.url);
|
|
@@ -159,29 +357,112 @@ export function createRequestHandler(
|
|
|
159
357
|
// batas runtime ini. Tanpa cast, tsc app user yang memuat `@moonsjs/vite/server`
|
|
160
358
|
// (mis. `createTestServer` §19.5) melihat `unknown` vs `Ctx` global app.
|
|
161
359
|
const ctx = (await config.context(request)) as Ctx;
|
|
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
|
+
}
|
|
162
365
|
|
|
163
366
|
const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
|
|
164
367
|
if (url.pathname.startsWith(RPC_PREFIX)) {
|
|
165
368
|
return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
|
|
166
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
|
+
}
|
|
167
391
|
const resolved = resolveRoute(url.pathname, routes);
|
|
168
392
|
if (resolved === null) {
|
|
169
393
|
return await notFoundResponse(request);
|
|
170
394
|
}
|
|
171
395
|
try {
|
|
172
|
-
|
|
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);
|
|
173
408
|
} catch (error) {
|
|
174
|
-
|
|
409
|
+
// Addendum B.2: sinyal routing dari loader/meta.
|
|
410
|
+
if (isRedirectSignal(error)) {
|
|
411
|
+
return new Response(null, {
|
|
412
|
+
status: error.status,
|
|
413
|
+
headers: { location: new URL(error.to, request.url).toString() },
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
if (isNotFoundSignal(error)) {
|
|
417
|
+
return await notFoundResponse(request);
|
|
418
|
+
}
|
|
419
|
+
return await routeErrorResponse(error, request, resolved);
|
|
175
420
|
}
|
|
176
421
|
});
|
|
177
422
|
|
|
178
|
-
|
|
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
|
+
});
|
|
179
435
|
} catch (error) {
|
|
180
436
|
return internalErrorResponse(error);
|
|
181
437
|
}
|
|
182
438
|
};
|
|
183
439
|
}
|
|
184
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
|
+
|
|
185
466
|
async function loadModules(
|
|
186
467
|
keys: string[],
|
|
187
468
|
loadRoute: Record<string, () => Promise<RouteModule>>,
|
|
@@ -202,6 +483,59 @@ async function loadModules(
|
|
|
202
483
|
);
|
|
203
484
|
}
|
|
204
485
|
|
|
486
|
+
async function runRouteMeta(
|
|
487
|
+
pageModule: RouteModule,
|
|
488
|
+
pageData: unknown,
|
|
489
|
+
file: string,
|
|
490
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
491
|
+
const meta = (pageModule as { meta?: unknown }).meta;
|
|
492
|
+
if (meta === undefined) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
if (typeof meta !== "function") {
|
|
496
|
+
throw new MoonsError({
|
|
497
|
+
code: "E_INTERNAL",
|
|
498
|
+
file,
|
|
499
|
+
message: "export `meta` harus berupa fungsi `({ data }) => RouteMeta`",
|
|
500
|
+
details: "Bentuk `meta` mengikuti PRD §11.2 (Addendum B.1).",
|
|
501
|
+
suggestions: [
|
|
502
|
+
"Ubah menjadi `export const meta = ({ data }) => ({ title: \"...\" })`",
|
|
503
|
+
"Atau hapus export `meta`",
|
|
504
|
+
],
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
const result = await (meta as (args: { data: unknown }) => unknown)({ data: pageData });
|
|
508
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) {
|
|
509
|
+
throw new MoonsError({
|
|
510
|
+
code: "E_INTERNAL",
|
|
511
|
+
file,
|
|
512
|
+
message: "`meta()` harus mengembalikan objek RouteMeta",
|
|
513
|
+
details: "Nilai yang dikembalikan dipakai untuk <title>/description/og (Addendum B.1).",
|
|
514
|
+
suggestions: ["Kembalikan objek, mis. `({ title: \"Halaman\" })`"],
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
return result as Record<string, unknown>;
|
|
518
|
+
}
|
|
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
|
+
|
|
205
539
|
function internalErrorResponse(error: unknown, options: { log?: boolean } = {}): Response {
|
|
206
540
|
if (options.log !== false) {
|
|
207
541
|
console.error("[moons] request error:", error);
|
|
@@ -247,3 +581,28 @@ function escapeHtml(text: string): string {
|
|
|
247
581
|
.replaceAll(">", ">")
|
|
248
582
|
.replaceAll('"', """);
|
|
249
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
|
@@ -10,3 +10,9 @@ export { createTestServer } from "./test-server";
|
|
|
10
10
|
export type { TestServer, TestServerOptions } from "./test-server";
|
|
11
11
|
export { sendNodeResponse, toWebRequest } from "./node";
|
|
12
12
|
export { installDevMiddleware } from "./dev";
|
|
13
|
+
export { createPluginRuntime } from "./plugins";
|
|
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";
|
|
@@ -2,6 +2,7 @@ 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
4
|
import { sendNodeResponse, toWebRequest } from "./node";
|
|
5
|
+
import type { PluginRuntime } from "./plugins";
|
|
5
6
|
import { installGracefulShutdown } from "./shutdown";
|
|
6
7
|
|
|
7
8
|
const MIME_TYPES: Record<string, string> = {
|
|
@@ -33,6 +34,8 @@ export interface NodeServerOptions {
|
|
|
33
34
|
handleSignals?: boolean;
|
|
34
35
|
/** Batas drain shutdown (default 10_000 ms). */
|
|
35
36
|
shutdownTimeoutMs?: number;
|
|
37
|
+
/** Lifecycle plugin (Addendum B.4) — onStart/onReady/onShutdown. */
|
|
38
|
+
plugins?: PluginRuntime;
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
/**
|
|
@@ -73,17 +76,28 @@ export function startNodeServer(
|
|
|
73
76
|
})();
|
|
74
77
|
});
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// Addendum B.4: onStart sebelum listen, onReady setelah listen.
|
|
80
|
+
const listenAfterStart = (): void => {
|
|
81
|
+
server.listen(port, () => {
|
|
82
|
+
const address = server.address();
|
|
83
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
84
|
+
console.log(`[moons] server listening on http://localhost:${boundPort}`);
|
|
85
|
+
void options.plugins?.ready();
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
if (options.plugins !== undefined) {
|
|
89
|
+
void options.plugins.start().then(listenAfterStart);
|
|
90
|
+
} else {
|
|
91
|
+
listenAfterStart();
|
|
92
|
+
}
|
|
81
93
|
|
|
82
94
|
if (options.handleSignals !== false) {
|
|
83
|
-
const shutdown = installGracefulShutdown(
|
|
84
|
-
|
|
85
|
-
options.
|
|
86
|
-
|
|
95
|
+
const shutdown = installGracefulShutdown(server, {
|
|
96
|
+
...(options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs }),
|
|
97
|
+
...(options.plugins === undefined
|
|
98
|
+
? {}
|
|
99
|
+
: { onShutdown: () => options.plugins?.shutdown() }),
|
|
100
|
+
});
|
|
87
101
|
// `once`: sinyal kedua memakai perilaku default (proses langsung berhenti).
|
|
88
102
|
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
89
103
|
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
@@ -92,7 +106,7 @@ export function startNodeServer(
|
|
|
92
106
|
return server;
|
|
93
107
|
}
|
|
94
108
|
|
|
95
|
-
/** Resolve file static dengan proteksi path traversal. */
|
|
109
|
+
/** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
|
|
96
110
|
export function resolveStaticFile(clientDir: string, pathname: string): string | null {
|
|
97
111
|
let decoded: string;
|
|
98
112
|
try {
|
|
@@ -101,15 +115,55 @@ export function resolveStaticFile(clientDir: string, pathname: string): string |
|
|
|
101
115
|
return null;
|
|
102
116
|
}
|
|
103
117
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
118
|
+
// 1) File persis (aset: js/css/png/…, atau HTML yang diminta eksplisit).
|
|
119
|
+
const exact = safeWithin(clientDir, `.${decoded}`);
|
|
120
|
+
if (exact !== null && isFile(exact)) {
|
|
121
|
+
return exact;
|
|
107
122
|
}
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
|
|
124
|
+
// 2) Hasil prerender: hanya path yang tercatat di manifest yang dilayani
|
|
125
|
+
// statis — bukan shell `index.html` biasa (yang berarti bypass SSR).
|
|
126
|
+
const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
|
|
127
|
+
const manifest = readPrerenderManifest(clientDir);
|
|
128
|
+
if (manifest.has(normalized)) {
|
|
129
|
+
const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
|
|
130
|
+
const candidate = safeWithin(clientDir, `./${target}`);
|
|
131
|
+
if (candidate !== null && isFile(candidate)) {
|
|
132
|
+
return candidate;
|
|
133
|
+
}
|
|
110
134
|
}
|
|
111
|
-
|
|
135
|
+
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function safeWithin(clientDir: string, relative: string): string | null {
|
|
140
|
+
const candidate = resolve(clientDir, relative);
|
|
141
|
+
if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
|
|
112
142
|
return null;
|
|
113
143
|
}
|
|
114
144
|
return candidate;
|
|
115
145
|
}
|
|
146
|
+
|
|
147
|
+
function isFile(path: string): boolean {
|
|
148
|
+
return existsSync(path) && statSync(path).isFile();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
let prerenderManifestCache: { file: string; mtimeMs: number; paths: Set<string> } | null = null;
|
|
152
|
+
|
|
153
|
+
function readPrerenderManifest(clientDir: string): Set<string> {
|
|
154
|
+
const file = join(clientDir, "_moons_prerender.json");
|
|
155
|
+
try {
|
|
156
|
+
const mtimeMs = statSync(file).mtimeMs;
|
|
157
|
+
if (prerenderManifestCache !== null && prerenderManifestCache.file === file && prerenderManifestCache.mtimeMs === mtimeMs) {
|
|
158
|
+
return prerenderManifestCache.paths;
|
|
159
|
+
}
|
|
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;
|
|
166
|
+
} catch {
|
|
167
|
+
return new Set();
|
|
168
|
+
}
|
|
169
|
+
}
|
package/src/server/node.ts
CHANGED
|
@@ -54,8 +54,25 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
|
54
54
|
export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
|
|
55
55
|
res.statusCode = response.status;
|
|
56
56
|
response.headers.forEach((value, key) => {
|
|
57
|
+
// `set-cookie` ditangani terpisah: forEach menggabungkan nilai ganda
|
|
58
|
+
// menjadi satu string (cookie kedua hilang — D.2).
|
|
59
|
+
if (key === "set-cookie") {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
57
62
|
res.setHeader(key, value);
|
|
58
63
|
});
|
|
64
|
+
const getSetCookie = (response.headers as { getSetCookie?: () => string[] }).getSetCookie;
|
|
65
|
+
if (typeof getSetCookie === "function") {
|
|
66
|
+
const cookies = getSetCookie.call(response.headers);
|
|
67
|
+
if (cookies.length > 0) {
|
|
68
|
+
res.setHeader("set-cookie", cookies);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const single = response.headers.get("set-cookie");
|
|
72
|
+
if (single !== null) {
|
|
73
|
+
res.setHeader("set-cookie", single);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
59
76
|
const body = Buffer.from(await response.arrayBuffer());
|
|
60
77
|
res.end(body);
|
|
61
78
|
}
|