@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/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,19 +8,50 @@ 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,
|
|
20
|
+
isNotFoundSignal,
|
|
21
|
+
isRedirectSignal,
|
|
18
22
|
resolveRoute,
|
|
19
23
|
routeKeyFromFile,
|
|
20
24
|
runLoaders
|
|
21
25
|
} from "@moonsjs/core";
|
|
22
26
|
import { encodeSsrPayload, reviveError, serializeError } from "@moonsjs/core/serialize";
|
|
27
|
+
import { API_METHODS, resolveApiRoute } from "@moonsjs/core";
|
|
23
28
|
import { renderToMarkup } from "@moonsjs/react/ssr";
|
|
24
|
-
import { createRpcHandler, runWithContext } 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
|
|
25
55
|
var RPC_PREFIX = "/_moons/fn/";
|
|
26
56
|
var HEALTH_PATH = "/_moons/health";
|
|
27
57
|
function createRequestHandler(options) {
|
|
@@ -29,6 +59,7 @@ function createRequestHandler(options) {
|
|
|
29
59
|
const rpc = createRpcHandler(config.security === undefined ? {} : { security: config.security });
|
|
30
60
|
const notFoundRoute = routes.find((route) => route.kind === "404");
|
|
31
61
|
const errorRoute = routes.find((route) => route.kind === "error");
|
|
62
|
+
const warnedPayloadLabels = new Set;
|
|
32
63
|
async function renderRoute(resolved, request, ctx) {
|
|
33
64
|
const entries = [...resolved.layouts, resolved.route];
|
|
34
65
|
const keys = entries.map((entry) => routeKeyFromFile(routesDir, entry.file));
|
|
@@ -38,14 +69,80 @@ function createRequestHandler(options) {
|
|
|
38
69
|
const pageModule = modules[modules.length - 1];
|
|
39
70
|
const entry = { page: pageKey, layouts: keys.slice(0, -1) };
|
|
40
71
|
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
|
72
|
+
const meta = await runRouteMeta(pageModule, data[pageKey], resolved.route.file);
|
|
41
73
|
const markup = renderToMarkup(entry, { page: pageModule, layouts: modules.slice(0, -1) }, resolved.params, data, searchParams);
|
|
42
74
|
const payload = encodeSsrPayload({
|
|
43
75
|
key: pageKey,
|
|
44
76
|
params: resolved.params,
|
|
45
77
|
searchParams,
|
|
46
|
-
data
|
|
78
|
+
data,
|
|
79
|
+
...meta === undefined ? {} : { meta }
|
|
47
80
|
});
|
|
48
|
-
|
|
81
|
+
warnPayloadFindings(payload, options.payloadScan, warnedPayloadLabels);
|
|
82
|
+
return htmlResponse(await shell(markup, payload, request.url, meta), 200);
|
|
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
|
+
}
|
|
49
146
|
}
|
|
50
147
|
async function notFoundResponse(request) {
|
|
51
148
|
if (notFoundRoute !== undefined) {
|
|
@@ -63,16 +160,17 @@ function createRequestHandler(options) {
|
|
|
63
160
|
const html = await shell("<h1>404</h1><p>Halaman tidak ditemukan.</p>", payload, request.url);
|
|
64
161
|
return htmlResponse(html, 404);
|
|
65
162
|
}
|
|
66
|
-
async function routeErrorResponse(error, request) {
|
|
163
|
+
async function routeErrorResponse(error, request, resolved) {
|
|
67
164
|
console.error("[moons] route error:", error);
|
|
68
|
-
|
|
165
|
+
const boundary = (resolved === undefined ? null : findErrorBoundary(resolved.route, routes)) ?? errorRoute ?? null;
|
|
166
|
+
if (boundary !== null) {
|
|
69
167
|
try {
|
|
70
|
-
const key = routeKeyFromFile(routesDir,
|
|
168
|
+
const key = routeKeyFromFile(routesDir, boundary.file);
|
|
71
169
|
const [module] = await loadModules([key], loadRoute);
|
|
72
170
|
const isProduction = process.env["NODE_ENV"] === "production";
|
|
73
171
|
const serialized = isProduction ? undefined : serializeError(error);
|
|
74
172
|
const payload = encodeSsrPayload({
|
|
75
|
-
key
|
|
173
|
+
key,
|
|
76
174
|
params: {},
|
|
77
175
|
data: {},
|
|
78
176
|
...serialized === undefined ? {} : { error: serialized }
|
|
@@ -85,6 +183,62 @@ function createRequestHandler(options) {
|
|
|
85
183
|
}
|
|
86
184
|
return internalErrorResponse(error, { log: false });
|
|
87
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
|
+
}
|
|
88
242
|
return async function handler(request) {
|
|
89
243
|
try {
|
|
90
244
|
const url = new URL(request.url);
|
|
@@ -92,26 +246,86 @@ function createRequestHandler(options) {
|
|
|
92
246
|
return healthResponse(request.method);
|
|
93
247
|
}
|
|
94
248
|
const ctx = await config.context(request);
|
|
249
|
+
options.onContext?.(ctx);
|
|
250
|
+
if (request.headers.get("x-moons-prerender") === "1") {
|
|
251
|
+
ctx["__prerender"] = true;
|
|
252
|
+
}
|
|
95
253
|
const respond = composeMiddleware(config.middleware ?? [], ctx, async () => {
|
|
96
254
|
if (url.pathname.startsWith(RPC_PREFIX)) {
|
|
97
255
|
return await rpc(request, url.pathname.slice(RPC_PREFIX.length), ctx);
|
|
98
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
|
+
}
|
|
99
268
|
const resolved = resolveRoute(url.pathname, routes);
|
|
100
269
|
if (resolved === null) {
|
|
101
270
|
return await notFoundResponse(request);
|
|
102
271
|
}
|
|
103
272
|
try {
|
|
104
|
-
|
|
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);
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (isRedirectSignal(error)) {
|
|
281
|
+
return new Response(null, {
|
|
282
|
+
status: error.status,
|
|
283
|
+
headers: { location: new URL(error.to, request.url).toString() }
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (isNotFoundSignal(error)) {
|
|
287
|
+
return await notFoundResponse(request);
|
|
288
|
+
}
|
|
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)));
|
|
105
296
|
} catch (error) {
|
|
106
|
-
|
|
297
|
+
response = internalErrorResponse(error);
|
|
107
298
|
}
|
|
299
|
+
return applyResponseMutations(response);
|
|
108
300
|
});
|
|
109
|
-
return await runWithContext(ctx, () => respond(request));
|
|
110
301
|
} catch (error) {
|
|
111
302
|
return internalErrorResponse(error);
|
|
112
303
|
}
|
|
113
304
|
};
|
|
114
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
|
+
}
|
|
115
329
|
async function loadModules(keys, loadRoute) {
|
|
116
330
|
return await Promise.all(keys.map(async (key) => {
|
|
117
331
|
const load = loadRoute[key];
|
|
@@ -126,6 +340,50 @@ async function loadModules(keys, loadRoute) {
|
|
|
126
340
|
return await load();
|
|
127
341
|
}));
|
|
128
342
|
}
|
|
343
|
+
async function runRouteMeta(pageModule, pageData, file) {
|
|
344
|
+
const meta = pageModule.meta;
|
|
345
|
+
if (meta === undefined) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (typeof meta !== "function") {
|
|
349
|
+
throw new MoonsError({
|
|
350
|
+
code: "E_INTERNAL",
|
|
351
|
+
file,
|
|
352
|
+
message: "export `meta` harus berupa fungsi `({ data }) => RouteMeta`",
|
|
353
|
+
details: "Bentuk `meta` mengikuti PRD §11.2 (Addendum B.1).",
|
|
354
|
+
suggestions: [
|
|
355
|
+
'Ubah menjadi `export const meta = ({ data }) => ({ title: "..." })`',
|
|
356
|
+
"Atau hapus export `meta`"
|
|
357
|
+
]
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
const result = await meta({ data: pageData });
|
|
361
|
+
if (result === null || typeof result !== "object" || Array.isArray(result)) {
|
|
362
|
+
throw new MoonsError({
|
|
363
|
+
code: "E_INTERNAL",
|
|
364
|
+
file,
|
|
365
|
+
message: "`meta()` harus mengembalikan objek RouteMeta",
|
|
366
|
+
details: "Nilai yang dikembalikan dipakai untuk <title>/description/og (Addendum B.1).",
|
|
367
|
+
suggestions: ['Kembalikan objek, mis. `({ title: "Halaman" })`']
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
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
|
+
}
|
|
129
387
|
function internalErrorResponse(error, options = {}) {
|
|
130
388
|
if (options.log !== false) {
|
|
131
389
|
console.error("[moons] request error:", error);
|
|
@@ -155,6 +413,19 @@ function healthResponse(method) {
|
|
|
155
413
|
function escapeHtml(text) {
|
|
156
414
|
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
157
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
|
+
}
|
|
158
429
|
// src/server/node-server.ts
|
|
159
430
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
160
431
|
import { createServer } from "node:http";
|
|
@@ -184,13 +455,22 @@ function installGracefulShutdown(server, options = {}) {
|
|
|
184
455
|
try {
|
|
185
456
|
server.closeIdleConnections?.();
|
|
186
457
|
} catch {}
|
|
187
|
-
server.close((error) => {
|
|
188
|
-
clearTimeout(timer);
|
|
458
|
+
server.close(async (error) => {
|
|
189
459
|
if (error !== undefined) {
|
|
460
|
+
clearTimeout(timer);
|
|
190
461
|
log(`[moons] penutupan server gagal: ${error.message}`);
|
|
191
462
|
exit(1);
|
|
192
463
|
return;
|
|
193
464
|
}
|
|
465
|
+
if (options.onShutdown !== undefined) {
|
|
466
|
+
timer.ref?.();
|
|
467
|
+
try {
|
|
468
|
+
await options.onShutdown();
|
|
469
|
+
} catch (hookError) {
|
|
470
|
+
log(`[moons] onShutdown gagal: ${String(hookError)}`);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
clearTimeout(timer);
|
|
194
474
|
log("[moons] shutdown selesai (drain penuh)");
|
|
195
475
|
exit(0);
|
|
196
476
|
});
|
|
@@ -246,13 +526,24 @@ function startNodeServer(handler, options = {}) {
|
|
|
246
526
|
}
|
|
247
527
|
})();
|
|
248
528
|
});
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
529
|
+
const listenAfterStart = () => {
|
|
530
|
+
server.listen(port, () => {
|
|
531
|
+
const address = server.address();
|
|
532
|
+
const boundPort = typeof address === "object" && address !== null ? address.port : port;
|
|
533
|
+
console.log(`[moons] server listening on http://localhost:${boundPort}`);
|
|
534
|
+
options.plugins?.ready();
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
if (options.plugins !== undefined) {
|
|
538
|
+
options.plugins.start().then(listenAfterStart);
|
|
539
|
+
} else {
|
|
540
|
+
listenAfterStart();
|
|
541
|
+
}
|
|
254
542
|
if (options.handleSignals !== false) {
|
|
255
|
-
const shutdown = installGracefulShutdown(server,
|
|
543
|
+
const shutdown = installGracefulShutdown(server, {
|
|
544
|
+
...options.shutdownTimeoutMs === undefined ? {} : { timeoutMs: options.shutdownTimeoutMs },
|
|
545
|
+
...options.plugins === undefined ? {} : { onShutdown: () => options.plugins?.shutdown() }
|
|
546
|
+
});
|
|
256
547
|
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
257
548
|
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
258
549
|
}
|
|
@@ -265,21 +556,50 @@ function resolveStaticFile(clientDir, pathname) {
|
|
|
265
556
|
} catch {
|
|
266
557
|
return null;
|
|
267
558
|
}
|
|
268
|
-
const
|
|
269
|
-
if (
|
|
270
|
-
return
|
|
559
|
+
const exact = safeWithin(clientDir, `.${decoded}`);
|
|
560
|
+
if (exact !== null && isFile(exact)) {
|
|
561
|
+
return exact;
|
|
271
562
|
}
|
|
272
|
-
|
|
273
|
-
|
|
563
|
+
const normalized = decoded.length > 1 ? decoded.replace(/\/+$/, "") : decoded;
|
|
564
|
+
const manifest = readPrerenderManifest(clientDir);
|
|
565
|
+
if (manifest.has(normalized)) {
|
|
566
|
+
const target = normalized === "/" ? "index.html" : `${normalized.slice(1)}/index.html`;
|
|
567
|
+
const candidate = safeWithin(clientDir, `./${target}`);
|
|
568
|
+
if (candidate !== null && isFile(candidate)) {
|
|
569
|
+
return candidate;
|
|
570
|
+
}
|
|
274
571
|
}
|
|
275
|
-
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
function safeWithin(clientDir, relative) {
|
|
575
|
+
const candidate = resolve(clientDir, relative);
|
|
576
|
+
if (candidate !== clientDir && !candidate.startsWith(clientDir + sep)) {
|
|
276
577
|
return null;
|
|
277
578
|
}
|
|
278
579
|
return candidate;
|
|
279
580
|
}
|
|
581
|
+
function isFile(path) {
|
|
582
|
+
return existsSync(path) && statSync(path).isFile();
|
|
583
|
+
}
|
|
584
|
+
var prerenderManifestCache = null;
|
|
585
|
+
function readPrerenderManifest(clientDir) {
|
|
586
|
+
const file = join(clientDir, "_moons_prerender.json");
|
|
587
|
+
try {
|
|
588
|
+
const mtimeMs = statSync(file).mtimeMs;
|
|
589
|
+
if (prerenderManifestCache !== null && prerenderManifestCache.file === file && prerenderManifestCache.mtimeMs === mtimeMs) {
|
|
590
|
+
return prerenderManifestCache.paths;
|
|
591
|
+
}
|
|
592
|
+
const json = JSON.parse(readFileSync(file, "utf8"));
|
|
593
|
+
const paths = new Set(Array.isArray(json.paths) ? json.paths.filter((path) => typeof path === "string") : []);
|
|
594
|
+
prerenderManifestCache = { file, mtimeMs, paths };
|
|
595
|
+
return paths;
|
|
596
|
+
} catch {
|
|
597
|
+
return new Set;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
280
600
|
// src/server/test-server.ts
|
|
281
601
|
function createTestServer(options, baseUrl = "http://localhost") {
|
|
282
|
-
const shell = options.shell ?? ((markup, payload) => injectShell2(DEFAULT_TEMPLATE2, markup, payload));
|
|
602
|
+
const shell = options.shell ?? ((markup, payload, _url, meta) => injectShell2(DEFAULT_TEMPLATE2, markup, payload, meta));
|
|
283
603
|
const handler = createRequestHandler({
|
|
284
604
|
routes: options.routes,
|
|
285
605
|
config: options.config,
|
|
@@ -292,20 +612,256 @@ function createTestServer(options, baseUrl = "http://localhost") {
|
|
|
292
612
|
fetch: (input, init) => handler(new Request(new URL(input, baseUrl), init))
|
|
293
613
|
};
|
|
294
614
|
}
|
|
615
|
+
// src/server/plugins.ts
|
|
616
|
+
import { MOONS_VERSION } from "@moonsjs/core";
|
|
617
|
+
function createPluginRuntime(options) {
|
|
618
|
+
const log = options.log ?? ((message) => console.warn(message));
|
|
619
|
+
const plugins = options.config.plugins ?? [];
|
|
620
|
+
const state = { ready: false, version: MOONS_VERSION };
|
|
621
|
+
const app = state;
|
|
622
|
+
const extensions = [];
|
|
623
|
+
const register = (fn) => {
|
|
624
|
+
extensions.push(fn);
|
|
625
|
+
};
|
|
626
|
+
for (const plugin of plugins) {
|
|
627
|
+
for (const route of options.routes) {
|
|
628
|
+
const frozen = Object.freeze({
|
|
629
|
+
...route,
|
|
630
|
+
params: [...route.params]
|
|
631
|
+
});
|
|
632
|
+
plugin.addRoute?.(frozen);
|
|
633
|
+
}
|
|
634
|
+
for (const middleware of options.middlewares) {
|
|
635
|
+
plugin.addMiddleware?.(middleware);
|
|
636
|
+
}
|
|
637
|
+
plugin.extendContext?.(register);
|
|
638
|
+
}
|
|
639
|
+
return {
|
|
640
|
+
app,
|
|
641
|
+
async start() {
|
|
642
|
+
for (const plugin of plugins) {
|
|
643
|
+
await plugin.onStart?.(app);
|
|
644
|
+
}
|
|
645
|
+
},
|
|
646
|
+
async ready() {
|
|
647
|
+
state.ready = true;
|
|
648
|
+
for (const plugin of plugins) {
|
|
649
|
+
await plugin.onReady?.(app);
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
async shutdown() {
|
|
653
|
+
for (const plugin of plugins) {
|
|
654
|
+
try {
|
|
655
|
+
await plugin.onShutdown?.(app);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
log(`[moons] plugin \`${plugin.name}\` gagal saat onShutdown: ${String(error)}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
},
|
|
661
|
+
applyExtensions(ctx) {
|
|
662
|
+
for (const extend of extensions) {
|
|
663
|
+
const result = extend(ctx);
|
|
664
|
+
if (result !== null && typeof result === "object" && !Array.isArray(result)) {
|
|
665
|
+
Object.assign(ctx, result);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
// src/server/prerender.ts
|
|
672
|
+
import { MoonsError as MoonsError2 } from "@moonsjs/core";
|
|
673
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
674
|
+
import { dirname, join as join2, resolve as resolve2, sep as sep2 } from "node:path";
|
|
675
|
+
var PRERENDER_MANIFEST_FILE = "_moons_prerender.json";
|
|
676
|
+
async function runPrerender(options) {
|
|
677
|
+
const log = options.log ?? ((message) => console.log(message));
|
|
678
|
+
const env = options.env ?? process.env;
|
|
679
|
+
let written = 0;
|
|
680
|
+
let skipped = 0;
|
|
681
|
+
const writtenPaths = [];
|
|
682
|
+
const pages = options.routes.filter((route) => route.kind === "page");
|
|
683
|
+
for (const route of pages) {
|
|
684
|
+
const key = routeKeyOf(options.routesDir, route.file);
|
|
685
|
+
const load = options.loadRoute[key];
|
|
686
|
+
if (load === undefined) {
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
const module = await load();
|
|
690
|
+
const prerender = module.prerender;
|
|
691
|
+
if (prerender !== true) {
|
|
692
|
+
if (prerender !== undefined && prerender !== false) {
|
|
693
|
+
throw prerenderError(route, "`prerender` harus boolean (`true`/`false`)");
|
|
694
|
+
}
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const pathnames = await resolvePathnames(route, module);
|
|
698
|
+
for (const pathname of pathnames) {
|
|
699
|
+
const response = await options.handler(new Request(`http://localhost${pathname}`, {
|
|
700
|
+
method: "GET",
|
|
701
|
+
headers: { "x-moons-prerender": "1" }
|
|
702
|
+
}));
|
|
703
|
+
if (response.status === 404) {
|
|
704
|
+
log(`[moons] prerender: lewati ${pathname} (notFound)`);
|
|
705
|
+
skipped += 1;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (response.status >= 300 && response.status < 400) {
|
|
709
|
+
throw prerenderError(route, `\`redirect()\` tidak didukung saat prerender (${pathname})`);
|
|
710
|
+
}
|
|
711
|
+
if (response.status !== 200) {
|
|
712
|
+
throw prerenderError(route, `status ${response.status} saat merender ${pathname}`);
|
|
713
|
+
}
|
|
714
|
+
const html = await response.text();
|
|
715
|
+
const findings = scanPayloadForSecrets(html, {
|
|
716
|
+
...options.canary === undefined ? {} : { canary: options.canary },
|
|
717
|
+
env
|
|
718
|
+
});
|
|
719
|
+
if (findings.length > 0) {
|
|
720
|
+
const labels = findings.map((finding) => finding.kind === "canary" ? "canary" : `env(${finding.label})`).join(", ");
|
|
721
|
+
throw new MoonsError2({
|
|
722
|
+
code: "E_PRERENDER_LEAK",
|
|
723
|
+
file: route.file,
|
|
724
|
+
message: `hasil prerender \`${pathname}\` memuat ${labels}`,
|
|
725
|
+
details: "HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali (PRD Addendum D.5).",
|
|
726
|
+
suggestions: [
|
|
727
|
+
"Jangan mengembalikan nilai env rahasia dari loader yang di-prerender",
|
|
728
|
+
"Matikan `prerender` untuk route ini atau pindahkan datanya ke serverFn request-time"
|
|
729
|
+
]
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
const file = outputFile(options.clientDir, pathname);
|
|
733
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
734
|
+
writeFileSync(file, html);
|
|
735
|
+
writtenPaths.push(pathname);
|
|
736
|
+
written += 1;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
writeFileSync(join2(options.clientDir, PRERENDER_MANIFEST_FILE), `${JSON.stringify({ paths: writtenPaths }, null, 2)}
|
|
740
|
+
`);
|
|
741
|
+
log(`[moons] prerender selesai: ${written} file (${skipped} dilewati)`);
|
|
742
|
+
return { written, skipped };
|
|
743
|
+
}
|
|
744
|
+
function routeKeyOf(routesDir, file) {
|
|
745
|
+
const relative = file.startsWith(routesDir) ? file.slice(routesDir.length + 1) : file;
|
|
746
|
+
return relative.split("\\").join("/").replace(/\.(tsx|ts)$/, "");
|
|
747
|
+
}
|
|
748
|
+
async function resolvePathnames(route, module) {
|
|
749
|
+
const isDynamic = route.params.length > 0;
|
|
750
|
+
if (!isDynamic) {
|
|
751
|
+
return [route.path];
|
|
752
|
+
}
|
|
753
|
+
const paths = module.paths;
|
|
754
|
+
if (typeof paths !== "function") {
|
|
755
|
+
throw new MoonsError2({
|
|
756
|
+
code: "E_PRERENDER_NO_PATHS",
|
|
757
|
+
file: route.file,
|
|
758
|
+
message: "route dynamic ber-`prerender = true` tanpa `export const paths`",
|
|
759
|
+
details: "Prerender butuh daftar param untuk merender tiap path (PRD Addendum D.5).",
|
|
760
|
+
suggestions: [
|
|
761
|
+
'Tambahkan `export const paths = () => [{ id: "…" }]`',
|
|
762
|
+
"Atau hapus `prerender` agar dirender SSR saat request"
|
|
763
|
+
]
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
const raw = await paths();
|
|
767
|
+
if (!Array.isArray(raw)) {
|
|
768
|
+
throw prerenderError(route, "`paths()` harus mengembalikan array");
|
|
769
|
+
}
|
|
770
|
+
const expected = new Set(route.params);
|
|
771
|
+
const result = [];
|
|
772
|
+
for (const item of raw) {
|
|
773
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
774
|
+
throw prerenderError(route, "item `paths()` harus objek param string");
|
|
775
|
+
}
|
|
776
|
+
const record = item;
|
|
777
|
+
const keys = Object.keys(record);
|
|
778
|
+
for (const name of expected) {
|
|
779
|
+
const value = record[name];
|
|
780
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
781
|
+
throw prerenderError(route, `param \`${name}\` wajib string non-kosong`);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
for (const key of keys) {
|
|
785
|
+
if (!expected.has(key)) {
|
|
786
|
+
throw prerenderError(route, `param \`${key}\` tidak ada di pola route`);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
result.push(buildPathname(route, record));
|
|
790
|
+
}
|
|
791
|
+
return result;
|
|
792
|
+
}
|
|
793
|
+
function buildPathname(route, params) {
|
|
794
|
+
const segments = route.path.split("/").filter((segment) => segment.length > 0);
|
|
795
|
+
const out = [];
|
|
796
|
+
let catchallName = null;
|
|
797
|
+
for (const segment of segments) {
|
|
798
|
+
if (segment === "*") {
|
|
799
|
+
catchallName = route.params[route.params.length - 1] ?? null;
|
|
800
|
+
const value = catchallName === null ? "" : params[catchallName] ?? "";
|
|
801
|
+
for (const part of value.split("/")) {
|
|
802
|
+
if (part.length === 0) {
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
out.push(encodeSegment(route, part));
|
|
806
|
+
}
|
|
807
|
+
continue;
|
|
808
|
+
}
|
|
809
|
+
if (segment.startsWith(":")) {
|
|
810
|
+
out.push(encodeSegment(route, params[segment.slice(1)] ?? ""));
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
out.push(segment);
|
|
814
|
+
}
|
|
815
|
+
return `/${out.join("/")}`;
|
|
816
|
+
}
|
|
817
|
+
function encodeSegment(route, value) {
|
|
818
|
+
if (value.length === 0 || /^\.+$/.test(value)) {
|
|
819
|
+
throw prerenderError(route, `nilai param \`${value}\` tidak valid untuk path file`);
|
|
820
|
+
}
|
|
821
|
+
return encodeURIComponent(value);
|
|
822
|
+
}
|
|
823
|
+
function outputFile(clientDir, pathname) {
|
|
824
|
+
const parts = pathname.split("/").filter((part) => part.length > 0);
|
|
825
|
+
const file = resolve2(join2(clientDir, ...parts, "index.html"));
|
|
826
|
+
const base = resolve2(clientDir);
|
|
827
|
+
if (file !== base && !file.startsWith(base + sep2)) {
|
|
828
|
+
throw new MoonsError2({
|
|
829
|
+
code: "E_PRERENDER_INVALID",
|
|
830
|
+
message: `path prerender keluar dari direktori client (\`${pathname}\`)`,
|
|
831
|
+
details: "Nama file harus berada di dalam `dist/client`.",
|
|
832
|
+
suggestions: ["Periksa nilai param `paths` — jangan memakai `..`"]
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
return file;
|
|
836
|
+
}
|
|
837
|
+
function prerenderError(route, message) {
|
|
838
|
+
return new MoonsError2({
|
|
839
|
+
code: "E_PRERENDER_INVALID",
|
|
840
|
+
file: route.file,
|
|
841
|
+
message,
|
|
842
|
+
details: "Kontrak prerender mengikuti PRD Addendum D.5.",
|
|
843
|
+
suggestions: [
|
|
844
|
+
"Perbaiki bentuk `paths`/param agar cocok dengan pola route",
|
|
845
|
+
"Jangan memakai `redirect()` pada route yang di-prerender"
|
|
846
|
+
]
|
|
847
|
+
});
|
|
848
|
+
}
|
|
295
849
|
export {
|
|
296
850
|
DEFAULT_TEMPLATE2 as DEFAULT_TEMPLATE,
|
|
297
851
|
ROOT_PLACEHOLDER2 as ROOT_PLACEHOLDER,
|
|
298
852
|
createDevShell2 as createDevShell,
|
|
299
853
|
createFileShell2 as createFileShell,
|
|
854
|
+
createPluginRuntime,
|
|
300
855
|
createRequestHandler,
|
|
301
856
|
createTestServer,
|
|
302
857
|
injectShell2 as injectShell,
|
|
303
858
|
installDevMiddleware2 as installDevMiddleware,
|
|
304
859
|
installGracefulShutdown,
|
|
305
860
|
resolveStaticFile,
|
|
861
|
+
runPrerender,
|
|
306
862
|
sendNodeResponse2 as sendNodeResponse,
|
|
307
863
|
startNodeServer,
|
|
308
864
|
toWebRequest2 as toWebRequest
|
|
309
865
|
};
|
|
310
866
|
|
|
311
|
-
//# debugId=
|
|
867
|
+
//# debugId=C7C001D56BF6021C64756E2164756E21
|