@moonsjs/vite 0.1.4 → 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 (49) hide show
  1. package/dist/api.d.ts +8 -0
  2. package/dist/api.d.ts.map +1 -0
  3. package/dist/{index-33p1r65s.js → index-1fnt137k.js} +50 -5
  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 +213 -16
  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 +3 -0
  15. package/dist/server/dev.d.ts.map +1 -1
  16. package/dist/server/handler.d.ts +16 -1
  17. package/dist/server/handler.d.ts.map +1 -1
  18. package/dist/server/index.d.ts +4 -0
  19. package/dist/server/index.d.ts.map +1 -1
  20. package/dist/server/index.js +453 -17
  21. package/dist/server/index.js.map +8 -6
  22. package/dist/server/node-server.d.ts +1 -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/prerender.d.ts +28 -0
  26. package/dist/server/prerender.d.ts.map +1 -0
  27. package/dist/server-only.d.ts +57 -0
  28. package/dist/server-only.d.ts.map +1 -0
  29. package/dist/transform.d.ts +9 -1
  30. package/dist/transform.d.ts.map +1 -1
  31. package/dist/transform.js +1 -1
  32. package/dist/virtual-modules.d.ts +1 -0
  33. package/dist/virtual-modules.d.ts.map +1 -1
  34. package/package.json +1 -1
  35. package/src/api.ts +124 -0
  36. package/src/manifest.ts +42 -1
  37. package/src/payload-scan.ts +53 -0
  38. package/src/plugin.ts +130 -7
  39. package/src/server/dev.ts +14 -5
  40. package/src/server/handler.ts +317 -13
  41. package/src/server/index.ts +4 -0
  42. package/src/server/node-server.ts +47 -7
  43. package/src/server/node.ts +17 -0
  44. package/src/server/prerender.ts +230 -0
  45. package/src/server-only.ts +220 -0
  46. package/src/transform.ts +138 -8
  47. package/src/virtual-modules.ts +1 -0
  48. package/dist/index-33p1r65s.js.map +0 -14
  49. package/dist/index-3ec9vpek.js.map +0 -10
@@ -0,0 +1,230 @@
1
+ import { MoonsError, type RouteDefinition, type RouteModule } from "@moonsjs/core";
2
+ import { mkdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, resolve, sep } from "node:path";
4
+ import { scanPayloadForSecrets } from "../payload-scan";
5
+
6
+ /**
7
+ * Prerender statis (PRD Addendum D.5): render route ber-`prerender = true`
8
+ * saat build → `dist/client/<path>/index.html` (guard: payload scan fail-mode).
9
+ */
10
+ export interface PrerenderOptions {
11
+ routes: RouteDefinition[];
12
+ routesDir: string;
13
+ loadRoute: Record<string, () => Promise<RouteModule>>;
14
+ /** Handler SSR yang sudah dibuat runtime (createRequestHandler). */
15
+ handler: (request: Request) => Promise<Response>;
16
+ /** Tujuan file statis (biasanya `<root>/dist/client`). */
17
+ clientDir: string;
18
+ canary?: string;
19
+ env?: Record<string, string | undefined>;
20
+ log?: (message: string) => void;
21
+ }
22
+
23
+ export interface PrerenderResult {
24
+ written: number;
25
+ skipped: number;
26
+ }
27
+
28
+ /**
29
+ * Manifest path yang benar-benar di-prerender (dibaca static server agar hanya
30
+ * file hasil prerender yang "menang" atas SSR — bukan shell index.html biasa).
31
+ */
32
+ export const PRERENDER_MANIFEST_FILE = "_moons_prerender.json";
33
+
34
+ export async function runPrerender(options: PrerenderOptions): Promise<PrerenderResult> {
35
+ const log = options.log ?? ((message: string) => console.log(message));
36
+ const env = options.env ?? process.env;
37
+ let written = 0;
38
+ let skipped = 0;
39
+ const writtenPaths: string[] = [];
40
+
41
+ const pages = options.routes.filter((route) => route.kind === "page");
42
+ for (const route of pages) {
43
+ const key = routeKeyOf(options.routesDir, route.file);
44
+ const load = options.loadRoute[key];
45
+ if (load === undefined) {
46
+ continue;
47
+ }
48
+ const module = (await load()) as RouteModule;
49
+ const prerender = module.prerender;
50
+
51
+ if (prerender !== true) {
52
+ if (prerender !== undefined && prerender !== false) {
53
+ throw prerenderError(route, "`prerender` harus boolean (`true`/`false`)");
54
+ }
55
+ continue;
56
+ }
57
+
58
+ const pathnames = await resolvePathnames(route, module);
59
+ for (const pathname of pathnames) {
60
+ const response = await options.handler(
61
+ new Request(`http://localhost${pathname}`, {
62
+ method: "GET",
63
+ headers: { "x-moons-prerender": "1" },
64
+ }),
65
+ );
66
+
67
+ if (response.status === 404) {
68
+ log(`[moons] prerender: lewati ${pathname} (notFound)`);
69
+ skipped += 1;
70
+ continue;
71
+ }
72
+ if (response.status >= 300 && response.status < 400) {
73
+ throw prerenderError(route, `\`redirect()\` tidak didukung saat prerender (${pathname})`);
74
+ }
75
+ if (response.status !== 200) {
76
+ throw prerenderError(route, `status ${response.status} saat merender ${pathname}`);
77
+ }
78
+
79
+ const html = await response.text();
80
+ const findings = scanPayloadForSecrets(html, {
81
+ ...(options.canary === undefined ? {} : { canary: options.canary }),
82
+ env,
83
+ });
84
+ if (findings.length > 0) {
85
+ const labels = findings
86
+ .map((finding) => (finding.kind === "canary" ? "canary" : `env(${finding.label})`))
87
+ .join(", ");
88
+ throw new MoonsError({
89
+ code: "E_PRERENDER_LEAK",
90
+ file: route.file,
91
+ message: `hasil prerender \`${pathname}\` memuat ${labels}`,
92
+ details:
93
+ "HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali (PRD Addendum D.5).",
94
+ suggestions: [
95
+ "Jangan mengembalikan nilai env rahasia dari loader yang di-prerender",
96
+ "Matikan `prerender` untuk route ini atau pindahkan datanya ke serverFn request-time",
97
+ ],
98
+ });
99
+ }
100
+
101
+ const file = outputFile(options.clientDir, pathname);
102
+ mkdirSync(dirname(file), { recursive: true });
103
+ writeFileSync(file, html);
104
+ writtenPaths.push(pathname);
105
+ written += 1;
106
+ }
107
+ }
108
+
109
+ // Manifest agar static server tahu path mana yang boleh dilayani statis.
110
+ writeFileSync(
111
+ join(options.clientDir, PRERENDER_MANIFEST_FILE),
112
+ `${JSON.stringify({ paths: writtenPaths }, null, 2)}\n`,
113
+ );
114
+
115
+ log(`[moons] prerender selesai: ${written} file (${skipped} dilewati)`);
116
+ return { written, skipped };
117
+ }
118
+
119
+ function routeKeyOf(routesDir: string, file: string): string {
120
+ const relative = file.startsWith(routesDir) ? file.slice(routesDir.length + 1) : file;
121
+ return relative.split("\\").join("/").replace(/\.(tsx|ts)$/, "");
122
+ }
123
+
124
+ async function resolvePathnames(route: RouteDefinition, module: RouteModule): Promise<string[]> {
125
+ const isDynamic = route.params.length > 0;
126
+ if (!isDynamic) {
127
+ return [route.path];
128
+ }
129
+ const paths = module.paths;
130
+ if (typeof paths !== "function") {
131
+ throw new MoonsError({
132
+ code: "E_PRERENDER_NO_PATHS",
133
+ file: route.file,
134
+ message: "route dynamic ber-`prerender = true` tanpa `export const paths`",
135
+ details: "Prerender butuh daftar param untuk merender tiap path (PRD Addendum D.5).",
136
+ suggestions: [
137
+ 'Tambahkan `export const paths = () => [{ id: "…" }]`',
138
+ "Atau hapus `prerender` agar dirender SSR saat request",
139
+ ],
140
+ });
141
+ }
142
+
143
+ const raw = await (paths as () => unknown | Promise<unknown>)();
144
+ if (!Array.isArray(raw)) {
145
+ throw prerenderError(route, "`paths()` harus mengembalikan array");
146
+ }
147
+
148
+ const expected = new Set(route.params);
149
+ const result: string[] = [];
150
+ for (const item of raw as unknown[]) {
151
+ if (item === null || typeof item !== "object" || Array.isArray(item)) {
152
+ throw prerenderError(route, "item `paths()` harus objek param string");
153
+ }
154
+ const record = item as Record<string, unknown>;
155
+ const keys = Object.keys(record);
156
+ for (const name of expected) {
157
+ const value = record[name];
158
+ if (typeof value !== "string" || value.length === 0) {
159
+ throw prerenderError(route, `param \`${name}\` wajib string non-kosong`);
160
+ }
161
+ }
162
+ for (const key of keys) {
163
+ if (!expected.has(key)) {
164
+ throw prerenderError(route, `param \`${key}\` tidak ada di pola route`);
165
+ }
166
+ }
167
+ result.push(buildPathname(route, record as Record<string, string>));
168
+ }
169
+ return result;
170
+ }
171
+
172
+ function buildPathname(route: RouteDefinition, params: Record<string, string>): string {
173
+ const segments = route.path.split("/").filter((segment) => segment.length > 0);
174
+ const out: string[] = [];
175
+ let catchallName: string | null = null;
176
+ for (const segment of segments) {
177
+ if (segment === "*") {
178
+ catchallName = route.params[route.params.length - 1] ?? null;
179
+ const value = catchallName === null ? "" : (params[catchallName] ?? "");
180
+ for (const part of value.split("/")) {
181
+ if (part.length === 0) {
182
+ continue;
183
+ }
184
+ out.push(encodeSegment(route, part));
185
+ }
186
+ continue;
187
+ }
188
+ if (segment.startsWith(":")) {
189
+ out.push(encodeSegment(route, params[segment.slice(1)] ?? ""));
190
+ continue;
191
+ }
192
+ out.push(segment);
193
+ }
194
+ return `/${out.join("/")}`;
195
+ }
196
+
197
+ function encodeSegment(route: RouteDefinition, value: string): string {
198
+ if (value.length === 0 || /^\.+$/.test(value)) {
199
+ throw prerenderError(route, `nilai param \`${value}\` tidak valid untuk path file`);
200
+ }
201
+ return encodeURIComponent(value);
202
+ }
203
+
204
+ function outputFile(clientDir: string, pathname: string): string {
205
+ const parts = pathname.split("/").filter((part) => part.length > 0);
206
+ const file = resolve(join(clientDir, ...parts, "index.html"));
207
+ const base = resolve(clientDir);
208
+ if (file !== base && !file.startsWith(base + sep)) {
209
+ throw new MoonsError({
210
+ code: "E_PRERENDER_INVALID",
211
+ message: `path prerender keluar dari direktori client (\`${pathname}\`)`,
212
+ details: "Nama file harus berada di dalam `dist/client`.",
213
+ suggestions: ["Periksa nilai param `paths` — jangan memakai `..`"],
214
+ });
215
+ }
216
+ return file;
217
+ }
218
+
219
+ function prerenderError(route: RouteDefinition, message: string): MoonsError {
220
+ return new MoonsError({
221
+ code: "E_PRERENDER_INVALID",
222
+ file: route.file,
223
+ message,
224
+ details: "Kontrak prerender mengikuti PRD Addendum D.5.",
225
+ suggestions: [
226
+ "Perbaiki bentuk `paths`/param agar cocok dengan pola route",
227
+ "Jangan memakai `redirect()` pada route yang di-prerender",
228
+ ],
229
+ });
230
+ }
@@ -0,0 +1,220 @@
1
+ import { MoonsError } from "@moonsjs/core";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+
5
+ /**
6
+ * Gate paket server-only (PRD Addendum C): paket npm yang tidak boleh masuk
7
+ * graph client — di-import dari route/client/shared → build gagal.
8
+ *
9
+ * Daftar bawaan adalah **minimum normatif** dan TIDAK dapat dihapus app:
10
+ * gate kebocoran tidak boleh bisa dilonggarkan (prinsip "batas lebih ketat
11
+ * menang"); salah-tangkap diperbaiki lewat rilis framework.
12
+ */
13
+ export const DEFAULT_SERVER_ONLY_PACKAGES: ReadonlyArray<string> = [
14
+ "pg",
15
+ "postgres",
16
+ "mysql",
17
+ "mysql2",
18
+ "sqlite3",
19
+ "better-sqlite3",
20
+ "drizzle-orm",
21
+ "@prisma/client",
22
+ "prisma",
23
+ "mongodb",
24
+ "mongoose",
25
+ "redis",
26
+ "ioredis",
27
+ "bcrypt",
28
+ "bcryptjs",
29
+ "argon2",
30
+ "jsonwebtoken",
31
+ "nodemailer",
32
+ "sharp",
33
+ "execa",
34
+ "cross-spawn",
35
+ "dotenv",
36
+ "knex",
37
+ "typeorm",
38
+ "sequelize",
39
+ "@libsql/client",
40
+ ];
41
+
42
+ export type ServerOnlyMatchSource = "builtin" | "config" | "marker";
43
+
44
+ export interface PackageJsonInfo {
45
+ /** Direktori package (tempat package.json berada). */
46
+ dir: string;
47
+ name: string;
48
+ serverOnlyMarker: boolean;
49
+ }
50
+
51
+ export interface ServerOnlyMatch {
52
+ packageName: string;
53
+ source: ServerOnlyMatchSource;
54
+ packageDir: string;
55
+ }
56
+
57
+ export interface ServerOnlyClassifier {
58
+ check(input: {
59
+ specifier: string;
60
+ resolvedFile: string | null;
61
+ importerFile: string;
62
+ }): ServerOnlyMatch | null;
63
+ }
64
+
65
+ export interface ServerOnlyClassifierOptions {
66
+ /** `security.serverOnlyPackages` — tambahan milik app (tidak menghapus bawaan). */
67
+ configured?: ReadonlyArray<string>;
68
+ /** Cache package.json per-direktori; bagikan lintas modul demi performa. */
69
+ cache?: Map<string, PackageJsonInfo | null>;
70
+ }
71
+
72
+ /** Nama paket dari specifier bare (dipakai sebagai info pelengkap; pencocokan utama lewat resolve). */
73
+ export function packageNameFromSpecifier(specifier: string): string | null {
74
+ if (
75
+ specifier.length === 0 ||
76
+ specifier.startsWith(".") ||
77
+ specifier.startsWith("/") ||
78
+ specifier.startsWith("\0") ||
79
+ specifier.includes(":")
80
+ ) {
81
+ return null;
82
+ }
83
+ const parts = specifier.split("/");
84
+ if (specifier.startsWith("@")) {
85
+ if (parts.length < 2 || (parts[1] ?? "").length === 0) {
86
+ return null;
87
+ }
88
+ return `${parts[0]}/${parts[1]}`;
89
+ }
90
+ return parts[0] ?? null;
91
+ }
92
+
93
+ /** Baca package.json pada direktori tertentu (null bila tidak ada/gagal). */
94
+ export function tryReadPackageJson(dir: string): PackageJsonInfo | null {
95
+ const candidate = join(dir, "package.json");
96
+ if (!existsSync(candidate)) {
97
+ return null;
98
+ }
99
+ try {
100
+ const json = JSON.parse(readFileSync(candidate, "utf8")) as {
101
+ name?: unknown;
102
+ moons?: unknown;
103
+ };
104
+ const moons = json.moons as { serverOnly?: unknown } | undefined;
105
+ return {
106
+ dir,
107
+ name: typeof json.name === "string" ? json.name : "",
108
+ serverOnlyMarker: moons?.serverOnly === true,
109
+ };
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ /** Baca package.json terdekat dari sebuah file absolut (menelusuri ke atas). */
116
+ export function readPackageJsonFrom(file: string): PackageJsonInfo | null {
117
+ let dir = dirname(file);
118
+ for (let depth = 0; depth < 64; depth += 1) {
119
+ const info = tryReadPackageJson(dir);
120
+ if (info !== null) {
121
+ return info;
122
+ }
123
+ const parent = dirname(dir);
124
+ if (parent === dir) {
125
+ return null;
126
+ }
127
+ dir = parent;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ export function createServerOnlyClassifier(
133
+ options: ServerOnlyClassifierOptions = {},
134
+ ): ServerOnlyClassifier {
135
+ const cache = options.cache ?? new Map<string, PackageJsonInfo | null>();
136
+ const configured = new Set(options.configured ?? []);
137
+ const builtin = new Set(DEFAULT_SERVER_ONLY_PACKAGES);
138
+
139
+ const infoOf = (file: string): PackageJsonInfo | null => {
140
+ let dir = dirname(file);
141
+ for (let depth = 0; depth < 64; depth += 1) {
142
+ if (cache.has(dir)) {
143
+ return cache.get(dir) ?? null;
144
+ }
145
+ const info = tryReadPackageJson(dir);
146
+ if (info !== null) {
147
+ cache.set(dir, info);
148
+ return info;
149
+ }
150
+ // Direktori tanpa package.json: lanjut ke atas (tidak di-cache agar
151
+ // penelusuran berikutnya tetap benar untuk subtree berbeda).
152
+ const parent = dirname(dir);
153
+ if (parent === dir) {
154
+ return null;
155
+ }
156
+ dir = parent;
157
+ }
158
+ return null;
159
+ };
160
+
161
+ return {
162
+ check({ specifier: _specifier, resolvedFile, importerFile }) {
163
+ if (resolvedFile === null) {
164
+ return null;
165
+ }
166
+ const info = infoOf(resolvedFile);
167
+ if (info === null || info.name.length === 0) {
168
+ return null;
169
+ }
170
+ // Gate menyasar dependency: import di dalam package yang sama diabaikan.
171
+ const importer = infoOf(importerFile);
172
+ if (importer !== null && importer.dir === info.dir) {
173
+ return null;
174
+ }
175
+ if (builtin.has(info.name)) {
176
+ return { packageName: info.name, source: "builtin", packageDir: info.dir };
177
+ }
178
+ if (configured.has(info.name)) {
179
+ return { packageName: info.name, source: "config", packageDir: info.dir };
180
+ }
181
+ if (info.serverOnlyMarker) {
182
+ return { packageName: info.name, source: "marker", packageDir: info.dir };
183
+ }
184
+ return null;
185
+ },
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Error §15 untuk import paket server-only (kode tetap
191
+ * `E_FORBIDDEN_SERVER_IMPORT`, PRD Addendum C.3).
192
+ */
193
+ export function serverOnlyImportError(input: {
194
+ relativeFile: string;
195
+ line: number;
196
+ specifier: string;
197
+ packageName: string;
198
+ source: ServerOnlyMatchSource;
199
+ /** Path package.json relatif project — untuk sumber kecocokan "marker". */
200
+ markerPath?: string;
201
+ dynamic?: boolean;
202
+ }): MoonsError {
203
+ const sourceLabel =
204
+ input.source === "builtin"
205
+ ? "Sumber kecocokan: daftar bawaan paket server-only framework."
206
+ : input.source === "config"
207
+ ? "Sumber kecocokan: `security.serverOnlyPackages` di moons.config.ts."
208
+ : `Sumber kecocokan: marker \`"moons": { "serverOnly": true }\` di ${input.markerPath ?? "package.json"}.`;
209
+ return new MoonsError({
210
+ code: "E_FORBIDDEN_SERVER_IMPORT",
211
+ file: input.relativeFile,
212
+ line: input.line,
213
+ message: `mengimpor${input.dynamic === true ? " (dynamic)" : ""} \`${input.specifier}\` — paket server-only \`${input.packageName}\``,
214
+ details: `${sourceLabel}\nPaket server-only tidak boleh masuk bundle client (PRD Addendum C).`,
215
+ suggestions: [
216
+ "Pindahkan operasi tersebut ke `*.server.ts` dan panggil lewat serverFn",
217
+ "Atau ganti/hapus import tersebut di modul client",
218
+ ],
219
+ });
220
+ }
package/src/transform.ts CHANGED
@@ -2,6 +2,11 @@ import { MoonsError, computeFnHash, fnRegistryKey } from "@moonsjs/core";
2
2
  import { parse, parseExpression } from "@babel/parser";
3
3
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import {
6
+ createServerOnlyClassifier,
7
+ serverOnlyImportError,
8
+ type PackageJsonInfo,
9
+ } from "./server-only";
5
10
 
6
11
  /**
7
12
  * Spec §12: analisis `.server.ts` + stub client + injeksi meta server +
@@ -124,6 +129,27 @@ function topLevelStatements(ast: AstNode): AstNode[] {
124
129
  return program?.body ?? [];
125
130
  }
126
131
 
132
+ /**
133
+ * Type-only import/export (Addendum C.1): `import type ...`, `export type ...`,
134
+ * atau seluruh specifier bertanda `type`. Terhapus saat kompilasi sehingga
135
+ * dikecualikan dari gate paket server-only. `export * from` = bernilai.
136
+ */
137
+ function isTypeOnlyImportExport(statement: AstNode): boolean {
138
+ const declarationKind = (statement["importKind"] ?? statement["exportKind"]) as
139
+ | string
140
+ | undefined;
141
+ if (declarationKind === "type") {
142
+ return true;
143
+ }
144
+ const specifiers = (statement["specifiers"] ?? []) as AstNode[];
145
+ if (specifiers.length === 0) {
146
+ return false;
147
+ }
148
+ return specifiers.every(
149
+ (item) => ((item["importKind"] ?? item["exportKind"]) as string | undefined) === "type",
150
+ );
151
+ }
152
+
127
153
  function keyName(node: unknown): string | null {
128
154
  const key = node as AstNode | undefined;
129
155
  if (key?.type === "Identifier") {
@@ -516,11 +542,19 @@ export function renderServerFnStub(
516
542
  annotation: fn.name,
517
543
  message: `schema ${kind} serverFn \`${fn.name}\` merujuk deklarasi lokal \`${reference}\``,
518
544
  details:
519
- "Stub client (PRD §12.2/§12.3) hanya membawa import dan ekspresi schema; " +
520
- "deklarasi lokal dibuang sehingga referensi menjadi `undefined` di browser (crash hydration).",
545
+ `\`${reference}\` dibuang dari stub client (PRD §12.2/§12.3), sehingga nilainya \`undefined\` di browser (crash hydration).\n\n` +
546
+ "Sebelum (ditolak):\n" +
547
+ ` const ${reference} = z.object({ … }); // deklarasi lokal di file yang sama\n` +
548
+ ` export const ${fn.name} = serverFn({ ${kind}: ${reference}, … });\n\n` +
549
+ "Sesudah (pilih salah satu):\n" +
550
+ ` // a) inline schema di argumen\n` +
551
+ ` export const ${fn.name} = serverFn({ ${kind}: z.object({ … }), … });\n` +
552
+ ` // b) pindahkan deklarasi ke modul bersama yang boleh masuk client (mis. src/shared/schemas.ts)\n` +
553
+ ` import { ${reference} } from "../shared/schemas";`,
521
554
  suggestions: [
522
- "Inline schema langsung di argumen `serverFn({ input, output })`, atau",
523
- "Impor schema dari modul lain (mis. `src/shared/schemas.ts`)",
555
+ `Inline schema di argumen \`serverFn({ ${kind}: … })\`, atau`,
556
+ `Pindahkan \`${reference}\` ke modul bersama (mis. \`src/shared/schemas.ts\`) lalu impor dari sana`,
557
+ "Jangan memindahkan nilai rahasia ke shared — schema harus client-safe (zod/Standard Schema)",
524
558
  ],
525
559
  });
526
560
  }
@@ -617,6 +651,12 @@ export interface ClientImportCheckContext {
617
651
  resolve: (specifier: string, importer: string) => string | null | Promise<string | null>;
618
652
  /** Analysis untuk file server (hasil cache plugin). */
619
653
  analyze: (absoluteFile: string) => ServerFileAnalysis | null;
654
+ /** Direktori `src/api` (absolut) — modul API server-only (Addendum D.1). */
655
+ apiDir?: string;
656
+ /** Paket server-only tambahan dari `security.serverOnlyPackages` (Addendum C). */
657
+ serverOnlyPackages?: ReadonlyArray<string>;
658
+ /** Cache package.json lintas modul (performa; dikelola plugin). */
659
+ packageCache?: Map<string, PackageJsonInfo | null>;
620
660
  }
621
661
 
622
662
  /** Nama serverFn (langsung atau via re-export) — versi async untuk pemeriksaan resolve. */
@@ -684,6 +724,36 @@ export async function checkClientModuleImports(
684
724
  const { file, code, projectRoot, resolve, analyze } = context;
685
725
  const ast = parseModule(code, file);
686
726
  const relativeFile = toRelative(projectRoot, file);
727
+ // Addendum C: gate paket server-only (daftar bawaan ∪ security.serverOnlyPackages ∪ marker).
728
+ const serverOnly = createServerOnlyClassifier({
729
+ configured: context.serverOnlyPackages ?? [],
730
+ ...(context.packageCache === undefined ? {} : { cache: context.packageCache }),
731
+ });
732
+ // Addendum D.1: `src/api/**` server-only (endpoint HTTP mentah).
733
+ let apiDirReal: string | null = null;
734
+ if (context.apiDir !== undefined && context.apiDir.length > 0) {
735
+ try {
736
+ apiDirReal = realpathSync(context.apiDir);
737
+ } catch {
738
+ apiDirReal = context.apiDir;
739
+ }
740
+ }
741
+
742
+ const rejectApiImport = (line: number, specifier: string, resolvedTarget: string): never => {
743
+ const relApi = toRelative(projectRoot, resolvedTarget);
744
+ throw new MoonsError({
745
+ code: "E_FORBIDDEN_SERVER_IMPORT",
746
+ file: relativeFile,
747
+ line,
748
+ message: `mengimpor \`${specifier}\` — \`${relApi}\` adalah modul API server-only`,
749
+ details:
750
+ "`src/api/**` hanya berjalan di server (endpoint HTTP) dan tidak pernah masuk bundle client (PRD Addendum D.1).",
751
+ suggestions: [
752
+ "Pindahkan operasi tersebut ke `*.server.ts` dan panggil lewat serverFn",
753
+ "Jangan mengimpor file `src/api` dari route/komponen client",
754
+ ],
755
+ });
756
+ };
687
757
 
688
758
  const resolveSafe = async (specifier: string, importer: string): Promise<string | null> => {
689
759
  try {
@@ -767,6 +837,9 @@ export async function checkClientModuleImports(
767
837
  // pencocokan & resolusi (audit adversarial M6).
768
838
  const specifier = stripQuery(rawSpecifier);
769
839
  const line = statement.loc?.start.line ?? 1;
840
+ // Addendum C: type-only (`import type ...`, seluruh specifier tipe)
841
+ // terhapus saat kompilasi → dikecualikan dari gate paket server-only.
842
+ const typeOnly = isTypeOnlyImportExport(statement);
770
843
 
771
844
  if (specifier.startsWith("node:") || specifier.startsWith("bun:")) {
772
845
  rejectForbidden(line, specifier);
@@ -774,6 +847,33 @@ export async function checkClientModuleImports(
774
847
 
775
848
  const resolvedTarget = await resolveSafe(specifier, file);
776
849
 
850
+ // Addendum D.1: modul `src/api/**` server-only.
851
+ if (apiDirReal !== null && resolvedTarget !== null && isInsideDir(resolvedTarget, apiDirReal)) {
852
+ rejectApiImport(line, rawSpecifier, resolvedTarget);
853
+ }
854
+
855
+ // Addendum C: paket server-only (nama paket hasil resolve — subpath,
856
+ // alias, tsconfig paths, symlink/workspace ikut tertangkap).
857
+ if (!typeOnly) {
858
+ const match = serverOnly.check({
859
+ specifier,
860
+ resolvedFile: resolvedTarget,
861
+ importerFile: file,
862
+ });
863
+ if (match !== null) {
864
+ throw serverOnlyImportError({
865
+ relativeFile,
866
+ line,
867
+ specifier: rawSpecifier,
868
+ packageName: match.packageName,
869
+ source: match.source,
870
+ ...(match.source === "marker"
871
+ ? { markerPath: toRelative(projectRoot, join(match.packageDir, "package.json")) }
872
+ : {}),
873
+ });
874
+ }
875
+ }
876
+
777
877
  // Audit ronde 2: file server di luar kontrak §12 ditolak (tidak bisa di-stub).
778
878
  if (resolvedTarget !== null && isNonContractServerFile(resolvedTarget)) {
779
879
  throw nonContractServerFileError(resolvedTarget, projectRoot);
@@ -892,6 +992,31 @@ export async function checkClientModuleImports(
892
992
  const specifier = stripQuery(request.text);
893
993
  const resolvedTarget = await resolveSafe(specifier, file);
894
994
 
995
+ // Addendum D.1: dynamic import modul `src/api/**` juga ditolak.
996
+ if (apiDirReal !== null && resolvedTarget !== null && isInsideDir(resolvedTarget, apiDirReal)) {
997
+ rejectApiImport(request.node.loc?.start.line ?? 1, request.text, resolvedTarget);
998
+ }
999
+
1000
+ // Addendum C: dynamic import paket server-only juga digerbang.
1001
+ const match = serverOnly.check({
1002
+ specifier,
1003
+ resolvedFile: resolvedTarget,
1004
+ importerFile: file,
1005
+ });
1006
+ if (match !== null) {
1007
+ throw serverOnlyImportError({
1008
+ relativeFile,
1009
+ line: request.node.loc?.start.line ?? 1,
1010
+ specifier: request.text,
1011
+ packageName: match.packageName,
1012
+ source: match.source,
1013
+ dynamic: true,
1014
+ ...(match.source === "marker"
1015
+ ? { markerPath: toRelative(projectRoot, join(match.packageDir, "package.json")) }
1016
+ : {}),
1017
+ });
1018
+ }
1019
+
895
1020
  if (resolvedTarget !== null && isNonContractServerFile(resolvedTarget)) {
896
1021
  throw nonContractServerFileError(resolvedTarget, projectRoot);
897
1022
  }
@@ -980,8 +1105,7 @@ function dynamicImportRequest(node: AstNode): DynamicImportRequest | null {
980
1105
  return null;
981
1106
  }
982
1107
 
983
- function globPatterns(node: AstNode | undefined): string[] {
984
- if (node?.type === "StringLiteral") {
1108
+ function globPatterns(node: AstNode | undefined): string[] { if (node?.type === "StringLiteral") {
985
1109
  return [node["value"] as string];
986
1110
  }
987
1111
  if (node?.type === "ArrayExpression") {
@@ -1039,6 +1163,11 @@ function toRelative(projectRoot: string, file: string): string {
1039
1163
  return relative.split("\\").join("/");
1040
1164
  }
1041
1165
 
1166
+ function isInsideDir(file: string, dir: string): boolean {
1167
+ const normalized = dir.endsWith("/") ? dir : `${dir}/`;
1168
+ return file === dir || file.startsWith(normalized);
1169
+ }
1170
+
1042
1171
  export interface RouteExportCheckContext {
1043
1172
  file: string;
1044
1173
  code: string;
@@ -1048,7 +1177,8 @@ export interface RouteExportCheckContext {
1048
1177
 
1049
1178
  /**
1050
1179
  * §11.2 + §12.4: export legal per jenis modul route.
1051
- * page: `default|loader|meta`; layout: `default|loader`; `_error`/`_404`: `default`.
1180
+ * page: `default|loader|meta|prerender|paths` (D.5); layout: `default|loader`;
1181
+ * `_error`/`_404`: `default`.
1052
1182
  */
1053
1183
  export function checkRouteModuleExports(context: RouteExportCheckContext): void {
1054
1184
  const { file, code, routesDir, projectRoot } = context;
@@ -1066,7 +1196,7 @@ export function checkRouteModuleExports(context: RouteExportCheckContext): void
1066
1196
  ? ["default", "loader"]
1067
1197
  : base === "_error.tsx" || base === "_404.tsx"
1068
1198
  ? ["default"]
1069
- : ["default", "loader", "meta"];
1199
+ : ["default", "loader", "meta", "prerender", "paths"];
1070
1200
  const allowedText = `Hanya ${allowed.map((name) => `\`${name}\``).join(", ")} yang boleh di-export.`;
1071
1201
  const relativeFile = toRelative(projectRoot, file);
1072
1202
  const ast = parseModule(code, file);
@@ -1,6 +1,7 @@
1
1
  /** Nama virtual module PRD §9.4. Semua di-resolve ke id `\0`-prefix. */
2
2
  export const VIRTUAL_MODULES = {
3
3
  routes: "virtual:moons/routes",
4
+ api: "virtual:moons/api",
4
5
  config: "virtual:moons/config",
5
6
  clientEntry: "virtual:moons/client-entry",
6
7
  serverEntry: "virtual:moons/server-entry",