@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.
Files changed (60) hide show
  1. package/dist/api.d.ts +8 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/{index-1sm9pvvd.js → index-1fnt137k.js} +106 -18
  4. package/dist/index-1fnt137k.js.map +14 -0
  5. package/dist/{index-3ec9vpek.js → index-e7qz0epf.js} +232 -24
  6. package/dist/index-e7qz0epf.js.map +11 -0
  7. package/dist/index.js +220 -19
  8. package/dist/index.js.map +5 -4
  9. package/dist/manifest.d.ts +6 -1
  10. package/dist/manifest.d.ts.map +1 -1
  11. package/dist/payload-scan.d.ts +18 -0
  12. package/dist/payload-scan.d.ts.map +1 -0
  13. package/dist/plugin.d.ts.map +1 -1
  14. package/dist/server/dev.d.ts +4 -0
  15. package/dist/server/dev.d.ts.map +1 -1
  16. package/dist/server/handler.d.ts +21 -1
  17. package/dist/server/handler.d.ts.map +1 -1
  18. package/dist/server/index.d.ts +6 -0
  19. package/dist/server/index.d.ts.map +1 -1
  20. package/dist/server/index.js +584 -28
  21. package/dist/server/index.js.map +10 -7
  22. package/dist/server/node-server.d.ts +4 -1
  23. package/dist/server/node-server.d.ts.map +1 -1
  24. package/dist/server/node.d.ts.map +1 -1
  25. package/dist/server/plugins.d.ts +26 -0
  26. package/dist/server/plugins.d.ts.map +1 -0
  27. package/dist/server/prerender.d.ts +28 -0
  28. package/dist/server/prerender.d.ts.map +1 -0
  29. package/dist/server/shell.d.ts +3 -3
  30. package/dist/server/shell.d.ts.map +1 -1
  31. package/dist/server/shutdown.d.ts +6 -0
  32. package/dist/server/shutdown.d.ts.map +1 -1
  33. package/dist/server/test-server.d.ts.map +1 -1
  34. package/dist/server-only.d.ts +57 -0
  35. package/dist/server-only.d.ts.map +1 -0
  36. package/dist/transform.d.ts +9 -1
  37. package/dist/transform.d.ts.map +1 -1
  38. package/dist/transform.js +1 -1
  39. package/dist/virtual-modules.d.ts +1 -0
  40. package/dist/virtual-modules.d.ts.map +1 -1
  41. package/package.json +1 -1
  42. package/src/api.ts +124 -0
  43. package/src/manifest.ts +42 -1
  44. package/src/payload-scan.ts +53 -0
  45. package/src/plugin.ts +137 -10
  46. package/src/server/dev.ts +48 -12
  47. package/src/server/handler.ts +373 -14
  48. package/src/server/index.ts +6 -0
  49. package/src/server/node-server.ts +70 -16
  50. package/src/server/node.ts +17 -0
  51. package/src/server/plugins.ts +85 -0
  52. package/src/server/prerender.ts +230 -0
  53. package/src/server/shell.ts +62 -8
  54. package/src/server/shutdown.ts +19 -2
  55. package/src/server/test-server.ts +2 -1
  56. package/src/server-only.ts +220 -0
  57. package/src/transform.ts +138 -8
  58. package/src/virtual-modules.ts +1 -0
  59. package/dist/index-1sm9pvvd.js.map +0 -14
  60. package/dist/index-3ec9vpek.js.map +0 -10
package/src/api.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { parse } from "@babel/parser";
2
+ import { API_METHODS, MoonsError } from "@moonsjs/core";
3
+
4
+ /**
5
+ * Validasi statis export modul `src/api` (PRD Addendum D.1):
6
+ * hanya handler method `GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS` sebagai fungsi;
7
+ * `default`/export lain ditolak → `E_API_HANDLER_INVALID`.
8
+ */
9
+ export function assertApiModuleExports(code: string, file: string): string[] {
10
+ let ast: unknown;
11
+ try {
12
+ ast = parse(code, {
13
+ sourceType: "module",
14
+ plugins: file.endsWith(".tsx") ? ["typescript", "jsx"] : ["typescript"],
15
+ });
16
+ } catch {
17
+ return []; // biarkan bundler melaporkan syntax error
18
+ }
19
+
20
+ const program = (ast as { program?: { body?: Array<Record<string, unknown>> } }).program;
21
+ const body = program?.body ?? [];
22
+ const methods: string[] = [];
23
+
24
+ for (const statement of body) {
25
+ if (statement["type"] === "ExportDefaultDeclaration") {
26
+ throw apiError(
27
+ file,
28
+ "export `default` tidak didukung di modul API",
29
+ "Modul `src/api` hanya mengekspor handler method bernama (PRD Addendum D.1).",
30
+ ["Ganti menjadi `export const GET = (args) => …`", "Hapus export default"],
31
+ );
32
+ }
33
+ if (statement["type"] !== "ExportNamedDeclaration") {
34
+ continue;
35
+ }
36
+ if (statement["source"] !== undefined) {
37
+ // re-export `export { X } from "..."` — nama tetap dicek di bawah.
38
+ }
39
+ const declaration = statement["declaration"] as Record<string, unknown> | undefined;
40
+ if (declaration !== undefined) {
41
+ if (declaration["type"] === "VariableDeclaration") {
42
+ for (const declarator of (declaration["declarations"] ?? []) as Array<Record<string, unknown>>) {
43
+ const id = declarator["id"] as Record<string, unknown> | undefined;
44
+ if (id?.["type"] === "Identifier") {
45
+ methods.push(id["name"] as string);
46
+ }
47
+ }
48
+ } else if (declaration["type"] === "FunctionDeclaration") {
49
+ const id = declaration["id"] as Record<string, unknown> | undefined;
50
+ if (id?.["type"] === "Identifier") {
51
+ methods.push(id["name"] as string);
52
+ }
53
+ }
54
+ continue;
55
+ }
56
+ for (const specifier of (statement["specifiers"] ?? []) as Array<Record<string, unknown>>) {
57
+ const exported = specifier["exported"] as Record<string, unknown> | undefined;
58
+ if (exported?.["type"] === "Identifier") {
59
+ methods.push(exported["name"] as string);
60
+ }
61
+ }
62
+ }
63
+
64
+ const illegal = methods.filter((name) => !API_METHODS.includes(name as never));
65
+ if (illegal.length > 0) {
66
+ throw apiError(
67
+ file,
68
+ `export \`${illegal[0]}\` bukan handler method yang dikenal`,
69
+ `Handler yang legal: ${API_METHODS.join(", ")} (PRD Addendum D.1).`,
70
+ [
71
+ "Ganti nama export menjadi salah satu method HTTP",
72
+ "Pindahkan helper ke modul lain (mis. `src/server/…`)",
73
+ ],
74
+ );
75
+ }
76
+ if (methods.length === 0) {
77
+ throw apiError(
78
+ file,
79
+ "modul API tanpa handler method",
80
+ `Wajib ada minimal satu dari ${API_METHODS.join(", ")} (PRD Addendum D.1).`,
81
+ [`Tambahkan \`export const GET = (args) => new Response(…)\``],
82
+ );
83
+ }
84
+
85
+ return methods;
86
+ }
87
+
88
+ export function assertMiddlewareExports(code: string, file: string): void {
89
+ let ast: unknown;
90
+ try {
91
+ ast = parse(code, {
92
+ sourceType: "module",
93
+ plugins: file.endsWith(".tsx") ? ["typescript", "jsx"] : ["typescript"],
94
+ });
95
+ } catch {
96
+ return;
97
+ }
98
+ const program = (ast as { program?: { body?: Array<Record<string, unknown>> } }).program;
99
+ const hasDefault = (program?.body ?? []).some(
100
+ (statement) => statement["type"] === "ExportDefaultDeclaration",
101
+ );
102
+ if (!hasDefault) {
103
+ throw new MoonsError({
104
+ code: "E_MIDDLEWARE_INVALID",
105
+ file,
106
+ message: "`_middleware.ts` tanpa `export default`",
107
+ details: "Middleware direktori mengikuti bentuk `Middleware`: `(args) => Promise<Response>` (PRD Addendum D.3).",
108
+ suggestions: [
109
+ "Tambahkan `export default async ({ req, ctx, next }) => await next()`",
110
+ "Hapus file bila tidak dipakai",
111
+ ],
112
+ });
113
+ }
114
+ }
115
+
116
+ function apiError(file: string, message: string, details: string, suggestions: string[]): MoonsError {
117
+ return new MoonsError({
118
+ code: "E_API_HANDLER_INVALID",
119
+ file,
120
+ message,
121
+ details,
122
+ suggestions,
123
+ });
124
+ }
package/src/manifest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { findLayouts, routeKeyFromFile, type RouteDefinition } from "@moonsjs/core";
1
+ import { findLayouts, routeKeyFromFile, type ApiRouteDefinition, type RouteDefinition } from "@moonsjs/core";
2
2
  import { relative, sep } from "node:path";
3
3
 
4
4
  export interface ClientManifestEntry {
@@ -38,10 +38,51 @@ export function buildClientManifest(
38
38
  if (errorRoute !== undefined) {
39
39
  manifest[ERROR_ROUTE_KEY] = { page: routeKeyFromFile(routesDir, errorRoute.file), layouts: [] };
40
40
  }
41
+ // D.3: semua boundary `_error` (termasuk bersarang) ikut manifest — payload
42
+ // memakai key file boundary yang dipilih runtime.
43
+ for (const route of routes) {
44
+ if (route.kind !== "error") {
45
+ continue;
46
+ }
47
+ const key = routeKeyFromFile(routesDir, route.file);
48
+ manifest[key] = { page: key, layouts: [] };
49
+ }
41
50
 
42
51
  return manifest;
43
52
  }
44
53
 
54
+ /** `virtual:moons/api` (Addendum D.1 + D.3): tabel API + middleware + peta import(). */
55
+ export function renderApiModule(
56
+ apiRoutes: ApiRouteDefinition[],
57
+ apiMiddlewares: ReadonlyArray<{ file: string; dir: string }>,
58
+ projectRoot: string,
59
+ ): string {
60
+ const loadEntries = apiRoutes
61
+ .map(
62
+ (route) =>
63
+ ` ${JSON.stringify(route.path)}: () => import(${JSON.stringify(rootSpecifier(projectRoot, route.file))}),`,
64
+ )
65
+ .join("\n");
66
+ const middlewareEntries = apiMiddlewares
67
+ .map(
68
+ (middleware) =>
69
+ ` ${JSON.stringify(middleware.file)}: () => import(${JSON.stringify(rootSpecifier(projectRoot, middleware.file))}),`,
70
+ )
71
+ .join("\n");
72
+ return [
73
+ "// Generated by @moons. Jangan diubah manual.",
74
+ `export const apiRoutes = ${JSON.stringify(apiRoutes, null, 2)};`,
75
+ `export const apiMiddlewares = ${JSON.stringify(apiMiddlewares, null, 2)};`,
76
+ "export const loadApi = {",
77
+ loadEntries,
78
+ "};",
79
+ "export const loadApiMiddleware = {",
80
+ middlewareEntries,
81
+ "};",
82
+ "",
83
+ ].join("\n");
84
+ }
85
+
45
86
  /**
46
87
  * Isi `virtual:moons/routes`: daftar RouteDefinition, manifest client, dan
47
88
  * peta import() dinamis per file route (specifier root-relative).
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Pemindaian payload SSR (nilai A1 / PRD §2): HANYA dipakai di dev sebagai
3
+ * peringatan — tidak ada biaya runtime di production (production tidak pernah
4
+ * memanggil fungsi ini; lihat `RequestHandlerOptions.payloadScan`).
5
+ */
6
+
7
+ export interface PayloadScanOptions {
8
+ /** Canary `leakTest.canary` (bila dikonfigurasi). */
9
+ canary?: string;
10
+ /** Lingkungan server (biasanya `process.env`). */
11
+ env?: Record<string, string | undefined>;
12
+ }
13
+
14
+ export interface PayloadScanFinding {
15
+ kind: "canary" | "env";
16
+ /** Label untuk pesan: `canary` atau nama env var. */
17
+ label: string;
18
+ }
19
+
20
+ /**
21
+ * Nama env yang "mirip rahasia" — dideteksi lewat pola nama + panjang nilai
22
+ * minimum 12 karakter untuk menghindari false positive (keputusan pemilik).
23
+ */
24
+ const SECRET_KEY_PATTERN =
25
+ /(SECRET|TOKEN|PASSWORD|PASSWD|PRIVATE|CREDENTIAL|AUTH|API_KEY|ACCESS_KEY|SIGNING)/i;
26
+
27
+ const MIN_SECRET_LENGTH = 12;
28
+
29
+ export function scanPayloadForSecrets(
30
+ payload: string,
31
+ options: PayloadScanOptions = {},
32
+ ): PayloadScanFinding[] {
33
+ const findings: PayloadScanFinding[] = [];
34
+
35
+ const canary = options.canary;
36
+ if (canary !== undefined && canary.length > 0 && payload.includes(canary)) {
37
+ findings.push({ kind: "canary", label: "canary" });
38
+ }
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
+
52
+ return findings;
53
+ }
package/src/plugin.ts CHANGED
@@ -5,13 +5,19 @@ import {
5
5
  MoonsError,
6
6
  fnRegistryKey,
7
7
  generateTypes,
8
+ scanApiMiddlewares,
9
+ scanApiRoutes,
8
10
  scanRoutes,
9
11
  validateConfig,
12
+ type ApiMiddlewareDefinition,
13
+ type ApiRouteDefinition,
10
14
  type RouteDefinition,
11
15
  } from "@moonsjs/core";
12
16
  import { loadConfigFromFile, type Plugin, type ViteDevServer } from "vite";
17
+ import { assertApiModuleExports, assertMiddlewareExports } from "./api";
13
18
  import { findLeak } from "./leak-check";
14
- import { renderRoutesModule, rootSpecifier } from "./manifest";
19
+ import type { PackageJsonInfo } from "./server-only";
20
+ import { renderApiModule, renderRoutesModule, rootSpecifier } from "./manifest";
15
21
  import { installDevMiddleware } from "./server/dev";
16
22
  import {
17
23
  analyzeServerFile,
@@ -49,7 +55,12 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
49
55
  let isSsrBuild = false;
50
56
  let configFilePath = "";
51
57
  let routesDir = "";
52
- let routes: RouteDefinition[] = [];
58
+ let serverOnlyPackages: ReadonlyArray<string> = [];
59
+ let apiDir = "";
60
+ let apiRoutes: ApiRouteDefinition[] = [];
61
+ let apiMiddlewares: ApiMiddlewareDefinition[] = [];
62
+ /** Cache package.json untuk gate Addendum C (dibagi lintas modul). */
63
+ const packageCache = new Map<string, PackageJsonInfo | null>(); let routes: RouteDefinition[] = [];
53
64
  let analysisCache = new Map<string, ServerFileAnalysis>();
54
65
  const stubTransformed = new Set<string>();
55
66
 
@@ -163,8 +174,18 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
163
174
  emptyOutDir: true,
164
175
  // Entry nyata yang di-generate saat buildStart (Rolldown butuh file,
165
176
  // entry virtual tidak di-resolve lewat plugin container).
166
- ssr: ".moons/server-entry.ts",
167
- rollupOptions: { output: { entryFileNames: "index.js", codeSplitting: false } },
177
+ // D.5: dua entry — server (index) + prerender runner.
178
+ ssr: true,
179
+ rollupOptions: {
180
+ input: {
181
+ index: ".moons/server-entry.ts",
182
+ prerender: ".moons/prerender-entry.ts",
183
+ },
184
+ output: {
185
+ entryFileNames: "[name].js",
186
+ chunkFileNames: "chunks/[name]-[hash].js",
187
+ },
188
+ },
168
189
  },
169
190
  ssr: { noExternal: true, target: "node" as const },
170
191
  };
@@ -223,10 +244,31 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
223
244
 
224
245
  const config = validateConfig(raw, { file: configFilePath, projectRoot: root });
225
246
  routesDir = resolvePath(root, config.routesDir);
247
+ serverOnlyPackages = config.security?.serverOnlyPackages ?? [];
248
+
249
+ // Addendum D.1: scan + validasi statis modul `src/api`.
250
+ apiDir = resolvePath(root, config.apiDir ?? "src/api");
251
+ if (existsSync(apiDir)) {
252
+ apiRoutes = scanApiRoutes(apiDir);
253
+ for (const route of apiRoutes) {
254
+ assertApiModuleExports(readFileSync(route.file, "utf8"), route.file);
255
+ }
256
+ apiMiddlewares = scanApiMiddlewares(apiDir);
257
+ for (const middleware of apiMiddlewares) {
258
+ assertMiddlewareExports(readFileSync(middleware.file, "utf8"), middleware.file);
259
+ }
260
+ } else {
261
+ apiRoutes = [];
262
+ apiMiddlewares = [];
263
+ }
226
264
  },
227
265
 
228
266
  async buildStart() {
229
267
  routes = scanRoutes(routesDir);
268
+ // D.3: validasi statis `_middleware.ts` pohon routes.
269
+ for (const middleware of routes.filter((route) => route.kind === "middleware")) {
270
+ assertMiddlewareExports(readFileSync(middleware.file, "utf8"), middleware.file);
271
+ }
230
272
  await generateTypes({
231
273
  routesDir,
232
274
  configPath: configFilePath,
@@ -239,6 +281,11 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
239
281
  renderServerEntryBuild(routesDir, serverFnFileSpecifiers()),
240
282
  "utf8",
241
283
  );
284
+ writeFileSync(
285
+ resolvePath(root, ".moons/prerender-entry.ts"),
286
+ renderPrerenderEntry(routesDir, root, options.leakTest?.canary),
287
+ "utf8",
288
+ );
242
289
  }
243
290
  },
244
291
 
@@ -258,6 +305,8 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
258
305
  switch (name) {
259
306
  case VIRTUAL_MODULES.routes:
260
307
  return renderRoutesModule(routes, routesDir, root);
308
+ case VIRTUAL_MODULES.api:
309
+ return renderApiModule(apiRoutes, apiMiddlewares, root);
261
310
  case VIRTUAL_MODULES.clientEntry:
262
311
  return '// Generated by @moons. Jangan diubah manual.\nexport { clientManifest, loadRoute } from "virtual:moons/routes";\n';
263
312
  case VIRTUAL_MODULES.config:
@@ -316,6 +365,9 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
316
365
  }
317
366
  },
318
367
  analyze: (absoluteFile) => analysisCache.get(absoluteFile) ?? analyzeFile(absoluteFile),
368
+ apiDir,
369
+ serverOnlyPackages,
370
+ packageCache,
319
371
  });
320
372
  }
321
373
 
@@ -369,7 +421,18 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
369
421
  },
370
422
 
371
423
  configureServer(server) {
372
- installDevMiddleware(server, { templatePath: resolvePath(root, "index.html") });
424
+ installDevMiddleware(server, {
425
+ templatePath: resolvePath(root, "index.html"),
426
+ // A1: pemindaian payload SSR hanya di dev (warning), tanpa biaya di production.
427
+ payloadScan: {
428
+ ...(options.leakTest?.canary === undefined ? {} : { canary: options.leakTest.canary }),
429
+ env: process.env,
430
+ },
431
+ // D.1: request ke prefix API (semua method) dirutekan ke runtime.
432
+ ...(apiRoutes.length === 0
433
+ ? {}
434
+ : { apiPrefix: `/${apiDir.split(/[\\/]/).filter((part) => part.length > 0).pop() ?? "api"}` }),
435
+ });
373
436
  },
374
437
 
375
438
  async handleHotUpdate(context) {
@@ -379,12 +442,34 @@ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
379
442
  return;
380
443
  }
381
444
 
445
+ if (context.file.startsWith(apiDir) && existsSync(apiDir)) {
446
+ try {
447
+ apiRoutes = scanApiRoutes(apiDir);
448
+ for (const route of apiRoutes) {
449
+ assertApiModuleExports(readFileSync(route.file, "utf8"), route.file);
450
+ }
451
+ apiMiddlewares = scanApiMiddlewares(apiDir);
452
+ for (const middleware of apiMiddlewares) {
453
+ assertMiddlewareExports(readFileSync(middleware.file, "utf8"), middleware.file);
454
+ }
455
+ } catch (error) {
456
+ context.server.config.logger.error(String(error));
457
+ return;
458
+ }
459
+ invalidateVirtualModules(context.server);
460
+ context.server.ws.send({ type: "full-reload" });
461
+ return;
462
+ }
463
+
382
464
  if (!context.file.startsWith(routesDir)) {
383
465
  return;
384
466
  }
385
467
 
386
468
  try {
387
469
  routes = scanRoutes(routesDir);
470
+ for (const middleware of routes.filter((route) => route.kind === "middleware")) {
471
+ assertMiddlewareExports(readFileSync(middleware.file, "utf8"), middleware.file);
472
+ }
388
473
  await generateTypes({
389
474
  routesDir,
390
475
  configPath: configFilePath,
@@ -414,13 +499,16 @@ function renderConfigModule(root: string, configFilePath: string): string {
414
499
  function renderServerEntryDev(routesDir: string, eagerImports: string[]): string {
415
500
  return [
416
501
  "// Generated by @moons. Jangan diubah manual.",
417
- 'import { createRequestHandler } from "@moonsjs/vite/server";',
502
+ 'import { createPluginRuntime, createRequestHandler } from "@moonsjs/vite/server";',
418
503
  'import config from "virtual:moons/config";',
419
504
  'import { routes, loadRoute } from "virtual:moons/routes";',
505
+ 'import { apiRoutes, apiMiddlewares, loadApi, loadApiMiddleware } from "virtual:moons/api";',
420
506
  ...eagerImports.map((specifier) => `import ${JSON.stringify(specifier)};`),
421
507
  "",
422
- "export function createDevHandler(shell) {",
423
- ` return createRequestHandler({ routes, config, routesDir: ${JSON.stringify(routesDir)}, loadRoute, shell });`,
508
+ "export function createDevApp(shell, payloadScan) {",
509
+ " const plugins = createPluginRuntime({ config, routes, middlewares: config.middleware ?? [] });",
510
+ ` const handler = createRequestHandler({ routes, apiRoutes, apiMiddlewares, loadApi, loadApiMiddleware, config, routesDir: ${JSON.stringify(routesDir)}, loadRoute, shell, onContext: (ctx) => plugins.applyExtensions(ctx), ...(payloadScan === undefined ? {} : { payloadScan }) });`,
511
+ " return { handler, plugins };",
424
512
  "}",
425
513
  "",
426
514
  ].join("\n");
@@ -429,20 +517,27 @@ function renderServerEntryDev(routesDir: string, eagerImports: string[]): string
429
517
  function renderServerEntryBuild(routesDir: string, eagerImports: string[]): string {
430
518
  return [
431
519
  "// Generated by @moons. Jangan diubah manual.",
432
- 'import { createFileShell, createRequestHandler, startNodeServer } from "@moonsjs/vite/server";',
520
+ 'import { createFileShell, createPluginRuntime, createRequestHandler, startNodeServer } from "@moonsjs/vite/server";',
433
521
  'import config from "virtual:moons/config";',
434
522
  'import { routes, loadRoute } from "virtual:moons/routes";',
523
+ 'import { apiRoutes, apiMiddlewares, loadApi, loadApiMiddleware } from "virtual:moons/api";',
435
524
  ...eagerImports.map((specifier) => `import ${JSON.stringify(specifier)};`),
436
525
  "",
526
+ "const plugins = createPluginRuntime({ config, routes, middlewares: config.middleware ?? [] });",
437
527
  "const handler = createRequestHandler({",
438
528
  " routes,",
529
+ " apiRoutes,",
530
+ " apiMiddlewares,",
531
+ " loadApi,",
532
+ " loadApiMiddleware,",
439
533
  " config,",
440
534
  ` routesDir: ${JSON.stringify(routesDir)},`,
441
535
  " loadRoute,",
442
536
  " shell: createFileShell(),",
537
+ " onContext: (ctx) => plugins.applyExtensions(ctx),",
443
538
  "});",
444
539
  "",
445
- "startNodeServer(handler);",
540
+ "startNodeServer(handler, { plugins });",
446
541
  "",
447
542
  ].join("\n");
448
543
  }
@@ -452,6 +547,38 @@ interface ModuleGraphLike {
452
547
  invalidateModule(module: unknown): void;
453
548
  }
454
549
 
550
+ /** Entry D.5: runner prerender (dijalankan CLI setelah build server). */
551
+ function renderPrerenderEntry(
552
+ routesDir: string,
553
+ projectRoot: string,
554
+ canary: string | undefined,
555
+ ): string {
556
+ return [
557
+ "// Generated by @moons. Jangan diubah manual.",
558
+ 'import { createFileShell, createRequestHandler, runPrerender } from "@moonsjs/vite/server";',
559
+ 'import config from "virtual:moons/config";',
560
+ 'import { routes, loadRoute } from "virtual:moons/routes";',
561
+ "",
562
+ "const handler = createRequestHandler({",
563
+ " routes,",
564
+ " config,",
565
+ ` routesDir: ${JSON.stringify(routesDir)},`,
566
+ " loadRoute,",
567
+ " shell: createFileShell(),",
568
+ "});",
569
+ "",
570
+ "await runPrerender({",
571
+ " routes,",
572
+ ` routesDir: ${JSON.stringify(routesDir)},`,
573
+ " loadRoute,",
574
+ " handler,",
575
+ ` clientDir: ${JSON.stringify(resolvePath(projectRoot, "dist/client"))},`,
576
+ ...(canary === undefined ? [] : [` canary: ${JSON.stringify(canary)},`]),
577
+ "});",
578
+ "",
579
+ ].join("\n");
580
+ }
581
+
455
582
  function environmentName(context: unknown): string {
456
583
  const environment = (context as { environment?: { name?: string } } | undefined)?.environment;
457
584
  return environment?.name ?? "client";
package/src/server/dev.ts CHANGED
@@ -1,43 +1,74 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import type { ViteDevServer } from "vite";
3
3
  import { VIRTUAL_MODULES } from "../virtual-modules";
4
+ import type { PayloadScanOptions } from "../payload-scan";
5
+ import type { PluginRuntime } from "./plugins";
4
6
  import type { Shell } from "./shell";
5
7
  import { createDevShell } from "./shell";
6
8
  import { sendNodeResponse, toWebRequest } from "./node";
7
9
 
10
+ interface DevApp {
11
+ handler: (request: Request) => Promise<Response>;
12
+ plugins: PluginRuntime;
13
+ }
14
+
8
15
  type DevServerEntry = {
9
- createDevHandler: (shell: Shell) => (request: Request) => Promise<Response>;
16
+ createDevApp: (
17
+ shell: Shell,
18
+ payloadScan?: import("../payload-scan").PayloadScanOptions,
19
+ ) => DevApp;
10
20
  };
11
21
 
12
22
  /**
13
23
  * Middleware SSR dev: dipasang sebelum middleware internal Vite, menangani
14
24
  * request HTML dan RPC serverFn (`/_moons/fn/*`) — modul/aset tetap Vite.
25
+ * Handler + lifecycle plugin (Addendum B.4) diinisialisasi sekali.
15
26
  */
16
27
  export function installDevMiddleware(
17
28
  server: ViteDevServer,
18
- options: { templatePath: string },
29
+ options: { templatePath: string; payloadScan?: PayloadScanOptions; apiPrefix?: string },
19
30
  ): void {
31
+ let appPromise: Promise<DevApp> | null = null;
32
+
33
+ const getApp = (): Promise<DevApp> => {
34
+ if (appPromise === null) {
35
+ appPromise = (async () => {
36
+ const entry = (await server.ssrLoadModule(VIRTUAL_MODULES.serverEntry)) as DevServerEntry;
37
+ const app = entry.createDevApp(createDevShell(server, options), options.payloadScan);
38
+ await app.plugins.start();
39
+ await app.plugins.ready();
40
+ return app;
41
+ })();
42
+ }
43
+ return appPromise;
44
+ };
45
+
46
+ server.httpServer?.once("close", () => {
47
+ void appPromise?.then((app) => app.plugins.shutdown());
48
+ });
49
+
20
50
  server.middlewares.use((req, res, next) => {
21
- if (!shouldHandle(req)) {
51
+ if (!shouldHandle(req, options.apiPrefix)) {
22
52
  next();
23
53
  return;
24
54
  }
25
- void handleRequest(server, options)(req, res).catch((error: unknown) => {
55
+ void handleRequest(getApp, req, res).catch((error: unknown) => {
26
56
  next(error as Error);
27
57
  });
28
58
  });
29
59
  }
30
60
 
31
- function handleRequest(server: ViteDevServer, options: { templatePath: string }) {
32
- return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
33
- const entry = (await server.ssrLoadModule(VIRTUAL_MODULES.serverEntry)) as DevServerEntry;
34
- const handler = entry.createDevHandler(createDevShell(server, options));
35
- const response = await handler(await toWebRequest(req));
36
- await sendNodeResponse(res, response);
37
- };
61
+ async function handleRequest(
62
+ getApp: () => Promise<DevApp>,
63
+ req: IncomingMessage,
64
+ res: ServerResponse,
65
+ ): Promise<void> {
66
+ const app = await getApp();
67
+ const response = await app.handler(await toWebRequest(req));
68
+ await sendNodeResponse(res, response);
38
69
  }
39
70
 
40
- function shouldHandle(req: IncomingMessage): boolean {
71
+ function shouldHandle(req: IncomingMessage, apiPrefix: string | undefined): boolean {
41
72
  const path = new URL(req.url ?? "/", "http://localhost").pathname;
42
73
 
43
74
  // RPC serverFn (semua method): dulu hanya GET/HEAD HTML yang dirutekan,
@@ -47,6 +78,11 @@ function shouldHandle(req: IncomingMessage): boolean {
47
78
  return true;
48
79
  }
49
80
 
81
+ // Addendum D.1: endpoint `src/api` (semua method) dirutekan ke runtime.
82
+ if (apiPrefix !== undefined && (path === apiPrefix || path.startsWith(`${apiPrefix}/`))) {
83
+ return true;
84
+ }
85
+
50
86
  if (req.method !== "GET" && req.method !== "HEAD") {
51
87
  return false;
52
88
  }