@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
@@ -0,0 +1,1073 @@
1
+ import { MoonsError, computeFnHash, fnRegistryKey } from "@moonsjs/core";
2
+ import { parse } from "@babel/parser";
3
+ import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+
6
+ /**
7
+ * Spec §12: analisis `.server.ts` + stub client + injeksi meta server +
8
+ * penolakan import (E_SERVER_IMPORT_IN_CLIENT / E_FORBIDDEN_SERVER_IMPORT).
9
+ */
10
+
11
+ export interface ServerFnInfo {
12
+ name: string;
13
+ hash: string;
14
+ line: number;
15
+ input: { start: number; end: number };
16
+ output: { start: number; end: number };
17
+ objectStart: number;
18
+ }
19
+
20
+ export interface ServerFileAnalysis {
21
+ /** Absolute path. */
22
+ file: string;
23
+ serverFns: ServerFnInfo[];
24
+ /** Semua nama export top-level (langsung). */
25
+ exportNames: string[];
26
+ /** Pernyataan `export { X as Y } from "./z.server"` (untuk resolusi re-export). */
27
+ reExports: Array<{ source: string; names: Array<{ local: string; exported: string }> }>;
28
+ /** `export * from "./z.server"`. */
29
+ exportAllFrom: string[];
30
+ }
31
+
32
+ interface AstNode {
33
+ type: string;
34
+ start: number | null;
35
+ end: number | null;
36
+ loc?: { start: { line: number } } | null;
37
+ [key: string]: unknown;
38
+ }
39
+
40
+ const SERVER_SPECIFIER = /\.server(\.(ts|tsx|js|jsx|mts|cts|mjs|cjs))?$/i;
41
+ const CONTRACT_SERVER_FILE = /\.server\.(ts|tsx)$/i;
42
+ const NON_CONTRACT_SERVER_FILE = /\.server\.(js|jsx|mts|cts|mjs|cjs)$/i;
43
+
44
+ export function isServerFilePath(path: string): boolean {
45
+ return CONTRACT_SERVER_FILE.test(stripQuery(path));
46
+ }
47
+
48
+ /** File server dengan ekstensi di luar kontrak §12 (tidak bisa di-stub). */
49
+ export function isNonContractServerFile(path: string): boolean {
50
+ return NON_CONTRACT_SERVER_FILE.test(stripQuery(path));
51
+ }
52
+
53
+ export function isServerSpecifier(specifier: string): boolean {
54
+ return SERVER_SPECIFIER.test(specifier);
55
+ }
56
+
57
+ /**
58
+ * Resolusi sinkron untuk specifier `*.server` (relatif/absolut).
59
+ * Import alias/virtual untuk file server tidak didukung — tidak dibutuhkan app.
60
+ */
61
+ export function resolveServerSpecifierSync(importerFile: string, specifier: string): string | null {
62
+ if (!isServerSpecifier(specifier)) {
63
+ return null;
64
+ }
65
+ const base = specifier.startsWith(".")
66
+ ? resolve(dirname(importerFile), specifier)
67
+ : isAbsolute(specifier)
68
+ ? specifier
69
+ : null;
70
+ if (base === null) {
71
+ return null;
72
+ }
73
+ for (const candidate of [base, `${base}.ts`, `${base}.tsx`]) {
74
+ if (existsSync(candidate)) {
75
+ return candidate;
76
+ }
77
+ }
78
+ return null;
79
+ }
80
+
81
+ export function stripQuery(id: string): string {
82
+ const queryIndex = id.indexOf("?");
83
+ return queryIndex === -1 ? id : id.slice(0, queryIndex);
84
+ }
85
+
86
+ function parseModule(code: string, filename: string): AstNode {
87
+ return parse(code, {
88
+ sourceType: "module",
89
+ plugins: filename.endsWith(".tsx") ? ["typescript", "jsx"] : ["typescript"],
90
+ errorRecovery: false,
91
+ }) as unknown as AstNode;
92
+ }
93
+
94
+ function walk(
95
+ node: unknown,
96
+ visit: (node: AstNode, ancestors: AstNode[]) => void,
97
+ ancestors: AstNode[] = [],
98
+ ): void {
99
+ if (node === null || typeof node !== "object") {
100
+ return;
101
+ }
102
+ if (Array.isArray(node)) {
103
+ for (const item of node) {
104
+ walk(item, visit, ancestors);
105
+ }
106
+ return;
107
+ }
108
+ const candidate = node as AstNode;
109
+ if (typeof candidate.type !== "string" || typeof candidate.start !== "number") {
110
+ return;
111
+ }
112
+ visit(candidate, ancestors);
113
+ const nextAncestors = [...ancestors, candidate];
114
+ for (const key of Object.keys(candidate)) {
115
+ if (key === "loc" || key === "start" || key === "end" || key === "extra") {
116
+ continue;
117
+ }
118
+ walk(candidate[key], visit, nextAncestors);
119
+ }
120
+ }
121
+
122
+ function topLevelStatements(ast: AstNode): AstNode[] {
123
+ const program = ast["program"] as { body?: AstNode[] } | undefined;
124
+ return program?.body ?? [];
125
+ }
126
+
127
+ function keyName(node: unknown): string | null {
128
+ const key = node as AstNode | undefined;
129
+ if (key?.type === "Identifier") {
130
+ return key["name"] as string;
131
+ }
132
+ if (key?.type === "StringLiteral") {
133
+ return key["value"] as string;
134
+ }
135
+ return null;
136
+ }
137
+
138
+ function extractServerFnInit(init: unknown): {
139
+ input: { start: number; end: number };
140
+ output: { start: number; end: number };
141
+ line: number;
142
+ objectStart: number;
143
+ } | null {
144
+ const call = init as AstNode | undefined;
145
+ if (call?.type !== "CallExpression") {
146
+ return null;
147
+ }
148
+ const callee = call["callee"] as AstNode | undefined;
149
+ if (callee?.type !== "Identifier" || callee["name"] !== "serverFn") {
150
+ return null;
151
+ }
152
+ const args = (call["arguments"] ?? []) as AstNode[];
153
+ const first = args[0];
154
+ if (first?.type !== "ObjectExpression") {
155
+ return null;
156
+ }
157
+
158
+ const properties = new Map<string, AstNode>();
159
+ for (const property of (first["properties"] ?? []) as AstNode[]) {
160
+ if (property.type !== "ObjectProperty") {
161
+ continue;
162
+ }
163
+ const name = keyName(property["key"]);
164
+ if (name !== null) {
165
+ properties.set(name, property);
166
+ }
167
+ }
168
+
169
+ const input = properties.get("input")?.["value"] as AstNode | undefined;
170
+ const output = properties.get("output")?.["value"] as AstNode | undefined;
171
+ if (
172
+ input?.start == null ||
173
+ input.end == null ||
174
+ output?.start == null ||
175
+ output.end == null
176
+ ) {
177
+ return null;
178
+ }
179
+
180
+ return {
181
+ input: { start: input.start, end: input.end },
182
+ output: { start: output.start, end: output.end },
183
+ line: call.loc?.start.line ?? 1,
184
+ objectStart: first.start ?? 0,
185
+ };
186
+ }
187
+
188
+ export function analyzeServerFile(
189
+ source: string,
190
+ filename: string,
191
+ projectRoot: string,
192
+ ): ServerFileAnalysis {
193
+ const ast = parseModule(source, filename);
194
+ const serverFns: ServerFnInfo[] = [];
195
+ const exportNames: string[] = [];
196
+ const reExports: ServerFileAnalysis["reExports"] = [];
197
+ const exportAllFrom: string[] = [];
198
+
199
+ for (const statement of topLevelStatements(ast)) {
200
+ if (statement.type === "ExportNamedDeclaration") {
201
+ const declaration = statement["declaration"] as AstNode | null | undefined;
202
+
203
+ if (declaration?.type === "VariableDeclaration") {
204
+ for (const declarator of (declaration["declarations"] ?? []) as AstNode[]) {
205
+ const id = declarator["id"] as AstNode | undefined;
206
+ if (id?.type !== "Identifier") {
207
+ continue;
208
+ }
209
+ const name = id["name"] as string;
210
+ exportNames.push(name);
211
+ const info = extractServerFnInit(declarator["init"]);
212
+ if (info !== null) {
213
+ serverFns.push({
214
+ name,
215
+ hash: computeFnHash(projectRoot, filename, name),
216
+ line: info.line,
217
+ input: info.input,
218
+ output: info.output,
219
+ objectStart: info.objectStart,
220
+ });
221
+ }
222
+ }
223
+ } else if (declaration?.type === "FunctionDeclaration") {
224
+ const id = declaration["id"] as AstNode | undefined;
225
+ if (id?.type === "Identifier") {
226
+ exportNames.push(id["name"] as string);
227
+ }
228
+ }
229
+
230
+ for (const specifier of (statement["specifiers"] ?? []) as AstNode[]) {
231
+ const exported = specifier["exported"] as AstNode | undefined;
232
+ if (exported?.type === "Identifier") {
233
+ exportNames.push(exported["name"] as string);
234
+ }
235
+ }
236
+
237
+ const sourceNode = statement["source"] as AstNode | undefined;
238
+ if (sourceNode?.type === "StringLiteral" && isServerSpecifier(sourceNode["value"] as string)) {
239
+ reExports.push({
240
+ source: sourceNode["value"] as string,
241
+ names: ((statement["specifiers"] ?? []) as AstNode[]).map((specifier) => ({
242
+ local: ((specifier["local"] as AstNode | undefined)?.["name"] as string) ?? "",
243
+ exported: (((specifier["exported"] as AstNode | undefined)?.["name"] as string) ?? ""),
244
+ })),
245
+ });
246
+ }
247
+ } else if (statement.type === "ExportDefaultDeclaration") {
248
+ exportNames.push("default");
249
+ } else if (statement.type === "ExportAllDeclaration") {
250
+ const sourceNode = statement["source"] as AstNode | undefined;
251
+ if (sourceNode?.type === "StringLiteral" && isServerSpecifier(sourceNode["value"] as string)) {
252
+ exportAllFrom.push(sourceNode["value"] as string);
253
+ }
254
+ }
255
+ }
256
+
257
+ return { file: filename, serverFns, exportNames, reExports, exportAllFrom };
258
+ }
259
+
260
+ /** Nama-nama yang diekspor sebagai serverFn stub dari file (langsung atau via re-export). */
261
+ export function serverFnExportNames(
262
+ analysis: ServerFileAnalysis,
263
+ analyzeTarget: (specifier: string) => ServerFileAnalysis | null,
264
+ seen = new Set<string>(),
265
+ ): Set<string> {
266
+ const names = new Set(analysis.serverFns.map((fn) => fn.name));
267
+
268
+ for (const reExport of analysis.reExports) {
269
+ const target = analyzeTarget(reExport.source);
270
+ if (target === null || seen.has(reExport.source)) {
271
+ continue;
272
+ }
273
+ seen.add(reExport.source);
274
+ const targetNames = serverFnExportNames(target, analyzeTarget, seen);
275
+ for (const { local, exported } of reExport.names) {
276
+ if (local === "*" || targetNames.has(local)) {
277
+ names.add(exported);
278
+ }
279
+ }
280
+ }
281
+
282
+ for (const specifier of analysis.exportAllFrom) {
283
+ const target = analyzeTarget(specifier);
284
+ if (target === null || seen.has(specifier)) {
285
+ continue;
286
+ }
287
+ seen.add(specifier);
288
+ for (const name of serverFnExportNames(target, analyzeTarget, seen)) {
289
+ names.add(name);
290
+ }
291
+ }
292
+
293
+ return names;
294
+ }
295
+
296
+ function collectRetainedText(source: string, analysis: ServerFileAnalysis): string {
297
+ return analysis.serverFns
298
+ .map((fn) => `${source.slice(fn.input.start, fn.input.end)}\n${source.slice(fn.output.start, fn.output.end)}`)
299
+ .join("\n");
300
+ }
301
+
302
+ function isUsed(name: string, retainedText: string): boolean {
303
+ return new RegExp(`(^|[^\\w$])${escapeRegExp(name)}([^\\w$]|$)`).test(retainedText);
304
+ }
305
+
306
+ function escapeRegExp(text: string): string {
307
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
308
+ }
309
+
310
+ export interface StubDependencies {
311
+ /** Resolve specifier relatif file sumber → analysis (null bila tidak bisa). */
312
+ analyzeSpecifier: (sourceFile: string, specifier: string) => ServerFileAnalysis | null;
313
+ }
314
+
315
+ export interface RenderStubResult {
316
+ code: string;
317
+ /** Specifier server file yang di-referensikan (untuk keperluan cache/diagnostik). */
318
+ referenced: string[];
319
+ }
320
+
321
+ /**
322
+ * Bangun stub client §12.2/§12.3 dari sumber `.server.ts`:
323
+ * hanya import yang dipakai schema + `__moons_createStub` per serverFn.
324
+ */
325
+ export function renderServerFnStub(
326
+ source: string,
327
+ filename: string,
328
+ analysis: ServerFileAnalysis,
329
+ dependencies: StubDependencies,
330
+ ): RenderStubResult {
331
+ const ast = parseModule(source, filename);
332
+ const retainedText = collectRetainedText(source, analysis);
333
+ const importLines: string[] = [];
334
+ const referenced: string[] = [];
335
+ let needsCreateStub = analysis.serverFns.length > 0;
336
+
337
+ for (const statement of topLevelStatements(ast)) {
338
+ if (statement.type !== "ImportDeclaration") {
339
+ continue;
340
+ }
341
+ const sourceNode = statement["source"] as AstNode;
342
+ const specifier = sourceNode["value"] as string;
343
+
344
+ if (/^@moons\/server(\/|$)/.test(specifier)) {
345
+ continue; // serverFn/env tidak dibawa ke client (§12.2)
346
+ }
347
+ if (specifier.startsWith("node:") || specifier.startsWith("bun:")) {
348
+ continue; // import server-only dibuang
349
+ }
350
+
351
+ const specifiers = (statement["specifiers"] ?? []) as AstNode[];
352
+ const kept: AstNode[] = [];
353
+
354
+ if (isServerSpecifier(specifier)) {
355
+ referenced.push(specifier);
356
+ for (const item of specifiers) {
357
+ if (item.type === "ImportDefaultSpecifier" || item.type === "ImportNamespaceSpecifier") {
358
+ continue; // default / * as dari *.server → dibuang (§12.2)
359
+ }
360
+ const local = ((item["local"] as AstNode | undefined)?.["name"] as string) ?? "";
361
+ if (local.length > 0 && isUsed(local, retainedText)) {
362
+ kept.push(item);
363
+ }
364
+ }
365
+ } else {
366
+ for (const item of specifiers) {
367
+ const local = ((item["local"] as AstNode | undefined)?.["name"] as string) ?? "";
368
+ if (local.length > 0 && isUsed(local, retainedText)) {
369
+ kept.push(item);
370
+ }
371
+ }
372
+ }
373
+
374
+ if (kept.length === 0) {
375
+ continue; // import tak terpakai → dipangkas (§12.3)
376
+ }
377
+
378
+ const keptText = kept
379
+ .map((item) => source.slice(item.start ?? 0, item.end ?? 0))
380
+ .join(", ");
381
+ importLines.push(`import { ${keptText} } from ${JSON.stringify(specifier)};`);
382
+ }
383
+
384
+ if (needsCreateStub) {
385
+ importLines.push('import { __moons_createStub } from "@moonsjs/server/client";');
386
+ }
387
+
388
+ const exportLines: string[] = [];
389
+ for (const fn of analysis.serverFns) {
390
+ const input = source.slice(fn.input.start, fn.input.end);
391
+ const output = source.slice(fn.output.start, fn.output.end);
392
+ exportLines.push(
393
+ `export const ${fn.name} = __moons_createStub({ hash: ${JSON.stringify(fn.hash)}, input: ${input}, output: ${output} });`,
394
+ );
395
+ }
396
+
397
+ for (const reExport of analysis.reExports) {
398
+ const target = dependencies.analyzeSpecifier(filename, reExport.source);
399
+ if (target === null) {
400
+ throw new MoonsError({
401
+ code: "E_SERVER_IMPORT_IN_CLIENT",
402
+ file: filename,
403
+ message: `tidak bisa me-resolve \`${reExport.source}\` untuk re-export`,
404
+ details: "Re-export dari `*.server.ts` harus bisa di-resolve statis (PRD §12.6).",
405
+ suggestions: ["Perbaiki path re-export", "Hindari re-export dinamis dari file server"],
406
+ });
407
+ }
408
+ const targetNames = serverFnExportNames(target, (specifier) =>
409
+ dependencies.analyzeSpecifier(filename, specifier),
410
+ );
411
+ const keptNames = reExport.names.filter(({ local }) => local !== "" && targetNames.has(local));
412
+ if (keptNames.length > 0) {
413
+ const specifiers = keptNames
414
+ .map(({ local, exported }) => (local === exported ? local : `${local} as ${exported}`))
415
+ .join(", ");
416
+ exportLines.push(`export { ${specifiers} } from ${JSON.stringify(reExport.source)};`);
417
+ }
418
+ }
419
+
420
+ for (const specifier of analysis.exportAllFrom) {
421
+ const target = dependencies.analyzeSpecifier(filename, specifier);
422
+ if (target === null) {
423
+ throw new MoonsError({
424
+ code: "E_SERVER_IMPORT_IN_CLIENT",
425
+ file: filename,
426
+ message: `\`export *\` dari \`${specifier}\` tidak bisa di-resolve`,
427
+ details: "`export *` dari `*.server.ts` wajib bisa di-expand statis (PRD §12.6).",
428
+ suggestions: ["Perbaiki path re-export", "Ganti menjadi named re-export"],
429
+ });
430
+ }
431
+ const targetNames = serverFnExportNames(target, (nested) =>
432
+ dependencies.analyzeSpecifier(filename, nested),
433
+ );
434
+ if (targetNames.size > 0) {
435
+ exportLines.push(
436
+ `export { ${[...targetNames].join(", ")} } from ${JSON.stringify(specifier)};`,
437
+ );
438
+ }
439
+ }
440
+
441
+ if (importLines.length === 0 && exportLines.length === 0) {
442
+ return { code: "// @moons: tidak ada serverFn yang di-export\nexport {};\n", referenced };
443
+ }
444
+
445
+ return { code: `${[...importLines, "", ...exportLines].join("\n")}\n`, referenced };
446
+ }
447
+
448
+ /** Inject meta §7.2 ke deklarasi serverFn (env ssr; handler tetap utuh). */
449
+ export function injectServerFnMeta(
450
+ source: string,
451
+ analysis: ServerFileAnalysis,
452
+ projectRoot: string,
453
+ ): string {
454
+ let output = source;
455
+ const inserts = [...analysis.serverFns].sort((a, b) => b.objectStart - a.objectStart);
456
+
457
+ for (const fn of inserts) {
458
+ const meta =
459
+ ` __hash: ${JSON.stringify(fn.hash)},` +
460
+ ` __file: ${JSON.stringify(fnRegistryKey(projectRoot, analysis.file, fn.name).split("#")[0] as string)},` +
461
+ ` __line: ${fn.line},` +
462
+ ` __exportName: ${JSON.stringify(fn.name)},`;
463
+ const at = fn.objectStart + 1;
464
+ output = output.slice(0, at) + meta + output.slice(at);
465
+ }
466
+
467
+ return output;
468
+ }
469
+
470
+ export interface ClientImportCheckContext {
471
+ /** Absolute path file yang sedang diperiksa. */
472
+ file: string;
473
+ code: string;
474
+ projectRoot: string;
475
+ /**
476
+ * Resolve specifier dengan importer tertentu → absolute path (null bila tidak
477
+ * ada). Boleh sinkron maupun async (plugin memakai `this.resolve`, sehingga
478
+ * alias Vite & `tsconfig paths` ikut terperiksa — audit ronde 2).
479
+ */
480
+ resolve: (specifier: string, importer: string) => string | null | Promise<string | null>;
481
+ /** Analysis untuk file server (hasil cache plugin). */
482
+ analyze: (absoluteFile: string) => ServerFileAnalysis | null;
483
+ }
484
+
485
+ /** Nama serverFn (langsung atau via re-export) — versi async untuk pemeriksaan resolve. */
486
+ async function serverFnExportNamesAsync(
487
+ analysis: ServerFileAnalysis,
488
+ analyzeSpecifier: (specifier: string, importer: string) => Promise<ServerFileAnalysis | null>,
489
+ seen = new Set<string>(),
490
+ ): Promise<Set<string>> {
491
+ const names = new Set(analysis.serverFns.map((fn) => fn.name));
492
+
493
+ for (const reExport of analysis.reExports) {
494
+ const key = `${analysis.file}\0${reExport.source}`;
495
+ if (seen.has(key)) {
496
+ continue;
497
+ }
498
+ seen.add(key);
499
+ const target = await analyzeSpecifier(reExport.source, analysis.file);
500
+ if (target === null) {
501
+ continue;
502
+ }
503
+ const targetNames = await serverFnExportNamesAsync(target, analyzeSpecifier, seen);
504
+ for (const { local, exported } of reExport.names) {
505
+ if (local === "*" || targetNames.has(local)) {
506
+ names.add(exported);
507
+ }
508
+ }
509
+ }
510
+
511
+ for (const specifier of analysis.exportAllFrom) {
512
+ const key = `${analysis.file}\0${specifier}`;
513
+ if (seen.has(key)) {
514
+ continue;
515
+ }
516
+ seen.add(key);
517
+ const target = await analyzeSpecifier(specifier, analysis.file);
518
+ if (target === null) {
519
+ continue;
520
+ }
521
+ for (const name of await serverFnExportNamesAsync(target, analyzeSpecifier, seen)) {
522
+ names.add(name);
523
+ }
524
+ }
525
+
526
+ return names;
527
+ }
528
+
529
+ export function nonContractServerFileError(file: string, projectRoot: string): MoonsError {
530
+ return new MoonsError({
531
+ code: "E_SERVER_IMPORT_IN_CLIENT",
532
+ file: toRelative(projectRoot, file),
533
+ message: "file server memakai ekstensi di luar kontrak §12",
534
+ details:
535
+ "Transform zero-trust hanya menangani `*.server.ts`/`*.server.tsx`; file server dengan ekstensi lain tidak bisa di-stub dan tidak boleh masuk graph client.",
536
+ suggestions: [
537
+ "Ganti nama file menjadi `*.server.ts`/`*.server.tsx` dan taruh `serverFn` di dalamnya",
538
+ "Atau pindahkan nilai yang dibutuhkan ke file non-server",
539
+ ],
540
+ });
541
+ }
542
+
543
+ /** Periksa import modul client terhadap §12.4/§12.7/§12.8 — berbasis hasil resolve. */
544
+ export async function checkClientModuleImports(
545
+ context: ClientImportCheckContext,
546
+ ): Promise<void> {
547
+ const { file, code, projectRoot, resolve, analyze } = context;
548
+ const ast = parseModule(code, file);
549
+ const relativeFile = toRelative(projectRoot, file);
550
+
551
+ const resolveSafe = async (specifier: string, importer: string): Promise<string | null> => {
552
+ try {
553
+ const result = await resolve(specifier, importer);
554
+ if (result === null) {
555
+ return null;
556
+ }
557
+ const clean = stripQuery(result);
558
+ // Audit ronde 2: normalisasi realpath — symlink ke `*.server.ts` harus
559
+ // tertangkap walau resolver mengembalikan path link.
560
+ try {
561
+ return realpathSync(clean);
562
+ } catch {
563
+ return clean;
564
+ }
565
+ } catch {
566
+ return null;
567
+ }
568
+ };
569
+
570
+ const analyzeResolved = async (
571
+ specifier: string,
572
+ importer: string,
573
+ ): Promise<ServerFileAnalysis | null> => {
574
+ const absolute = await resolveSafe(specifier, importer);
575
+ return absolute === null ? null : analyze(absolute);
576
+ };
577
+
578
+ const isAllowedServerFnImport = async (
579
+ resolvedTarget: string | null,
580
+ specifier: string,
581
+ name: string,
582
+ ): Promise<boolean> => {
583
+ const target =
584
+ resolvedTarget === null ? await analyzeResolved(specifier, file) : analyze(resolvedTarget);
585
+ if (target === null) {
586
+ return false;
587
+ }
588
+ const names = await serverFnExportNamesAsync(target, (nested, importer) =>
589
+ analyzeResolved(nested, importer),
590
+ );
591
+ return names.has(name);
592
+ };
593
+
594
+ const reject = (line: number, message: string, details: string): never => {
595
+ throw new MoonsError({
596
+ code: "E_SERVER_IMPORT_IN_CLIENT",
597
+ file: relativeFile,
598
+ line,
599
+ message,
600
+ details,
601
+ suggestions: [
602
+ "Buat server function yang mengembalikan data yang dibutuhkan, atau",
603
+ "Pindahkan nilai itu ke file yang tidak berakhiran .server.ts",
604
+ ],
605
+ });
606
+ };
607
+
608
+ const rejectForbidden = (line: number, specifier: string): never => {
609
+ throw new MoonsError({
610
+ code: "E_FORBIDDEN_SERVER_IMPORT",
611
+ file: relativeFile,
612
+ line,
613
+ message: `mengimpor \`${specifier}\` di modul client`,
614
+ details: "Modul server-only hanya boleh di-import dari *.server.ts.",
615
+ suggestions: [
616
+ "Pindahkan operasi tersebut ke serverFn, atau",
617
+ "Hapus import tersebut",
618
+ ],
619
+ });
620
+ };
621
+
622
+ for (const statement of topLevelStatements(ast)) {
623
+ if (statement.type === "ImportDeclaration" || statement.type === "ExportNamedDeclaration" || statement.type === "ExportAllDeclaration") {
624
+ const sourceNode = statement["source"] as AstNode | null | undefined;
625
+ if (sourceNode?.type !== "StringLiteral") {
626
+ continue;
627
+ }
628
+ const rawSpecifier = sourceNode["value"] as string;
629
+ // Query Vite (`?raw`, `?url`, `?inline`, dst.) dinormalisasi untuk
630
+ // pencocokan & resolusi (audit adversarial M6).
631
+ const specifier = stripQuery(rawSpecifier);
632
+ const line = statement.loc?.start.line ?? 1;
633
+
634
+ if (specifier.startsWith("node:") || specifier.startsWith("bun:")) {
635
+ rejectForbidden(line, specifier);
636
+ }
637
+
638
+ const resolvedTarget = await resolveSafe(specifier, file);
639
+
640
+ // Audit ronde 2: file server di luar kontrak §12 ditolak (tidak bisa di-stub).
641
+ if (resolvedTarget !== null && isNonContractServerFile(resolvedTarget)) {
642
+ throw nonContractServerFileError(resolvedTarget, projectRoot);
643
+ }
644
+
645
+ // Audit ronde 2: keputusan berbasis hasil resolve — menangkap alias Vite,
646
+ // `tsconfig paths`, dan symlink (realpath) yang menunjuk `*.server.ts`.
647
+ const serverBySpecifier = isServerSpecifier(specifier);
648
+ const serverByResolve = resolvedTarget !== null && isServerFilePath(resolvedTarget);
649
+ if (!serverBySpecifier && !serverByResolve) {
650
+ continue;
651
+ }
652
+
653
+ const targetRelative =
654
+ resolvedTarget === null ? rawSpecifier : toRelative(projectRoot, resolvedTarget);
655
+
656
+ if (statement.type === "ExportAllDeclaration") {
657
+ if (resolvedTarget === null) {
658
+ reject(line, `\`export *\` dari \`${targetRelative}\` tidak bisa di-resolve`, "Re-export dari `*.server.ts` wajib bisa di-resolve statis (PRD §12.6).");
659
+ }
660
+ continue; // aman: target menjadi stub
661
+ }
662
+
663
+ const specifiers = (statement["specifiers"] ?? []) as AstNode[];
664
+ for (const item of specifiers) {
665
+ if (item.type === "ImportDefaultSpecifier") {
666
+ reject(line, `mengimpor default dari ${targetRelative}`, "Default export dari file server tidak boleh menyeberang ke client.");
667
+ }
668
+ if (item.type === "ImportNamespaceSpecifier") {
669
+ reject(line, `mengimpor namespace (\`* as\`) dari ${targetRelative}`, "Import namespace dari file server tidak boleh menyeberang ke client.");
670
+ }
671
+ const name = ((item["local"] as AstNode | undefined)?.["name"] as string) ?? "";
672
+ const kind = (item["importKind"] as string | undefined) ?? (statement["importKind"] as string | undefined);
673
+ if (kind === "type") {
674
+ reject(line, `mengimpor type \`${name}\` dari ${targetRelative}`, "Import dari file server tidak diizinkan di modul client (PRD §12.4).");
675
+ }
676
+ if (name.length > 0 && !(await isAllowedServerFnImport(resolvedTarget, specifier, name))) {
677
+ reject(
678
+ line,
679
+ `mengimpor \`${name}\` dari ${targetRelative}`,
680
+ "Kode server tidak boleh muncul di client.\nHanya `serverFn` yang diizinkan menyeberang.",
681
+ );
682
+ }
683
+ }
684
+ }
685
+ }
686
+
687
+ // §12.4: pemanggilan env() di modul client (juga @moonsjs/server import env).
688
+ const envBindings = new Set<string>();
689
+ for (const statement of topLevelStatements(ast)) {
690
+ if (statement.type !== "ImportDeclaration") {
691
+ continue;
692
+ }
693
+ const sourceNode = statement["source"] as AstNode | undefined;
694
+ const specifier = sourceNode?.type === "StringLiteral" ? (sourceNode["value"] as string) : "";
695
+ if (specifier !== "@moonsjs/server" && !specifier.startsWith("@moonsjs/server/")) {
696
+ continue;
697
+ }
698
+ for (const item of (statement["specifiers"] ?? []) as AstNode[]) {
699
+ if (item.type !== "ImportSpecifier") {
700
+ continue;
701
+ }
702
+ const imported = item["imported"] as AstNode | undefined;
703
+ const local = item["local"] as AstNode | undefined;
704
+ if (imported?.type === "Identifier" && imported["name"] === "env" && local?.type === "Identifier") {
705
+ envBindings.add(local["name"] as string);
706
+ }
707
+ }
708
+ }
709
+
710
+ if (envBindings.size > 0) {
711
+ walk(ast, (node) => {
712
+ if (node.type !== "CallExpression") {
713
+ return;
714
+ }
715
+ const callee = node["callee"] as AstNode | undefined;
716
+ if (callee?.type !== "Identifier" || !envBindings.has(callee["name"] as string)) {
717
+ return;
718
+ }
719
+ const callText = code
720
+ .slice(node.start ?? 0, node.end ?? 0)
721
+ .replace(/\s+/g, " ")
722
+ .slice(0, 80);
723
+ throw new MoonsError({
724
+ code: "E_ENV_IN_CLIENT",
725
+ file: relativeFile,
726
+ line: node.loc?.start.line ?? 1,
727
+ message: `memanggil \`${callText}\` di modul yang masuk client`,
728
+ details: "`env()` hanya legal di server.",
729
+ suggestions: [
730
+ "Pindahkan pemanggilan ke dalam serverFn, atau",
731
+ "Gunakan import.meta.env untuk nilai publik",
732
+ ],
733
+ });
734
+ });
735
+ }
736
+
737
+ // Dynamic import (§12.7/§12.8) — literal maupun template dengan teks `.server`,
738
+ // plus resolusi (alias/tsconfig paths/symlink) — audit ronde 2.
739
+ const dynamicRequests: Array<{ request: DynamicImportRequest; guarded: boolean }> = [];
740
+ walk(ast, (node, ancestors) => {
741
+ const request = dynamicImportRequest(node);
742
+ if (request !== null) {
743
+ dynamicRequests.push({
744
+ request,
745
+ guarded: ancestors.some((ancestor) => isDevGuard(ancestor)),
746
+ });
747
+ }
748
+ });
749
+
750
+ for (const { request, guarded } of dynamicRequests) {
751
+ if (guarded) {
752
+ continue; // §12.8: branch DEV/PROD mati saat build → tidak perlu diperiksa
753
+ }
754
+
755
+ const specifier = stripQuery(request.text);
756
+ const resolvedTarget = await resolveSafe(specifier, file);
757
+
758
+ if (resolvedTarget !== null && isNonContractServerFile(resolvedTarget)) {
759
+ throw nonContractServerFileError(resolvedTarget, projectRoot);
760
+ }
761
+
762
+ const serverBySpecifier = isServerSpecifier(specifier);
763
+ const serverByResolve = resolvedTarget !== null && isServerFilePath(resolvedTarget);
764
+ if (!serverBySpecifier && !serverByResolve) {
765
+ continue;
766
+ }
767
+
768
+ const targetRelative =
769
+ resolvedTarget === null ? request.text : toRelative(projectRoot, resolvedTarget);
770
+ reject(request.node.loc?.start.line ?? 1, `mengimpor (dynamic) \`${request.text}\` dari ${targetRelative}`, "Client tidak boleh melakukan dynamic import ke `*.server.ts` (PRD §12.7).");
771
+ }
772
+
773
+ // `import.meta.glob` yang mencakup `*.server.ts` (§12.7) — audit adversarial M6.
774
+ walk(ast, (node) => {
775
+ if (node.type !== "CallExpression") {
776
+ return;
777
+ }
778
+ const callee = node["callee"] as AstNode | undefined;
779
+ if (callee?.type !== "MemberExpression") {
780
+ return;
781
+ }
782
+ const object = callee["object"] as AstNode | undefined;
783
+ const property = callee["property"] as AstNode | undefined;
784
+ if (
785
+ object?.type !== "MetaProperty" ||
786
+ property?.type !== "Identifier" ||
787
+ property["name"] !== "glob"
788
+ ) {
789
+ return;
790
+ }
791
+
792
+ for (const pattern of globPatterns(((node["arguments"] ?? []) as AstNode[])[0])) {
793
+ if (!isServerSpecifier(pattern)) {
794
+ continue;
795
+ }
796
+ throw new MoonsError({
797
+ code: "E_SERVER_IMPORT_IN_CLIENT",
798
+ file: relativeFile,
799
+ line: node.loc?.start.line ?? 1,
800
+ message: `mengimpor (glob) \`${pattern}\` di modul client`,
801
+ details: "`import.meta.glob` yang mencakup `*.server.ts` dilarang di client (PRD §12.7).",
802
+ suggestions: [
803
+ "Import statis `serverFn` dari `*.server.ts` (hanya itu yang boleh menyeberang), atau",
804
+ "Pindahkan kebutuhan data ke dalam server function",
805
+ ],
806
+ });
807
+ }
808
+ });
809
+ }
810
+
811
+ interface DynamicImportRequest {
812
+ /** Teks specifier literal, atau gabungan quasi template (`*` untuk ekspresi). */
813
+ text: string;
814
+ node: AstNode;
815
+ }
816
+
817
+ /** Dukung Babel 8 (`ImportExpression`) dan bentuk lama (`CallExpression` + callee `Import`). */
818
+ function dynamicImportRequest(node: AstNode): DynamicImportRequest | null {
819
+ let sourceNode: AstNode | null = null;
820
+
821
+ if (node.type === "ImportExpression") {
822
+ sourceNode = (node["source"] as AstNode | undefined) ?? null;
823
+ } else if (node.type === "CallExpression") {
824
+ const callee = node["callee"] as AstNode | undefined;
825
+ if (callee?.type !== "Import") {
826
+ return null;
827
+ }
828
+ sourceNode = ((node["arguments"] ?? []) as AstNode[])[0] ?? null;
829
+ }
830
+
831
+ if (sourceNode === null) {
832
+ return null;
833
+ }
834
+ if (sourceNode.type === "StringLiteral") {
835
+ return { text: sourceNode["value"] as string, node: sourceNode };
836
+ }
837
+ if (sourceNode.type === "TemplateLiteral") {
838
+ const text = ((sourceNode["quasis"] ?? []) as AstNode[])
839
+ .map((quasi) => ((quasi["value"] as { cooked?: string | null } | undefined)?.cooked ?? ""))
840
+ .join("*");
841
+ return { text, node: sourceNode };
842
+ }
843
+ return null;
844
+ }
845
+
846
+ function globPatterns(node: AstNode | undefined): string[] {
847
+ if (node?.type === "StringLiteral") {
848
+ return [node["value"] as string];
849
+ }
850
+ if (node?.type === "ArrayExpression") {
851
+ return ((node["elements"] ?? []) as Array<AstNode | null>)
852
+ .filter((element): element is AstNode => element?.type === "StringLiteral")
853
+ .map((element) => element["value"] as string);
854
+ }
855
+ return [];
856
+ }
857
+
858
+ function isDevGuard(node: AstNode): boolean {
859
+ if (node.type !== "IfStatement") {
860
+ return false;
861
+ }
862
+ const test = node["test"] as AstNode | undefined;
863
+ return isImportMetaFlag(test, "DEV") || isNegatedImportMetaFlag(test, "PROD");
864
+ }
865
+
866
+ function isImportMetaFlag(node: AstNode | undefined, flag: string): boolean {
867
+ const member = node;
868
+ if (member?.type !== "MemberExpression") {
869
+ return false;
870
+ }
871
+ const property = member["property"] as AstNode | undefined;
872
+ if (property?.type !== "Identifier" || property["name"] !== flag) {
873
+ return false;
874
+ }
875
+ const object = member["object"] as AstNode | undefined;
876
+ return isImportMetaEnv(object);
877
+ }
878
+
879
+ function isNegatedImportMetaFlag(node: AstNode | undefined, flag: string): boolean {
880
+ if (node?.type !== "UnaryExpression" || node["operator"] !== "!") {
881
+ return false;
882
+ }
883
+ return isImportMetaFlag(node["argument"] as AstNode | undefined, flag);
884
+ }
885
+
886
+ function isImportMetaEnv(node: AstNode | undefined): boolean {
887
+ if (node?.type !== "MemberExpression") {
888
+ return false;
889
+ }
890
+ const property = node["property"] as AstNode | undefined;
891
+ if (property?.type !== "Identifier" || property["name"] !== "env") {
892
+ return false;
893
+ }
894
+ const object = node["object"] as AstNode | undefined;
895
+ return object?.type === "MetaProperty";
896
+ }
897
+
898
+ function toRelative(projectRoot: string, file: string): string {
899
+ const relative = file.startsWith(projectRoot)
900
+ ? file.slice(projectRoot.length).replace(/^[/\\]/, "")
901
+ : file;
902
+ return relative.split("\\").join("/");
903
+ }
904
+
905
+ export interface RouteExportCheckContext {
906
+ file: string;
907
+ code: string;
908
+ routesDir: string;
909
+ projectRoot: string;
910
+ }
911
+
912
+ /**
913
+ * §11.2 + §12.4: export legal per jenis modul route.
914
+ * page: `default|loader|meta`; layout: `default|loader`; `_error`/`_404`: `default`.
915
+ */
916
+ export function checkRouteModuleExports(context: RouteExportCheckContext): void {
917
+ const { file, code, routesDir, projectRoot } = context;
918
+ if (!file.startsWith(routesDir) || !file.endsWith(".tsx")) {
919
+ return;
920
+ }
921
+
922
+ const base = file.slice(file.lastIndexOf("/") + 1);
923
+ if (base.startsWith("_") && base !== "_layout.tsx" && base !== "_error.tsx" && base !== "_404.tsx") {
924
+ return; // bukan route (diabaikan scanner)
925
+ }
926
+
927
+ const allowed =
928
+ base === "_layout.tsx"
929
+ ? ["default", "loader"]
930
+ : base === "_error.tsx" || base === "_404.tsx"
931
+ ? ["default"]
932
+ : ["default", "loader", "meta"];
933
+ const allowedText = `Hanya ${allowed.map((name) => `\`${name}\``).join(", ")} yang boleh di-export.`;
934
+ const relativeFile = toRelative(projectRoot, file);
935
+ const ast = parseModule(code, file);
936
+
937
+ const declarations: Array<{ name: string; line: number }> = [];
938
+ let hasDefault = false;
939
+
940
+ for (const statement of topLevelStatements(ast)) {
941
+ if (statement.type === "ExportDefaultDeclaration") {
942
+ hasDefault = true;
943
+ continue;
944
+ }
945
+
946
+ if (statement.type === "ExportAllDeclaration") {
947
+ throw new MoonsError({
948
+ code: "E_ROUTE_EXPORT_INVALID",
949
+ file: relativeFile,
950
+ line: statement.loc?.start.line ?? 1,
951
+ message: "`export *` tidak diizinkan di modul route",
952
+ details: allowedText,
953
+ suggestions: [
954
+ `Pindahkan isi file ke luar ${toRelative(projectRoot, routesDir)}/, atau`,
955
+ "Hapus `export *` tersebut",
956
+ ],
957
+ });
958
+ }
959
+
960
+ if (statement.type !== "ExportNamedDeclaration") {
961
+ continue;
962
+ }
963
+
964
+ const declaration = statement["declaration"] as AstNode | null | undefined;
965
+ if (declaration !== null && declaration !== undefined) {
966
+ for (const name of declarationNames(declaration)) {
967
+ declarations.push({ name, line: declaration.loc?.start.line ?? 1 });
968
+ }
969
+ }
970
+
971
+ for (const item of (statement["specifiers"] ?? []) as AstNode[]) {
972
+ const exported = item["exported"] as AstNode | undefined;
973
+ const name =
974
+ exported?.type === "Identifier"
975
+ ? (exported["name"] as string)
976
+ : exported?.type === "StringLiteral"
977
+ ? (exported["value"] as string)
978
+ : null;
979
+ if (name !== null) {
980
+ declarations.push({ name, line: item.loc?.start.line ?? statement.loc?.start.line ?? 1 });
981
+ }
982
+ }
983
+ }
984
+
985
+ for (const entry of declarations) {
986
+ if (!allowed.includes(entry.name)) {
987
+ throw new MoonsError({
988
+ code: "E_ROUTE_EXPORT_INVALID",
989
+ file: relativeFile,
990
+ line: entry.line,
991
+ message: `export \`${entry.name}\` tidak diizinkan di modul route`,
992
+ details: allowedText,
993
+ suggestions: [
994
+ `Pindahkan \`${entry.name}\` ke file di luar ${toRelative(projectRoot, routesDir)}/, atau`,
995
+ "Hapus export tersebut",
996
+ ],
997
+ });
998
+ }
999
+ }
1000
+
1001
+ if (!hasDefault) {
1002
+ throw new MoonsError({
1003
+ code: "E_ROUTE_EXPORT_INVALID",
1004
+ file: relativeFile,
1005
+ message: "modul route wajib mengekspor `default`",
1006
+ details: "Setiap file route harus punya `export default` berupa komponen React (PRD §11.2).",
1007
+ suggestions: ["Tambahkan `export default function Page() { ... }`"],
1008
+ });
1009
+ }
1010
+ }
1011
+
1012
+ function declarationNames(declaration: AstNode): string[] {
1013
+ switch (declaration.type) {
1014
+ case "VariableDeclaration":
1015
+ return ((declaration["declarations"] ?? []) as AstNode[])
1016
+ .map((declarator) => ((declarator["id"] as AstNode | undefined)?.["name"] as string) ?? "")
1017
+ .filter((name) => name.length > 0);
1018
+ case "FunctionDeclaration":
1019
+ case "ClassDeclaration":
1020
+ case "TSInterfaceDeclaration":
1021
+ case "TSTypeAliasDeclaration":
1022
+ case "TSEnumDeclaration":
1023
+ return [((declaration["id"] as AstNode | undefined)?.["name"] as string) ?? ""].filter(
1024
+ (name) => name.length > 0,
1025
+ );
1026
+ default:
1027
+ return [];
1028
+ }
1029
+ }
1030
+
1031
+ /** Scan semua `*.server.{ts,tsx}` di bawah root (exclude direktori build/dependency). */
1032
+ export function scanServerFiles(projectRoot: string): string[] {
1033
+ const excluded = new Set(["node_modules", "dist", ".moons", ".git"]);
1034
+ const result: string[] = [];
1035
+
1036
+ const walkDir = (dir: string): void => {
1037
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1038
+ if (entry.isDirectory()) {
1039
+ if (excluded.has(entry.name)) {
1040
+ continue;
1041
+ }
1042
+ walkDir(join(dir, entry.name));
1043
+ } else if (entry.isFile() && isServerFilePath(entry.name)) {
1044
+ result.push(join(dir, entry.name));
1045
+ }
1046
+ }
1047
+ };
1048
+
1049
+ walkDir(projectRoot);
1050
+ return result.sort();
1051
+ }
1052
+
1053
+ export interface FnRegistryJson {
1054
+ [key: string]: string;
1055
+ }
1056
+
1057
+ /** Bangun isi `fn-registry.json` (bentuk yang dibaca §21.3). */
1058
+ export function buildFnRegistry(projectRoot: string, files: string[]): FnRegistryJson {
1059
+ const registry: FnRegistryJson = {};
1060
+ for (const file of files) {
1061
+ let source: string;
1062
+ try {
1063
+ source = readFileSync(file, "utf8");
1064
+ } catch {
1065
+ continue;
1066
+ }
1067
+ const analysis = analyzeServerFile(source, file, projectRoot);
1068
+ for (const fn of analysis.serverFns) {
1069
+ registry[fnRegistryKey(projectRoot, file, fn.name)] = fn.hash;
1070
+ }
1071
+ }
1072
+ return registry;
1073
+ }