@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.
- package/dist/{index-tysyk2qf.js → index-qr2w9b24.js} +88 -5
- package/dist/index-qr2w9b24.js.map +11 -0
- package/dist/{index-h66m8653.js → index-vafk4b9b.js} +157 -20
- package/dist/index-vafk4b9b.js.map +15 -0
- package/dist/index.js +13 -5
- package/dist/index.js.map +3 -3
- package/dist/plugin.d.ts.map +1 -1
- package/dist/server/dev.d.ts +3 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/index.js +83 -20
- package/dist/server/index.js.map +3 -3
- package/dist/server/node-server.d.ts +5 -1
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts +36 -3
- package/dist/server/node.d.ts.map +1 -1
- package/dist/transform.d.ts +4 -0
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +1 -1
- package/package.json +1 -1
- package/src/plugin.ts +10 -1
- package/src/server/dev.ts +33 -3
- package/src/server/node-server.ts +105 -14
- package/src/server/node.ts +182 -17
- package/src/transform.ts +114 -4
- package/dist/index-h66m8653.js.map +0 -15
- package/dist/index-tysyk2qf.js.map +0 -11
|
@@ -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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/server/node.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
6
|
-
export
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 >
|
|
42
|
-
|
|
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", () =>
|
|
49
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
910
|
-
|
|
911
|
-
|
|
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(
|