@moonsjs/vite 0.1.12 → 0.1.13

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.
@@ -1,10 +1,17 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { createServer, type Server } from "node:http";
3
3
  import { extname, join, resolve, sep } from "node:path";
4
- import type { PrerenderStorage } from "@moonsjs/core";
4
+ import { DEFAULT_BODY_LIMIT_BYTES, MoonsError, type PrerenderStorage } from "@moonsjs/core";
5
5
  import { revalidatePrerendered, shouldRevalidate } from "./isr";
6
6
  import { contentTypeForPathname } from "./storage";
7
- import { sendNodeResponse, toWebRequest } from "./node";
7
+ import {
8
+ BodyLimitError,
9
+ DEFAULT_SECURITY_HEADERS,
10
+ InvalidRequestError,
11
+ resolveRequestUrl,
12
+ sendNodeResponse,
13
+ toWebRequest,
14
+ } from "./node";
8
15
  import type { PluginRuntime } from "./plugins";
9
16
  import { installGracefulShutdown } from "./shutdown";
10
17
 
@@ -52,6 +59,10 @@ export interface NodeServerOptions {
52
59
  * memakai storage ini; default: file sistem lokal.
53
60
  */
54
61
  storage?: PrerenderStorage;
62
+ /** Addendum J.1: percayai `x-forwarded-proto`/`x-forwarded-host` (default false). */
63
+ trustProxy?: boolean;
64
+ /** Addendum J.2: batas body RPC di transport (default `DEFAULT_BODY_LIMIT_BYTES`). */
65
+ bodyLimitBytes?: number;
55
66
  }
56
67
 
57
68
  /**
@@ -67,9 +78,21 @@ export function startNodeServer(
67
78
 
68
79
  const server = createServer((req, res) => {
69
80
  void (async () => {
81
+ let url: URL;
70
82
  try {
71
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
83
+ // Addendum J.1: URL/Origin mengikuti `x-forwarded-*` bila trustProxy aktif;
84
+ // request malformed → 400 (bukan 500 + stack).
85
+ url = resolveRequestUrl(req, { trustProxy: options.trustProxy === true });
86
+ } catch (error) {
87
+ if (error instanceof InvalidRequestError) {
88
+ console.warn(`[moons] ${error.message} — 400`);
89
+ respondBadRequest(res);
90
+ return;
91
+ }
92
+ throw error;
93
+ }
72
94
 
95
+ try {
73
96
  if (req.method === "GET" || req.method === "HEAD") {
74
97
  // Addendum I: storage bersama (bila dikonfigurasi) menggantikan file lokal.
75
98
  if (options.storage !== undefined) {
@@ -85,11 +108,7 @@ export function startNodeServer(
85
108
  });
86
109
  res.statusCode = 200;
87
110
  res.setHeader("content-type", contentTypeForPathname(url.pathname));
88
- for (const [name, value] of Object.entries(options.headers ?? {})) {
89
- if (!res.hasHeader(name)) {
90
- res.setHeader(name, value);
91
- }
92
- }
111
+ applyStaticHeaders(res, url.pathname, options.headers);
93
112
  res.end(req.method === "HEAD" ? undefined : Buffer.from(entry.body));
94
113
  return;
95
114
  }
@@ -107,20 +126,44 @@ export function startNodeServer(
107
126
  const body = readFileSync(staticFile);
108
127
  res.statusCode = 200;
109
128
  res.setHeader("content-type", MIME_TYPES[extname(staticFile).toLowerCase()] ?? "application/octet-stream");
110
- for (const [name, value] of Object.entries(options.headers ?? {})) {
111
- if (!res.hasHeader(name)) {
112
- res.setHeader(name, value);
113
- }
114
- }
129
+ applyStaticHeaders(res, url.pathname, options.headers);
115
130
  res.end(req.method === "HEAD" ? undefined : body);
116
131
  return;
117
132
  }
118
133
  }
119
134
  }
120
135
 
121
- const response = await handler(await toWebRequest(req));
136
+ // Addendum J.2: batas body RPC ditegakkan di transport — lebih awal dari
137
+ // pipeline, tanpa membaca penuh; `content-length` diperiksa lebih dulu.
138
+ const isRpc = url.pathname.startsWith("/_moons/fn/");
139
+ const rpcLimit = options.bodyLimitBytes ?? DEFAULT_BODY_LIMIT_BYTES;
140
+ if (isRpc) {
141
+ const contentLength = Number(req.headers["content-length"] ?? "0");
142
+ if (Number.isFinite(contentLength) && contentLength > rpcLimit) {
143
+ res.statusCode = 413;
144
+ res.setHeader("content-type", "text/plain; charset=utf-8");
145
+ res.end(rpcBodyTooLargeText(rpcLimit, contentLength));
146
+ res.once("finish", () => req.destroy());
147
+ return;
148
+ }
149
+ }
150
+
151
+ const request = await toWebRequest(req, {
152
+ trustProxy: options.trustProxy === true,
153
+ ...(isRpc ? { maxBodyBytes: rpcLimit } : {}),
154
+ });
155
+ const response = await handler(request);
122
156
  await sendNodeResponse(res, response);
123
157
  } catch (error) {
158
+ if (error instanceof BodyLimitError) {
159
+ const isRpc = url.pathname.startsWith("/_moons/fn/");
160
+ res.statusCode = 413;
161
+ res.setHeader("content-type", "text/plain; charset=utf-8");
162
+ res.end(isRpc ? rpcBodyTooLargeText(error.limit, null) : "Payload Too Large");
163
+ // Sisa body tidak perlu dibaca — putuskan setelah respons terkirim.
164
+ res.once("finish", () => req.destroy());
165
+ return;
166
+ }
124
167
  console.error("[moons] server error:", error);
125
168
  res.statusCode = 500;
126
169
  res.setHeader("content-type", "text/plain; charset=utf-8");
@@ -159,6 +202,54 @@ export function startNodeServer(
159
202
  return server;
160
203
  }
161
204
 
205
+ /**
206
+ * Addendum J.7: header konfigurasi (menang), default aman, dan
207
+ * `Cache-Control: immutable` untuk aset ber-hash di `/assets/*`.
208
+ */
209
+ function applyStaticHeaders(
210
+ res: import("node:http").ServerResponse,
211
+ pathname: string,
212
+ configured: Record<string, string> | undefined,
213
+ ): void {
214
+ for (const [name, value] of Object.entries(configured ?? {})) {
215
+ if (!res.hasHeader(name)) {
216
+ res.setHeader(name, value);
217
+ }
218
+ }
219
+ for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
220
+ if (!res.hasHeader(name)) {
221
+ res.setHeader(name, value);
222
+ }
223
+ }
224
+ if (pathname.startsWith("/assets/") && !res.hasHeader("cache-control")) {
225
+ res.setHeader("cache-control", "public, max-age=31536000, immutable");
226
+ }
227
+ }
228
+
229
+ function respondBadRequest(res: import("node:http").ServerResponse): void {
230
+ res.statusCode = 400;
231
+ res.setHeader("content-type", "text/plain; charset=utf-8");
232
+ res.end("Bad Request");
233
+ }
234
+
235
+ /** 413 transport dengan format §15 yang sama seperti `rpc.ts` (Addendum J.2). */
236
+ function rpcBodyTooLargeText(limit: number, size: number | null): string {
237
+ const megabytes = (bytes: number): string => (bytes / 1_000_000).toFixed(1);
238
+ return new MoonsError({
239
+ code: "E_RPC_BODY_TOO_LARGE",
240
+ message:
241
+ size === null
242
+ ? `body melebihi limit ${megabytes(limit)} MB`
243
+ : `body ${megabytes(size)} MB melebihi limit ${megabytes(limit)} MB`,
244
+ details:
245
+ "Body melebihi `security.bodyLimitBytes`; pembacaan dihentikan di transport sebelum penuh.",
246
+ suggestions: [
247
+ "Kurangi ukuran payload, atau",
248
+ "Naikkan security.bodyLimitBytes jika memang perlu",
249
+ ],
250
+ }).format();
251
+ }
252
+
162
253
  /** Resolve file static dengan proteksi path traversal (+ manifest prerender D.5). */
163
254
  export function resolveStaticFile(clientDir: string, pathname: string): string | null {
164
255
  const target = staticFileTarget(clientDir, pathname);
@@ -1,17 +1,88 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import { Readable } from "node:stream";
3
+ import { pipeline } from "node:stream/promises";
4
+ import { CLIENT_IP_HEADER } from "@moonsjs/core";
2
5
 
3
- const MAX_BODY_BYTES = 32 * 1024 * 1024; // batas defensif transport; limit RPC §7.3 tetap berlaku
6
+ /** Batas transport default (Addendum J.2); RPC punya batas lebih kecil dari config. */
7
+ export const MAX_TRANSPORT_BODY_BYTES = 32 * 1024 * 1024;
4
8
 
5
- /** Konversi request Node → Web `Request` (body diteruskan untuk non-GET/HEAD, §7.3). */
6
- export async function toWebRequest(req: IncomingMessage): Promise<Request> {
7
- const host = req.headers.host ?? "localhost";
8
- const url = `http://${host}${req.url ?? "/"}`;
9
+ /** Request tidak valid (Host/URL malformed) → dipetakan ke 400 oleh server. */
10
+ export class InvalidRequestError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+ this.name = "InvalidRequestError";
14
+ }
15
+ }
16
+
17
+ /** Body melebihi batas → dipetakan ke 413; pembacaan dihentikan saat melewati. */
18
+ export class BodyLimitError extends Error {
19
+ readonly limit: number;
20
+ constructor(limit: number) {
21
+ super(`request body melebihi batas ${limit} byte`);
22
+ this.name = "BodyLimitError";
23
+ this.limit = limit;
24
+ }
25
+ }
26
+
27
+ export interface ToWebRequestOptions {
28
+ /** Addendum J.1: percayai `x-forwarded-proto`/`x-forwarded-host` (default false). */
29
+ trustProxy?: boolean;
30
+ /** Batas pembacaan body (byte). Default: `MAX_TRANSPORT_BODY_BYTES`. */
31
+ maxBodyBytes?: number;
32
+ }
33
+
34
+ /**
35
+ * Rekonstruksi URL request (Addendum J.1). Tanpa `trustProxy`, origin/host
36
+ * murni dari header `Host` (perilaku lama, kebal spoofing `x-forwarded-*`).
37
+ * Malformed → `InvalidRequestError` (server memetakan ke 400).
38
+ */
39
+ export function resolveRequestUrl(
40
+ req: IncomingMessage,
41
+ options: ToWebRequestOptions = {},
42
+ ): URL {
43
+ const hostHeader = req.headers.host ?? "localhost";
44
+ const trustProxy = options.trustProxy === true;
45
+ const proto = trustProxy ? forwardedProtocol(req.headers["x-forwarded-proto"]) : null;
46
+ const forwardedHostValue = trustProxy ? forwardedHost(req.headers["x-forwarded-host"]) : null;
47
+ const host = forwardedHostValue ?? hostHeader;
48
+ try {
49
+ return new URL(`${proto ?? "http"}://${host}${req.url ?? "/"}`);
50
+ } catch {
51
+ throw new InvalidRequestError(`header Host tidak valid: ${JSON.stringify(hostHeader)}`);
52
+ }
53
+ }
54
+
55
+ /** IP klien tepercaya (Addendum J.1): XFF (hanya bila trustProxy) → socket. */
56
+ export function resolveClientIp(req: IncomingMessage, trustProxy: boolean): string | null {
57
+ if (trustProxy) {
58
+ const forwarded = firstHeaderValue(req.headers["x-forwarded-for"]);
59
+ if (forwarded !== null && isPlausibleIp(forwarded)) {
60
+ return normalizeIp(forwarded);
61
+ }
62
+ }
63
+ const remote = req.socket?.remoteAddress ?? null;
64
+ return remote === null ? null : normalizeIp(remote);
65
+ }
66
+
67
+ /** `::ffff:127.0.0.1` → `127.0.0.1`; buang zone id (`%eth0`). */
68
+ function normalizeIp(value: string): string {
69
+ const withoutZone = stripZoneId(value);
70
+ return withoutZone.startsWith("::ffff:") ? withoutZone.slice(7) : withoutZone;
71
+ }
72
+
73
+ /** Konversi request Node → Web `Request` (body diteruskan untuk non-GET/HEAD, §7.3 + J.1/J.2). */
74
+ export async function toWebRequest(
75
+ req: IncomingMessage,
76
+ options: ToWebRequestOptions = {},
77
+ ): Promise<Request> {
78
+ const url = resolveRequestUrl(req, options);
9
79
  const method = req.method ?? "GET";
10
80
 
11
81
  const headers = new Headers();
12
82
  for (const [key, value] of Object.entries(req.headers)) {
13
- if (value === undefined || key === "content-length") {
83
+ if (value === undefined || key === "content-length" || key === CLIENT_IP_HEADER) {
14
84
  // content-length tidak disalin; fetch menghitung ulang dari body.
85
+ // x-moons-client-ip selalu ditimpa nilai tepercaya di bawah.
15
86
  continue;
16
87
  }
17
88
  if (Array.isArray(value)) {
@@ -23,34 +94,69 @@ export async function toWebRequest(req: IncomingMessage): Promise<Request> {
23
94
  }
24
95
  }
25
96
 
97
+ const clientIp = resolveClientIp(req, options.trustProxy === true);
98
+ if (clientIp !== null) {
99
+ headers.set(CLIENT_IP_HEADER, clientIp);
100
+ }
101
+
26
102
  if (method === "GET" || method === "HEAD") {
27
103
  return new Request(url, { method, headers });
28
104
  }
29
105
 
30
- const body = await readBody(req);
31
- return new Request(url, { method, headers, body: new Uint8Array(body) });
106
+ const body = await readBody(req, options.maxBodyBytes ?? MAX_TRANSPORT_BODY_BYTES);
107
+ // View zero-copy (bukan salinan) — Buffer selalu di atas ArrayBuffer biasa.
108
+ const view = new Uint8Array(body.buffer as ArrayBuffer, body.byteOffset, body.byteLength);
109
+ return new Request(url, { method, headers, body: view });
32
110
  }
33
111
 
34
- function readBody(req: IncomingMessage): Promise<Buffer> {
112
+ /**
113
+ * Baca body dengan batas tegas (Addendum J.2): begitu melewati `maxBytes`,
114
+ * pembacaan dihentikan (pause) dan `BodyLimitError` dilempar — tidak pernah
115
+ * seluruh body besar masuk memori, dan hanya satu salinan buffer dibuat.
116
+ */
117
+ export function readBody(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
35
118
  return new Promise((resolvePromise, reject) => {
36
119
  const chunks: Buffer[] = [];
37
120
  let size = 0;
121
+ let settled = false;
122
+
123
+ const fail = (error: Error): void => {
124
+ if (settled) {
125
+ return;
126
+ }
127
+ settled = true;
128
+ req.pause();
129
+ reject(error);
130
+ };
38
131
 
39
132
  req.on("data", (chunk: Buffer) => {
133
+ if (settled) {
134
+ return;
135
+ }
40
136
  size += chunk.length;
41
- if (size > MAX_BODY_BYTES) {
42
- req.destroy();
43
- reject(new Error("request body melebihi batas transport"));
137
+ if (size > maxBytes) {
138
+ fail(new BodyLimitError(maxBytes));
44
139
  return;
45
140
  }
46
141
  chunks.push(chunk);
47
142
  });
48
- req.on("end", () => resolvePromise(Buffer.concat(chunks)));
49
- req.on("error", (error) => reject(error));
143
+ req.on("end", () => {
144
+ if (!settled) {
145
+ settled = true;
146
+ resolvePromise(Buffer.concat(chunks));
147
+ }
148
+ });
149
+ req.on("error", (error) => fail(error));
50
150
  });
51
151
  }
52
152
 
53
- /** Tulis Web `Response` ke `ServerResponse` Node. */
153
+ /** Addendum J.7: default aman runtime; `config.headers` tetap menang (cek hasHeader). */
154
+ export const DEFAULT_SECURITY_HEADERS: Record<string, string> = {
155
+ "x-content-type-options": "nosniff",
156
+ "referrer-policy": "strict-origin-when-cross-origin",
157
+ };
158
+
159
+ /** Tulis Web `Response` ke `ServerResponse` Node (streaming bila ada body). */
54
160
  export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
55
161
  res.statusCode = response.status;
56
162
  response.headers.forEach((value, key) => {
@@ -73,6 +179,65 @@ export async function sendNodeResponse(res: ServerResponse, response: Response):
73
179
  res.setHeader("set-cookie", single);
74
180
  }
75
181
  }
76
- const body = Buffer.from(await response.arrayBuffer());
77
- res.end(body);
182
+
183
+ for (const [name, value] of Object.entries(DEFAULT_SECURITY_HEADERS)) {
184
+ if (!res.hasHeader(name)) {
185
+ res.setHeader(name, value);
186
+ }
187
+ }
188
+
189
+ if (response.body === null || res.req?.method === "HEAD") {
190
+ res.end();
191
+ return;
192
+ }
193
+
194
+ // Addendum J.2: stream langsung — tidak ada `arrayBuffer()` penuh di memori.
195
+ await pipeline(
196
+ Readable.fromWeb(response.body as unknown as import("node:stream/web").ReadableStream),
197
+ res,
198
+ );
199
+ }
200
+
201
+ function firstHeaderValue(value: string | string[] | undefined): string | null {
202
+ if (value === undefined) {
203
+ return null;
204
+ }
205
+ const raw = Array.isArray(value) ? value[0] : value;
206
+ if (raw === undefined) {
207
+ return null;
208
+ }
209
+ const first = raw.split(",")[0]?.trim() ?? "";
210
+ return first.length === 0 ? null : first;
211
+ }
212
+
213
+ function forwardedProtocol(value: string | string[] | undefined): "http" | "https" | null {
214
+ const first = firstHeaderValue(value)?.toLowerCase() ?? null;
215
+ if (first === "https") {
216
+ return "https";
217
+ }
218
+ if (first === "http") {
219
+ return "http";
220
+ }
221
+ return null;
222
+ }
223
+
224
+ function forwardedHost(value: string | string[] | undefined): string | null {
225
+ const first = firstHeaderValue(value);
226
+ if (first === null || first.length === 0 || first.length > 255) {
227
+ return null;
228
+ }
229
+ // hostname[:port] — tolak karakter di luar himpunan aman (spasi, CRLF, /, @, ?).
230
+ if (!/^[A-Za-z0-9.\-[\]:]+$/.test(first)) {
231
+ return null;
232
+ }
233
+ return first;
234
+ }
235
+
236
+ function isPlausibleIp(value: string): boolean {
237
+ return value.length <= 64 && /^[0-9a-fA-F:.]+$/.test(value);
238
+ }
239
+
240
+ function stripZoneId(value: string): string {
241
+ const index = value.indexOf("%");
242
+ return index === -1 ? value : value.slice(0, index);
78
243
  }
package/src/transform.ts CHANGED
@@ -467,6 +467,9 @@ export function renderServerFnStub(
467
467
  const localNames = localTopLevelNames(ast);
468
468
  const importLines: string[] = [];
469
469
  const referenced: string[] = [];
470
+ // Addendum J.4 (D2): nama yang diimpor dari file `*.server.ts` lain — dipakai
471
+ // untuk mendeteksi referensi schema ke nilai server (dulu `MISSING_EXPORT`).
472
+ const serverImportedNames = new Map<string, { specifier: string; imported: string }>();
470
473
  let needsCreateStub = analysis.serverFns.length > 0;
471
474
 
472
475
  for (const statement of topLevelStatements(ast)) {
@@ -495,6 +498,9 @@ export function renderServerFnStub(
495
498
  const local = ((item["local"] as AstNode | undefined)?.["name"] as string) ?? "";
496
499
  if (local.length > 0 && isUsed(local, retainedText)) {
497
500
  kept.push(item);
501
+ const imported =
502
+ ((item["imported"] as AstNode | undefined)?.["name"] as string | undefined) ?? local;
503
+ serverImportedNames.set(local, { specifier, imported });
498
504
  }
499
505
  }
500
506
  } else {
@@ -532,7 +538,37 @@ export function renderServerFnStub(
532
538
  ["output", output],
533
539
  ] as const) {
534
540
  for (const reference of referencedIdentifiers(slice, filename)) {
535
- if (reference.length === 0 || !localNames.has(reference)) {
541
+ if (reference.length === 0) {
542
+ continue;
543
+ }
544
+ const serverImport = serverImportedNames.get(reference);
545
+ if (serverImport !== undefined) {
546
+ const target = dependencies.analyzeSpecifier(filename, serverImport.specifier);
547
+ const isServerFn =
548
+ target !== null && target.serverFns.some((fn) => fn.name === serverImport.imported);
549
+ if (!isServerFn) {
550
+ throw new MoonsError({
551
+ code: "E_SCHEMA_SERVER_REF",
552
+ file: filename,
553
+ line: fn.line,
554
+ annotation: fn.name,
555
+ message: `schema ${kind} serverFn \`${fn.name}\` merujuk nilai server \`${reference}\` dari \`${serverImport.specifier}\``,
556
+ details:
557
+ `\`${reference}\` diimpor dari file server lain dan bukan serverFn — stub client tidak mengekspornya (dulu gagal dengan error mentah bundler \`MISSING_EXPORT\`).\n\n` +
558
+ "Pilih salah satu:\n" +
559
+ " // a) pindahkan konstanta/schema ke modul client-safe\n" +
560
+ ` import { ${reference} } from "../shared/schemas";\n` +
561
+ " // b) inline nilai yang memang publik di schema, atau\n" +
562
+ " // c) validasi tambahan di handler (server) tanpa mereferensikan nilai server di schema",
563
+ suggestions: [
564
+ "Pindahkan deklarasi yang dirujuk ke modul bersama yang client-safe (mis. `src/shared/schemas.ts`)",
565
+ "Atau lakukan validasi di handler server, bukan di ekspresi schema",
566
+ ],
567
+ });
568
+ }
569
+ continue;
570
+ }
571
+ if (!localNames.has(reference)) {
536
572
  continue;
537
573
  }
538
574
  throw new MoonsError({
@@ -653,6 +689,10 @@ export interface ClientImportCheckContext {
653
689
  analyze: (absoluteFile: string) => ServerFileAnalysis | null;
654
690
  /** Direktori `src/api` (absolut) — modul API server-only (Addendum D.1). */
655
691
  apiDir?: string;
692
+ /** File config (absolut) — server-only (Addendum J.4). */
693
+ configFile?: string;
694
+ /** Direktori `src/server` (absolut) — server-only (Addendum J.4). */
695
+ serverDir?: string;
656
696
  /** Paket server-only tambahan dari `security.serverOnlyPackages` (Addendum C). */
657
697
  serverOnlyPackages?: ReadonlyArray<string>;
658
698
  /** Cache package.json lintas modul (performa; dikelola plugin). */
@@ -739,6 +779,54 @@ export async function checkClientModuleImports(
739
779
  }
740
780
  }
741
781
 
782
+ // Addendum J.4: config + direktori `src/server/**` server-only.
783
+ let configFileReal: string | null = null;
784
+ if (context.configFile !== undefined) {
785
+ try {
786
+ configFileReal = realpathSync(context.configFile);
787
+ } catch {
788
+ configFileReal = context.configFile;
789
+ }
790
+ }
791
+ let serverDirReal: string | null = null;
792
+ if (context.serverDir !== undefined) {
793
+ try {
794
+ serverDirReal = realpathSync(context.serverDir);
795
+ } catch {
796
+ serverDirReal = context.serverDir;
797
+ }
798
+ }
799
+
800
+ const rejectConfigImport = (line: number, specifier: string): never => {
801
+ throw new MoonsError({
802
+ code: "E_FORBIDDEN_SERVER_IMPORT",
803
+ file: relativeFile,
804
+ line,
805
+ message: `mengimpor \`${specifier}\` — \`moons.config.ts\` adalah modul server-only`,
806
+ details:
807
+ "Config memuat `context`/`middleware` (kode server) dan tidak pernah masuk bundle client (Addendum J.4).",
808
+ suggestions: [
809
+ "Pindahkan nilai yang dibutuhkan ke modul client-safe (mis. `src/shared/…`)",
810
+ "Ambil nilai dari server lewat loader/serverFn",
811
+ ],
812
+ });
813
+ };
814
+
815
+ const rejectServerDirImport = (line: number, specifier: string, resolvedTarget: string): never => {
816
+ throw new MoonsError({
817
+ code: "E_FORBIDDEN_SERVER_IMPORT",
818
+ file: relativeFile,
819
+ line,
820
+ message: `mengimpor \`${specifier}\` — \`${toRelative(projectRoot, resolvedTarget)}\` ada di \`src/server/**\` (server-only)`,
821
+ details:
822
+ "Direktori `src/server/**` hanya untuk kode server (Addendum J.4); suffix `.server.ts` bukan satu-satunya jaminan.",
823
+ suggestions: [
824
+ "Pindahkan kode yang client-safe ke `src/shared/`",
825
+ "Akses nilainya dari server lewat loader/serverFn",
826
+ ],
827
+ });
828
+ };
829
+
742
830
  const rejectApiImport = (line: number, specifier: string, resolvedTarget: string): never => {
743
831
  const relApi = toRelative(projectRoot, resolvedTarget);
744
832
  throw new MoonsError({
@@ -852,6 +940,24 @@ export async function checkClientModuleImports(
852
940
  rejectApiImport(line, rawSpecifier, resolvedTarget);
853
941
  }
854
942
 
943
+ // Addendum J.4: `moons.config.ts` + direktori `src/server/**` server-only
944
+ // (impor nilai; type-only dikecualikan — J.5).
945
+ if (!typeOnly && resolvedTarget !== null) {
946
+ if (configFileReal !== null && resolvedTarget === configFileReal) {
947
+ rejectConfigImport(line, rawSpecifier);
948
+ }
949
+ if (
950
+ serverDirReal !== null &&
951
+ isInsideDir(resolvedTarget, serverDirReal) &&
952
+ !isServerFilePath(resolvedTarget) &&
953
+ // File non-kontrak (`*.server.mts` dll.) punya error khusus yang
954
+ // lebih tepat — biarkan jatuh ke pemeriksaan di bawah.
955
+ !isNonContractServerFile(resolvedTarget)
956
+ ) {
957
+ rejectServerDirImport(line, rawSpecifier, resolvedTarget);
958
+ }
959
+ }
960
+
855
961
  // Addendum C: paket server-only (nama paket hasil resolve — subpath,
856
962
  // alias, tsconfig paths, symlink/workspace ikut tertangkap).
857
963
  if (!typeOnly) {
@@ -906,9 +1012,13 @@ export async function checkClientModuleImports(
906
1012
  reject(line, `mengimpor namespace (\`* as\`) dari ${targetRelative}`, "Import namespace dari file server tidak boleh menyeberang ke client.");
907
1013
  }
908
1014
  const name = ((item["local"] as AstNode | undefined)?.["name"] as string) ?? "";
909
- const kind = (item["importKind"] as string | undefined) ?? (statement["importKind"] as string | undefined);
910
- if (kind === "type") {
911
- reject(line, `mengimpor type \`${name}\` dari ${targetRelative}`, "Import dari file server tidak diizinkan di modul client.");
1015
+ // Type-only (Addendum J.5): Babel menaruh `importKind: "type"` di
1016
+ // statement untuk `import type { X }`, dan di specifier untuk
1017
+ // `import { type X }` — keduanya harus dibaca (D1).
1018
+ const specifierKind = item["importKind"] as string | undefined;
1019
+ const statementKind = statement["importKind"] as string | undefined;
1020
+ if (specifierKind === "type" || statementKind === "type") {
1021
+ continue;
912
1022
  }
913
1023
  if (name.length > 0 && !(await isAllowedServerFnImport(resolvedTarget, specifier, name))) {
914
1024
  reject(