@moonsjs/vite 0.1.4 → 0.1.6
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/api.d.ts +8 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/{index-33p1r65s.js → index-1fnt137k.js} +50 -5
- package/dist/index-1fnt137k.js.map +14 -0
- package/dist/{index-3ec9vpek.js → index-zs92aaz4.js} +232 -24
- package/dist/index-zs92aaz4.js.map +11 -0
- package/dist/index.js +216 -19
- package/dist/index.js.map +5 -4
- package/dist/manifest.d.ts +6 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/payload-scan.d.ts +18 -0
- package/dist/payload-scan.d.ts.map +1 -0
- 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/handler.d.ts +16 -1
- package/dist/server/handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +561 -17
- package/dist/server/index.js.map +9 -6
- package/dist/server/isr.d.ts +25 -0
- package/dist/server/isr.d.ts.map +1 -0
- package/dist/server/node-server.d.ts +7 -1
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts.map +1 -1
- package/dist/server/prerender.d.ts +28 -0
- package/dist/server/prerender.d.ts.map +1 -0
- package/dist/server-only.d.ts +57 -0
- package/dist/server-only.d.ts.map +1 -0
- package/dist/transform.d.ts +9 -1
- package/dist/transform.d.ts.map +1 -1
- package/dist/transform.js +1 -1
- package/dist/virtual-modules.d.ts +1 -0
- package/dist/virtual-modules.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/api.ts +124 -0
- package/src/manifest.ts +42 -1
- package/src/payload-scan.ts +53 -0
- package/src/plugin.ts +137 -10
- package/src/server/dev.ts +14 -5
- package/src/server/handler.ts +317 -13
- package/src/server/index.ts +4 -0
- package/src/server/isr.ts +81 -0
- package/src/server/node-server.ts +120 -7
- package/src/server/node.ts +17 -0
- package/src/server/prerender.ts +256 -0
- package/src/server-only.ts +220 -0
- package/src/transform.ts +138 -8
- package/src/virtual-modules.ts +1 -0
- package/dist/index-33p1r65s.js.map +0 -14
- package/dist/index-3ec9vpek.js.map +0 -10
package/src/server/node.ts
CHANGED
|
@@ -54,8 +54,25 @@ function readBody(req: IncomingMessage): Promise<Buffer> {
|
|
|
54
54
|
export async function sendNodeResponse(res: ServerResponse, response: Response): Promise<void> {
|
|
55
55
|
res.statusCode = response.status;
|
|
56
56
|
response.headers.forEach((value, key) => {
|
|
57
|
+
// `set-cookie` ditangani terpisah: forEach menggabungkan nilai ganda
|
|
58
|
+
// menjadi satu string (cookie kedua hilang — D.2).
|
|
59
|
+
if (key === "set-cookie") {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
57
62
|
res.setHeader(key, value);
|
|
58
63
|
});
|
|
64
|
+
const getSetCookie = (response.headers as { getSetCookie?: () => string[] }).getSetCookie;
|
|
65
|
+
if (typeof getSetCookie === "function") {
|
|
66
|
+
const cookies = getSetCookie.call(response.headers);
|
|
67
|
+
if (cookies.length > 0) {
|
|
68
|
+
res.setHeader("set-cookie", cookies);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
const single = response.headers.get("set-cookie");
|
|
72
|
+
if (single !== null) {
|
|
73
|
+
res.setHeader("set-cookie", single);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
59
76
|
const body = Buffer.from(await response.arrayBuffer());
|
|
60
77
|
res.end(body);
|
|
61
78
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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 entries: Array<{ path: string; revalidate?: number }> = [];
|
|
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
|
+
const revalidate = module.revalidate;
|
|
51
|
+
|
|
52
|
+
// Addendum E.1: `revalidate` hanya sah bersama `prerender`.
|
|
53
|
+
if (revalidate !== undefined) {
|
|
54
|
+
if (
|
|
55
|
+
typeof revalidate !== "number" ||
|
|
56
|
+
!Number.isFinite(revalidate) ||
|
|
57
|
+
revalidate < 0 ||
|
|
58
|
+
prerender !== true
|
|
59
|
+
) {
|
|
60
|
+
throw new MoonsError({
|
|
61
|
+
code: "E_ISR_INVALID",
|
|
62
|
+
file: route.file,
|
|
63
|
+
message: "`revalidate` tidak valid atau dipakai tanpa `prerender = true`",
|
|
64
|
+
details: "ISR berbasis file: `revalidate` (detik, number >= 0) + `prerender` (PRD Addendum E.1).",
|
|
65
|
+
suggestions: [
|
|
66
|
+
"Gunakan angka detik, mis. `export const revalidate = 60`",
|
|
67
|
+
"Tambahkan `export const prerender = true` pada page yang sama",
|
|
68
|
+
],
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (prerender !== true) {
|
|
74
|
+
if (prerender !== undefined && prerender !== false) {
|
|
75
|
+
throw prerenderError(route, "`prerender` harus boolean (`true`/`false`)");
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const pathnames = await resolvePathnames(route, module);
|
|
81
|
+
for (const pathname of pathnames) {
|
|
82
|
+
const response = await options.handler(
|
|
83
|
+
new Request(`http://localhost${pathname}`, {
|
|
84
|
+
method: "GET",
|
|
85
|
+
headers: { "x-moons-prerender": "1" },
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (response.status === 404) {
|
|
90
|
+
log(`[moons] prerender: lewati ${pathname} (notFound)`);
|
|
91
|
+
skipped += 1;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (response.status >= 300 && response.status < 400) {
|
|
95
|
+
throw prerenderError(route, `\`redirect()\` tidak didukung saat prerender (${pathname})`);
|
|
96
|
+
}
|
|
97
|
+
if (response.status !== 200) {
|
|
98
|
+
throw prerenderError(route, `status ${response.status} saat merender ${pathname}`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const html = await response.text();
|
|
102
|
+
const findings = scanPayloadForSecrets(html, {
|
|
103
|
+
...(options.canary === undefined ? {} : { canary: options.canary }),
|
|
104
|
+
env,
|
|
105
|
+
});
|
|
106
|
+
if (findings.length > 0) {
|
|
107
|
+
const labels = findings
|
|
108
|
+
.map((finding) => (finding.kind === "canary" ? "canary" : `env(${finding.label})`))
|
|
109
|
+
.join(", ");
|
|
110
|
+
throw new MoonsError({
|
|
111
|
+
code: "E_PRERENDER_LEAK",
|
|
112
|
+
file: route.file,
|
|
113
|
+
message: `hasil prerender \`${pathname}\` memuat ${labels}`,
|
|
114
|
+
details:
|
|
115
|
+
"HTML statis permanen — rahasia yang ter-prerender tidak bisa ditarik kembali (PRD Addendum D.5).",
|
|
116
|
+
suggestions: [
|
|
117
|
+
"Jangan mengembalikan nilai env rahasia dari loader yang di-prerender",
|
|
118
|
+
"Matikan `prerender` untuk route ini atau pindahkan datanya ke serverFn request-time",
|
|
119
|
+
],
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const file = outputFile(options.clientDir, pathname);
|
|
124
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
125
|
+
writeFileSync(file, html);
|
|
126
|
+
entries.push(
|
|
127
|
+
typeof revalidate === "number" && revalidate >= 0
|
|
128
|
+
? { path: pathname, revalidate }
|
|
129
|
+
: { path: pathname },
|
|
130
|
+
);
|
|
131
|
+
written += 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Manifest v2 (Addendum E.1): entries + revalidate; reader tetap menerima v1.
|
|
136
|
+
writeFileSync(
|
|
137
|
+
join(options.clientDir, PRERENDER_MANIFEST_FILE),
|
|
138
|
+
`${JSON.stringify({ version: 2, entries }, null, 2)}\n`,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
log(`[moons] prerender selesai: ${written} file (${skipped} dilewati)`);
|
|
142
|
+
return { written, skipped };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function routeKeyOf(routesDir: string, file: string): string {
|
|
146
|
+
const relative = file.startsWith(routesDir) ? file.slice(routesDir.length + 1) : file;
|
|
147
|
+
return relative.split("\\").join("/").replace(/\.(tsx|ts)$/, "");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function resolvePathnames(route: RouteDefinition, module: RouteModule): Promise<string[]> {
|
|
151
|
+
const isDynamic = route.params.length > 0;
|
|
152
|
+
if (!isDynamic) {
|
|
153
|
+
return [route.path];
|
|
154
|
+
}
|
|
155
|
+
const paths = module.paths;
|
|
156
|
+
if (typeof paths !== "function") {
|
|
157
|
+
throw new MoonsError({
|
|
158
|
+
code: "E_PRERENDER_NO_PATHS",
|
|
159
|
+
file: route.file,
|
|
160
|
+
message: "route dynamic ber-`prerender = true` tanpa `export const paths`",
|
|
161
|
+
details: "Prerender butuh daftar param untuk merender tiap path (PRD Addendum D.5).",
|
|
162
|
+
suggestions: [
|
|
163
|
+
'Tambahkan `export const paths = () => [{ id: "…" }]`',
|
|
164
|
+
"Atau hapus `prerender` agar dirender SSR saat request",
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const raw = await (paths as () => unknown | Promise<unknown>)();
|
|
170
|
+
if (!Array.isArray(raw)) {
|
|
171
|
+
throw prerenderError(route, "`paths()` harus mengembalikan array");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const expected = new Set(route.params);
|
|
175
|
+
const result: string[] = [];
|
|
176
|
+
for (const item of raw as unknown[]) {
|
|
177
|
+
if (item === null || typeof item !== "object" || Array.isArray(item)) {
|
|
178
|
+
throw prerenderError(route, "item `paths()` harus objek param string");
|
|
179
|
+
}
|
|
180
|
+
const record = item as Record<string, unknown>;
|
|
181
|
+
const keys = Object.keys(record);
|
|
182
|
+
for (const name of expected) {
|
|
183
|
+
const value = record[name];
|
|
184
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
185
|
+
throw prerenderError(route, `param \`${name}\` wajib string non-kosong`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
for (const key of keys) {
|
|
189
|
+
if (!expected.has(key)) {
|
|
190
|
+
throw prerenderError(route, `param \`${key}\` tidak ada di pola route`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
result.push(buildPathname(route, record as Record<string, string>));
|
|
194
|
+
}
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function buildPathname(route: RouteDefinition, params: Record<string, string>): string {
|
|
199
|
+
const segments = route.path.split("/").filter((segment) => segment.length > 0);
|
|
200
|
+
const out: string[] = [];
|
|
201
|
+
let catchallName: string | null = null;
|
|
202
|
+
for (const segment of segments) {
|
|
203
|
+
if (segment === "*") {
|
|
204
|
+
catchallName = route.params[route.params.length - 1] ?? null;
|
|
205
|
+
const value = catchallName === null ? "" : (params[catchallName] ?? "");
|
|
206
|
+
for (const part of value.split("/")) {
|
|
207
|
+
if (part.length === 0) {
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
out.push(encodeSegment(route, part));
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
if (segment.startsWith(":")) {
|
|
215
|
+
out.push(encodeSegment(route, params[segment.slice(1)] ?? ""));
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
out.push(segment);
|
|
219
|
+
}
|
|
220
|
+
return `/${out.join("/")}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function encodeSegment(route: RouteDefinition, value: string): string {
|
|
224
|
+
if (value.length === 0 || /^\.+$/.test(value)) {
|
|
225
|
+
throw prerenderError(route, `nilai param \`${value}\` tidak valid untuk path file`);
|
|
226
|
+
}
|
|
227
|
+
return encodeURIComponent(value);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function outputFile(clientDir: string, pathname: string): string {
|
|
231
|
+
const parts = pathname.split("/").filter((part) => part.length > 0);
|
|
232
|
+
const file = resolve(join(clientDir, ...parts, "index.html"));
|
|
233
|
+
const base = resolve(clientDir);
|
|
234
|
+
if (file !== base && !file.startsWith(base + sep)) {
|
|
235
|
+
throw new MoonsError({
|
|
236
|
+
code: "E_PRERENDER_INVALID",
|
|
237
|
+
message: `path prerender keluar dari direktori client (\`${pathname}\`)`,
|
|
238
|
+
details: "Nama file harus berada di dalam `dist/client`.",
|
|
239
|
+
suggestions: ["Periksa nilai param `paths` — jangan memakai `..`"],
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return file;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function prerenderError(route: RouteDefinition, message: string): MoonsError {
|
|
246
|
+
return new MoonsError({
|
|
247
|
+
code: "E_PRERENDER_INVALID",
|
|
248
|
+
file: route.file,
|
|
249
|
+
message,
|
|
250
|
+
details: "Kontrak prerender mengikuti PRD Addendum D.5.",
|
|
251
|
+
suggestions: [
|
|
252
|
+
"Perbaiki bentuk `paths`/param agar cocok dengan pola route",
|
|
253
|
+
"Jangan memakai `redirect()` pada route yang di-prerender",
|
|
254
|
+
],
|
|
255
|
+
});
|
|
256
|
+
}
|
|
@@ -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
|
+
}
|