@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.
- 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-e7qz0epf.js} +232 -24
- package/dist/index-e7qz0epf.js.map +11 -0
- package/dist/index.js +213 -16
- 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 +453 -17
- package/dist/server/index.js.map +8 -6
- package/dist/server/node-server.d.ts +1 -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 +130 -7
- package/src/server/dev.ts +14 -5
- package/src/server/handler.ts +317 -13
- package/src/server/index.ts +4 -0
- package/src/server/node-server.ts +47 -7
- package/src/server/node.ts +17 -0
- package/src/server/prerender.ts +230 -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
|
@@ -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
|
-
|
|
520
|
-
"
|
|
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
|
-
|
|
523
|
-
|
|
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
|
|
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);
|
package/src/virtual-modules.ts
CHANGED
|
@@ -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",
|