@moonsjs/vite 0.0.0-stage → 0.1.0

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 (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -2
  3. package/dist/index-8xq8fcgq.js +222 -0
  4. package/dist/index-8xq8fcgq.js.map +14 -0
  5. package/dist/index-fg6nqart.js +776 -0
  6. package/dist/index-fg6nqart.js.map +10 -0
  7. package/dist/index.d.ts +3 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +423 -0
  10. package/dist/index.js.map +11 -0
  11. package/dist/leak-check.d.ts +14 -0
  12. package/dist/leak-check.d.ts.map +1 -0
  13. package/dist/manifest.d.ts +21 -0
  14. package/dist/manifest.d.ts.map +1 -0
  15. package/dist/plugin.d.ts +13 -0
  16. package/dist/plugin.d.ts.map +1 -0
  17. package/dist/server/dev.d.ts +9 -0
  18. package/dist/server/dev.d.ts.map +1 -0
  19. package/dist/server/handler.d.ts +18 -0
  20. package/dist/server/handler.d.ts.map +1 -0
  21. package/dist/server/index.d.ts +13 -0
  22. package/dist/server/index.d.ts.map +1 -0
  23. package/dist/server/index.js +311 -0
  24. package/dist/server/index.js.map +13 -0
  25. package/dist/server/node-server.d.ts +17 -0
  26. package/dist/server/node-server.d.ts.map +1 -0
  27. package/dist/server/node.d.ts +6 -0
  28. package/dist/server/node.d.ts.map +1 -0
  29. package/dist/server/shell.d.ts +18 -0
  30. package/dist/server/shell.d.ts.map +1 -0
  31. package/dist/server/shutdown.d.ts +17 -0
  32. package/dist/server/shutdown.d.ts.map +1 -0
  33. package/dist/server/test-server.d.ts +21 -0
  34. package/dist/server/test-server.d.ts.map +1 -0
  35. package/dist/transform.d.ts +101 -0
  36. package/dist/transform.d.ts.map +1 -0
  37. package/dist/transform.js +34 -0
  38. package/dist/transform.js.map +9 -0
  39. package/dist/virtual-modules.d.ts +12 -0
  40. package/dist/virtual-modules.d.ts.map +1 -0
  41. package/package.json +51 -4
  42. package/src/ctx-fallback.d.ts +9 -0
  43. package/src/index.ts +2 -0
  44. package/src/leak-check.ts +36 -0
  45. package/src/manifest.ts +79 -0
  46. package/src/plugin.ts +471 -0
  47. package/src/server/dev.ts +54 -0
  48. package/src/server/handler.ts +249 -0
  49. package/src/server/index.ts +12 -0
  50. package/src/server/node-server.ts +115 -0
  51. package/src/server/node.ts +61 -0
  52. package/src/server/shell.ts +77 -0
  53. package/src/server/shutdown.ts +64 -0
  54. package/src/server/test-server.ts +44 -0
  55. package/src/transform.ts +1073 -0
  56. package/src/virtual-modules.ts +23 -0
package/src/plugin.ts ADDED
@@ -0,0 +1,471 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { resolve as resolvePath } from "node:path";
4
+ import {
5
+ MoonsError,
6
+ fnRegistryKey,
7
+ generateTypes,
8
+ scanRoutes,
9
+ validateConfig,
10
+ type RouteDefinition,
11
+ } from "@moonsjs/core";
12
+ import { loadConfigFromFile, type Plugin, type ViteDevServer } from "vite";
13
+ import { findLeak } from "./leak-check";
14
+ import { renderRoutesModule, rootSpecifier } from "./manifest";
15
+ import { installDevMiddleware } from "./server/dev";
16
+ import {
17
+ analyzeServerFile,
18
+ checkClientModuleImports,
19
+ checkRouteModuleExports,
20
+ injectServerFnMeta,
21
+ isNonContractServerFile,
22
+ isServerFilePath,
23
+ nonContractServerFileError,
24
+ renderServerFnStub,
25
+ resolveServerSpecifierSync,
26
+ scanServerFiles,
27
+ stripQuery,
28
+ type ServerFileAnalysis,
29
+ } from "./transform";
30
+ import { fromNullPrefixed, VIRTUAL_MODULES } from "./virtual-modules";
31
+
32
+ export interface MoonsVitePluginOptions {
33
+ /** Default: "moons.config.ts". */
34
+ configPath?: string;
35
+ /** Hanya dipakai di CI (M2 §21). */
36
+ leakTest?: { canary?: string };
37
+ /** Default: root Vite. */
38
+ projectRoot?: string;
39
+ }
40
+
41
+ /**
42
+ * Plugin `@moonsjs/vite` (PRD §9): scan route, typegen, virtual module,
43
+ * dev SSR middleware, build client + server.
44
+ */
45
+ const CODE_FILE_PATTERN = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
46
+
47
+ export default function moons(options: MoonsVitePluginOptions = {}): Plugin {
48
+ let root = options.projectRoot ?? process.cwd();
49
+ let isSsrBuild = false;
50
+ let configFilePath = "";
51
+ let routesDir = "";
52
+ let routes: RouteDefinition[] = [];
53
+ let analysisCache = new Map<string, ServerFileAnalysis>();
54
+ const stubTransformed = new Set<string>();
55
+
56
+ function analyzeFile(absoluteFile: string): ServerFileAnalysis | null {
57
+ const cached = analysisCache.get(absoluteFile);
58
+ if (cached !== undefined) {
59
+ return cached;
60
+ }
61
+ let source: string;
62
+ try {
63
+ source = readFileSync(absoluteFile, "utf8");
64
+ } catch {
65
+ return null;
66
+ }
67
+ const analysis = analyzeServerFile(source, absoluteFile, root);
68
+ analysisCache.set(absoluteFile, analysis);
69
+ return analysis;
70
+ }
71
+
72
+ function refreshServerFns(): void {
73
+ const next = new Map<string, ServerFileAnalysis>();
74
+ const registry: Record<string, string> = {};
75
+
76
+ for (const file of scanServerFiles(root)) {
77
+ let source: string;
78
+ try {
79
+ source = readFileSync(file, "utf8");
80
+ } catch {
81
+ continue;
82
+ }
83
+ const analysis = analyzeServerFile(source, file, root);
84
+ next.set(file, analysis);
85
+ for (const fn of analysis.serverFns) {
86
+ registry[fnRegistryKey(root, file, fn.name)] = fn.hash;
87
+ }
88
+ }
89
+
90
+ analysisCache = next;
91
+ const outDir = resolvePath(root, ".moons");
92
+ mkdirSync(outDir, { recursive: true });
93
+ writeFileSync(
94
+ resolvePath(outDir, "fn-registry.json"),
95
+ `${JSON.stringify(registry, null, 2)}\n`,
96
+ "utf8",
97
+ );
98
+ }
99
+
100
+ /** Semua specifier file `.server.ts` yang punya serverFn (untuk eager import di entry server). */
101
+ function serverFnFileSpecifiers(): string[] {
102
+ const specifiers: string[] = [];
103
+ for (const [file, analysis] of analysisCache) {
104
+ if (analysis.serverFns.length > 0) {
105
+ specifiers.push(rootSpecifier(root, file));
106
+ }
107
+ }
108
+ return specifiers.sort();
109
+ }
110
+
111
+ /** Stub client §12.2 + cache §12.2.5. */
112
+ function transformServerFileClient(code: string, id: string): string {
113
+ const relativePath = id.startsWith(root)
114
+ ? id.slice(root.length).split("\\").join("/")
115
+ : id;
116
+ const cacheKey = createHash("sha256").update(`${relativePath}\0${code}`).digest("hex");
117
+ const cacheFile = resolvePath(root, ".moons/cache", `${cacheKey}.js`);
118
+
119
+ if (existsSync(cacheFile)) {
120
+ stubTransformed.add(id);
121
+ return readFileSync(cacheFile, "utf8");
122
+ }
123
+
124
+ const analysis = analyzeFile(id);
125
+ if (analysis === null) {
126
+ throw new MoonsError({
127
+ code: "E_INTERNAL",
128
+ file: id,
129
+ message: "gagal membaca file server untuk transform",
130
+ details: "File `.server.ts` harus bisa dibaca saat transform (PRD §12.2).",
131
+ suggestions: ["Periksa path dan izin file"],
132
+ });
133
+ }
134
+
135
+ const stub = renderServerFnStub(code, id, analysis, {
136
+ analyzeSpecifier: (sourceFile, specifier) => {
137
+ const resolved = resolveServerSpecifierSync(sourceFile, specifier);
138
+ return resolved === null ? null : analyzeFile(resolved);
139
+ },
140
+ });
141
+
142
+ mkdirSync(resolvePath(root, ".moons/cache"), { recursive: true });
143
+ writeFileSync(cacheFile, stub.code, "utf8");
144
+ stubTransformed.add(id);
145
+ return stub.code;
146
+ }
147
+
148
+ return {
149
+ name: "moons",
150
+ enforce: "pre",
151
+
152
+ config(_config, env) {
153
+ isSsrBuild = env.isSsrBuild === true;
154
+ const common = {
155
+ appType: "custom" as const,
156
+ };
157
+
158
+ if (isSsrBuild) {
159
+ return {
160
+ ...common,
161
+ build: {
162
+ outDir: "dist/server",
163
+ emptyOutDir: true,
164
+ // Entry nyata yang di-generate saat buildStart (Rolldown butuh file,
165
+ // entry virtual tidak di-resolve lewat plugin container).
166
+ ssr: ".moons/server-entry.ts",
167
+ rollupOptions: { output: { entryFileNames: "index.js", codeSplitting: false } },
168
+ },
169
+ ssr: { noExternal: true, target: "node" as const },
170
+ };
171
+ }
172
+
173
+ return {
174
+ ...common,
175
+ build: { outDir: "dist/client", emptyOutDir: true },
176
+ };
177
+ },
178
+
179
+ async configResolved(resolved) {
180
+ root = options.projectRoot ?? resolved.root;
181
+ configFilePath = resolvePath(root, options.configPath ?? "moons.config.ts");
182
+
183
+ if (!existsSync(configFilePath)) {
184
+ throw new MoonsError({
185
+ code: "E_CONFIG_INVALID",
186
+ file: configFilePath,
187
+ message: "`moons.config.ts` tidak ditemukan",
188
+ details: "Framework membutuhkan file config di root project (PRD §14.3).",
189
+ suggestions: [
190
+ "Buat `moons.config.ts` dengan `export default defineConfig({ context: async (req) => ({}) })`",
191
+ ],
192
+ });
193
+ }
194
+
195
+ let raw: unknown;
196
+ try {
197
+ const loaded = await loadConfigFromFile(
198
+ { command: resolved.command, mode: resolved.mode },
199
+ configFilePath,
200
+ root,
201
+ );
202
+ raw = loaded?.config;
203
+ } catch (error) {
204
+ if (error instanceof MoonsError) {
205
+ throw error;
206
+ }
207
+ throw new MoonsError({
208
+ code: "E_CONFIG_INVALID",
209
+ file: configFilePath,
210
+ message: "gagal memuat `moons.config.ts`",
211
+ details: error instanceof Error ? error.message : String(error),
212
+ suggestions: ["Periksa sintaks dan import di `moons.config.ts`"],
213
+ });
214
+ }
215
+
216
+ const config = validateConfig(raw, { file: configFilePath, projectRoot: root });
217
+ routesDir = resolvePath(root, config.routesDir);
218
+ },
219
+
220
+ async buildStart() {
221
+ routes = scanRoutes(routesDir);
222
+ await generateTypes({
223
+ routesDir,
224
+ configPath: configFilePath,
225
+ outDir: resolvePath(root, ".moons"),
226
+ });
227
+ refreshServerFns();
228
+ if (isSsrBuild) {
229
+ writeFileSync(
230
+ resolvePath(root, ".moons/server-entry.ts"),
231
+ renderServerEntryBuild(routesDir, serverFnFileSpecifiers()),
232
+ "utf8",
233
+ );
234
+ }
235
+ },
236
+
237
+ resolveId(id) {
238
+ const clean = id.startsWith("\0") ? id.slice(1) : id;
239
+ if ((Object.values(VIRTUAL_MODULES) as string[]).includes(clean)) {
240
+ return `\0${clean}`;
241
+ }
242
+ return undefined;
243
+ },
244
+
245
+ load(id) {
246
+ const name = fromNullPrefixed(id);
247
+ if (name === null) {
248
+ return undefined;
249
+ }
250
+ switch (name) {
251
+ case VIRTUAL_MODULES.routes:
252
+ return renderRoutesModule(routes, routesDir, root);
253
+ case VIRTUAL_MODULES.clientEntry:
254
+ return '// Generated by @moons. Jangan diubah manual.\nexport { clientManifest, loadRoute } from "virtual:moons/routes";\n';
255
+ case VIRTUAL_MODULES.config:
256
+ return renderConfigModule(root, configFilePath);
257
+ case VIRTUAL_MODULES.serverEntry:
258
+ // Hanya dipakai dev (ssrLoadModule); build memakai .moons/server-entry.ts.
259
+ return renderServerEntryDev(routesDir, serverFnFileSpecifiers());
260
+ }
261
+ },
262
+
263
+ async transform(code, id) {
264
+ const cleanId = stripQuery(id);
265
+ if (cleanId.startsWith("\0") || cleanId.includes("/node_modules/")) {
266
+ return undefined;
267
+ }
268
+
269
+ const environment = environmentName(this);
270
+
271
+ if (isServerFilePath(cleanId)) {
272
+ if (environment === "ssr") {
273
+ const analysis = analysisCache.get(cleanId) ?? analyzeFile(cleanId);
274
+ if (analysis === null || analysis.serverFns.length === 0) {
275
+ return undefined;
276
+ }
277
+ return injectServerFnMeta(code, analysis, root);
278
+ }
279
+ return transformServerFileClient(code, cleanId);
280
+ }
281
+
282
+ // Audit ronde 2: file server di luar kontrak §12 tidak bisa di-stub —
283
+ // jangan pernah biarkan masuk graph client.
284
+ if (environment !== "ssr" && isNonContractServerFile(cleanId)) {
285
+ throw nonContractServerFileError(cleanId, root);
286
+ }
287
+
288
+ if (environment !== "ssr" && CODE_FILE_PATTERN.test(cleanId)) {
289
+ if (cleanId.startsWith(routesDir) && cleanId.endsWith(".tsx")) {
290
+ checkRouteModuleExports({
291
+ file: cleanId,
292
+ code,
293
+ routesDir,
294
+ projectRoot: root,
295
+ });
296
+ }
297
+ await checkClientModuleImports({
298
+ file: cleanId,
299
+ code,
300
+ projectRoot: root,
301
+ // Resolusi penuh (alias Vite, tsconfig paths, symlink) — audit ronde 2.
302
+ resolve: async (specifier, importer) => {
303
+ try {
304
+ const resolved = await this.resolve(specifier, importer);
305
+ return resolved === null ? null : stripQuery(resolved.id);
306
+ } catch {
307
+ return null;
308
+ }
309
+ },
310
+ analyze: (absoluteFile) => analysisCache.get(absoluteFile) ?? analyzeFile(absoluteFile),
311
+ });
312
+ }
313
+
314
+ return undefined;
315
+ },
316
+
317
+ generateBundle(_options, bundle) {
318
+ if (environmentName(this) === "ssr") {
319
+ return;
320
+ }
321
+
322
+ const canary = options.leakTest?.canary;
323
+ if (canary !== undefined && canary.length > 0) {
324
+ const leak = findLeak(Object.values(bundle), canary);
325
+ if (leak !== null) {
326
+ throw new MoonsError({
327
+ code: "E_INTERNAL",
328
+ message: `canary \`${canary}\` ditemukan di bundle client (${leak.fileName})`,
329
+ details:
330
+ "Verifikasi kebocoran §2/§9.2.8 gagal: kode server bocor ke output client. Build dihentikan.",
331
+ suggestions: [
332
+ "Periksa transform `*.server.ts` (§12)",
333
+ "Jalankan `./scripts/m2/test-leak.sh` untuk audit",
334
+ ],
335
+ });
336
+ }
337
+ }
338
+
339
+ for (const output of Object.values(bundle)) {
340
+ if (output.type !== "chunk") {
341
+ continue;
342
+ }
343
+ for (const moduleId of Object.keys(output.modules)) {
344
+ const cleanId = stripQuery(moduleId);
345
+ if (!isServerFilePath(cleanId) || stubTransformed.has(cleanId)) {
346
+ continue;
347
+ }
348
+ throw new MoonsError({
349
+ code: "E_FORBIDDEN_SERVER_IMPORT",
350
+ file: cleanId,
351
+ message: "modul server ikut ter-bundle di client tanpa melewati transform",
352
+ details:
353
+ "Verifikasi build §9.2: setiap `*.server.ts` di bundle client wajib berupa stub RPC hasil transform.",
354
+ suggestions: [
355
+ "Pastikan transform zero-trust (§12) berjalan untuk modul ini",
356
+ "Laporkan ini sebagai bug framework jika berulang",
357
+ ],
358
+ });
359
+ }
360
+ }
361
+ },
362
+
363
+ configureServer(server) {
364
+ installDevMiddleware(server, { templatePath: resolvePath(root, "index.html") });
365
+ },
366
+
367
+ async handleHotUpdate(context) {
368
+ if (isServerFilePath(context.file)) {
369
+ refreshServerFns();
370
+ context.server.ws.send({ type: "full-reload" });
371
+ return;
372
+ }
373
+
374
+ if (!context.file.startsWith(routesDir)) {
375
+ return;
376
+ }
377
+
378
+ try {
379
+ routes = scanRoutes(routesDir);
380
+ await generateTypes({
381
+ routesDir,
382
+ configPath: configFilePath,
383
+ outDir: resolvePath(root, ".moons"),
384
+ });
385
+ } catch (error) {
386
+ context.server.config.logger.error(String(error));
387
+ return;
388
+ }
389
+
390
+ invalidateVirtualModules(context.server);
391
+ context.server.ws.send({ type: "full-reload" });
392
+ },
393
+ };
394
+ }
395
+
396
+ function renderConfigModule(root: string, configFilePath: string): string {
397
+ return [
398
+ "// Generated by @moons. Jangan diubah manual.",
399
+ `import raw from ${JSON.stringify(rootSpecifier(root, configFilePath))};`,
400
+ 'import { validateConfig } from "@moonsjs/core";',
401
+ `export default validateConfig(raw, { file: ${JSON.stringify(configFilePath)} });`,
402
+ "",
403
+ ].join("\n");
404
+ }
405
+
406
+ function renderServerEntryDev(routesDir: string, eagerImports: string[]): string {
407
+ return [
408
+ "// Generated by @moons. Jangan diubah manual.",
409
+ 'import { createRequestHandler } from "@moonsjs/vite/server";',
410
+ 'import config from "virtual:moons/config";',
411
+ 'import { routes, loadRoute } from "virtual:moons/routes";',
412
+ ...eagerImports.map((specifier) => `import ${JSON.stringify(specifier)};`),
413
+ "",
414
+ "export function createDevHandler(shell) {",
415
+ ` return createRequestHandler({ routes, config, routesDir: ${JSON.stringify(routesDir)}, loadRoute, shell });`,
416
+ "}",
417
+ "",
418
+ ].join("\n");
419
+ }
420
+
421
+ function renderServerEntryBuild(routesDir: string, eagerImports: string[]): string {
422
+ return [
423
+ "// Generated by @moons. Jangan diubah manual.",
424
+ 'import { createFileShell, createRequestHandler, startNodeServer } from "@moonsjs/vite/server";',
425
+ 'import config from "virtual:moons/config";',
426
+ 'import { routes, loadRoute } from "virtual:moons/routes";',
427
+ ...eagerImports.map((specifier) => `import ${JSON.stringify(specifier)};`),
428
+ "",
429
+ "const handler = createRequestHandler({",
430
+ " routes,",
431
+ " config,",
432
+ ` routesDir: ${JSON.stringify(routesDir)},`,
433
+ " loadRoute,",
434
+ " shell: createFileShell(),",
435
+ "});",
436
+ "",
437
+ "startNodeServer(handler);",
438
+ "",
439
+ ].join("\n");
440
+ }
441
+
442
+ interface ModuleGraphLike {
443
+ getModulesByFile(file: string): Iterable<{ id?: string | null }> | undefined;
444
+ invalidateModule(module: unknown): void;
445
+ }
446
+
447
+ function environmentName(context: unknown): string {
448
+ const environment = (context as { environment?: { name?: string } } | undefined)?.environment;
449
+ return environment?.name ?? "client";
450
+ }
451
+
452
+ function invalidateVirtualModules(server: ViteDevServer): void {
453
+ const graphs: ModuleGraphLike[] = [server.moduleGraph as unknown as ModuleGraphLike];
454
+ const environments = (server as unknown as { environments?: Record<string, { moduleGraph?: unknown }> })
455
+ .environments;
456
+ if (environments !== undefined) {
457
+ for (const environment of Object.values(environments)) {
458
+ if (environment.moduleGraph !== undefined) {
459
+ graphs.push(environment.moduleGraph as ModuleGraphLike);
460
+ }
461
+ }
462
+ }
463
+
464
+ for (const graph of graphs) {
465
+ for (const name of Object.values(VIRTUAL_MODULES)) {
466
+ for (const module of graph.getModulesByFile(`\0${name}`) ?? []) {
467
+ graph.invalidateModule(module);
468
+ }
469
+ }
470
+ }
471
+ }
@@ -0,0 +1,54 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { ViteDevServer } from "vite";
3
+ import { VIRTUAL_MODULES } from "../virtual-modules";
4
+ import type { Shell } from "./shell";
5
+ import { createDevShell } from "./shell";
6
+ import { sendNodeResponse, toWebRequest } from "./node";
7
+
8
+ type DevServerEntry = {
9
+ createDevHandler: (shell: Shell) => (request: Request) => Promise<Response>;
10
+ };
11
+
12
+ /**
13
+ * Middleware SSR dev: dipasang sebelum middleware internal Vite, hanya
14
+ * menangani request HTML (modul/aset tetap dilayani Vite).
15
+ */
16
+ export function installDevMiddleware(
17
+ server: ViteDevServer,
18
+ options: { templatePath: string },
19
+ ): void {
20
+ server.middlewares.use((req, res, next) => {
21
+ if (!isHtmlRequest(req)) {
22
+ next();
23
+ return;
24
+ }
25
+ void handleRequest(server, options)(req, res).catch((error: unknown) => {
26
+ next(error as Error);
27
+ });
28
+ });
29
+ }
30
+
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
+ };
38
+ }
39
+
40
+ function isHtmlRequest(req: IncomingMessage): boolean {
41
+ if (req.method !== "GET" && req.method !== "HEAD") {
42
+ return false;
43
+ }
44
+ const path = new URL(req.url ?? "/", "http://localhost").pathname;
45
+ if (path.startsWith("/@") || path.startsWith("/node_modules/") || path.startsWith("/__")) {
46
+ return false;
47
+ }
48
+ const lastSegment = path.split("/").pop() ?? "";
49
+ if (lastSegment.includes(".")) {
50
+ return false;
51
+ }
52
+ const accept = req.headers.accept ?? "";
53
+ return accept.includes("text/html") || accept.includes("*/*");
54
+ }