@moonsjs/vite 0.1.3 → 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-1sm9pvvd.js → index-1fnt137k.js} +106 -18
- 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 +220 -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 +4 -0
- package/dist/server/dev.d.ts.map +1 -1
- package/dist/server/handler.d.ts +21 -1
- package/dist/server/handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +6 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +584 -28
- package/dist/server/index.js.map +10 -7
- package/dist/server/node-server.d.ts +4 -1
- package/dist/server/node-server.d.ts.map +1 -1
- package/dist/server/node.d.ts.map +1 -1
- package/dist/server/plugins.d.ts +26 -0
- package/dist/server/plugins.d.ts.map +1 -0
- package/dist/server/prerender.d.ts +28 -0
- package/dist/server/prerender.d.ts.map +1 -0
- package/dist/server/shell.d.ts +3 -3
- package/dist/server/shell.d.ts.map +1 -1
- package/dist/server/shutdown.d.ts +6 -0
- package/dist/server/shutdown.d.ts.map +1 -1
- package/dist/server/test-server.d.ts.map +1 -1
- 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 +48 -12
- package/src/server/handler.ts +373 -14
- package/src/server/index.ts +6 -0
- package/src/server/node-server.ts +70 -16
- package/src/server/node.ts +17 -0
- package/src/server/plugins.ts +85 -0
- package/src/server/prerender.ts +230 -0
- package/src/server/shell.ts +62 -8
- package/src/server/shutdown.ts +19 -2
- package/src/server/test-server.ts +2 -1
- package/src/server-only.ts +220 -0
- package/src/transform.ts +138 -8
- package/src/virtual-modules.ts +1 -0
- package/dist/index-1sm9pvvd.js.map +0 -14
- package/dist/index-3ec9vpek.js.map +0 -10
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MOONS_VERSION, type App, type Middleware, type MoonsConfig, type RouteDefinition } from "@moonsjs/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Eksekusi lifecycle plugin (PRD §14.4 / Addendum B.4).
|
|
5
|
+
* Urutan: registrasi (addRoute per route → addMiddleware per middleware →
|
|
6
|
+
* extendContext(register)) → onStart → ready/onReady → shutdown/onShutdown.
|
|
7
|
+
*/
|
|
8
|
+
export interface PluginRuntime {
|
|
9
|
+
readonly app: App;
|
|
10
|
+
/** Registrasi + `onStart` (sebelum server siap). */
|
|
11
|
+
start(): Promise<void>;
|
|
12
|
+
/** Tandai `app.ready = true` lalu panggil `onReady` (server siap). */
|
|
13
|
+
ready(): Promise<void>;
|
|
14
|
+
/** Panggil `onShutdown` (selama drain; error dilaporkan, tidak menggagalkan). */
|
|
15
|
+
shutdown(): Promise<void>;
|
|
16
|
+
/** Terapkan extender `extendContext` ke ctx request (dipanggil handler). */
|
|
17
|
+
applyExtensions(ctx: unknown): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PluginRuntimeOptions {
|
|
21
|
+
config: MoonsConfig;
|
|
22
|
+
routes: RouteDefinition[];
|
|
23
|
+
/** Rantai middleware final (config.middleware). */
|
|
24
|
+
middlewares: Middleware[];
|
|
25
|
+
log?: (message: string) => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createPluginRuntime(options: PluginRuntimeOptions): PluginRuntime {
|
|
29
|
+
const log = options.log ?? ((message: string) => console.warn(message));
|
|
30
|
+
const plugins = options.config.plugins ?? [];
|
|
31
|
+
const state = { ready: false, version: MOONS_VERSION };
|
|
32
|
+
const app = state as App;
|
|
33
|
+
const extensions: Array<(ctx: unknown) => unknown> = [];
|
|
34
|
+
const register = (fn: (ctx: unknown) => unknown): void => {
|
|
35
|
+
extensions.push(fn);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Fase registrasi (sinkron): addRoute → addMiddleware → extendContext.
|
|
39
|
+
for (const plugin of plugins) {
|
|
40
|
+
for (const route of options.routes) {
|
|
41
|
+
// Objek beku: hook bersifat observasi di v1 (lihat docs/decisions/0022).
|
|
42
|
+
const frozen = Object.freeze({
|
|
43
|
+
...route,
|
|
44
|
+
params: [...route.params],
|
|
45
|
+
}) as RouteDefinition;
|
|
46
|
+
plugin.addRoute?.(frozen);
|
|
47
|
+
}
|
|
48
|
+
for (const middleware of options.middlewares) {
|
|
49
|
+
plugin.addMiddleware?.(middleware);
|
|
50
|
+
}
|
|
51
|
+
plugin.extendContext?.(register as never);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
app,
|
|
56
|
+
async start(): Promise<void> {
|
|
57
|
+
for (const plugin of plugins) {
|
|
58
|
+
await plugin.onStart?.(app);
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
async ready(): Promise<void> {
|
|
62
|
+
state.ready = true;
|
|
63
|
+
for (const plugin of plugins) {
|
|
64
|
+
await plugin.onReady?.(app);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
async shutdown(): Promise<void> {
|
|
68
|
+
for (const plugin of plugins) {
|
|
69
|
+
try {
|
|
70
|
+
await plugin.onShutdown?.(app);
|
|
71
|
+
} catch (error) {
|
|
72
|
+
log(`[moons] plugin \`${plugin.name}\` gagal saat onShutdown: ${String(error)}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
applyExtensions(ctx: unknown): void {
|
|
77
|
+
for (const extend of extensions) {
|
|
78
|
+
const result = extend(ctx);
|
|
79
|
+
if (result !== null && typeof result === "object" && !Array.isArray(result)) {
|
|
80
|
+
Object.assign(ctx as Record<string, unknown>, result);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -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
|
+
}
|
package/src/server/shell.ts
CHANGED
|
@@ -3,7 +3,12 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import type { ViteDevServer } from "vite";
|
|
5
5
|
|
|
6
|
-
export type Shell = (
|
|
6
|
+
export type Shell = (
|
|
7
|
+
markup: string,
|
|
8
|
+
payload: string,
|
|
9
|
+
url: string,
|
|
10
|
+
meta?: Record<string, unknown>,
|
|
11
|
+
) => string | Promise<string>;
|
|
7
12
|
|
|
8
13
|
export const ROOT_PLACEHOLDER = '<div id="root"></div>';
|
|
9
14
|
|
|
@@ -23,8 +28,13 @@ export const DEFAULT_TEMPLATE = [
|
|
|
23
28
|
"",
|
|
24
29
|
].join("\n");
|
|
25
30
|
|
|
26
|
-
/** Inject markup SSR + payload devalue ke shell HTML (PRD §8.2, §17). */
|
|
27
|
-
export function injectShell(
|
|
31
|
+
/** Inject markup SSR + payload devalue ke shell HTML (PRD §8.2, §17, Addendum B.1). */
|
|
32
|
+
export function injectShell(
|
|
33
|
+
template: string,
|
|
34
|
+
markup: string,
|
|
35
|
+
payload: string,
|
|
36
|
+
meta?: Record<string, unknown>,
|
|
37
|
+
): string {
|
|
28
38
|
if (!template.includes(ROOT_PLACEHOLDER)) {
|
|
29
39
|
throw new MoonsError({
|
|
30
40
|
code: "E_INTERNAL",
|
|
@@ -37,7 +47,51 @@ export function injectShell(template: string, markup: string, payload: string):
|
|
|
37
47
|
});
|
|
38
48
|
}
|
|
39
49
|
const dataScript = `<script id="__moons_data" type="application/x-moons">${payload}</script>`;
|
|
40
|
-
|
|
50
|
+
const withRoot = template.replace(ROOT_PLACEHOLDER, `<div id="root">${markup}</div>${dataScript}`);
|
|
51
|
+
return applyMeta(withRoot, meta);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function escapeHtml(value: string): string {
|
|
55
|
+
return value
|
|
56
|
+
.replaceAll("&", "&")
|
|
57
|
+
.replaceAll("<", "<")
|
|
58
|
+
.replaceAll(">", ">")
|
|
59
|
+
.replaceAll('"', """);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Terapkan meta route ke `<head>` (Addendum B.1): `<title>` + description/og.
|
|
64
|
+
* Tag yang sudah ada (mis. di index.html) ditimpa; yang belum ada disisipkan
|
|
65
|
+
* sebelum `</head>`.
|
|
66
|
+
*/
|
|
67
|
+
function applyMeta(html: string, meta: Record<string, unknown> | undefined): string {
|
|
68
|
+
if (meta === undefined) {
|
|
69
|
+
return html;
|
|
70
|
+
}
|
|
71
|
+
let out = html;
|
|
72
|
+
if (typeof meta.title === "string") {
|
|
73
|
+
const title = `<title>${escapeHtml(meta.title)}</title>`;
|
|
74
|
+
out = /<title>[\s\S]*?<\/title>/.test(out)
|
|
75
|
+
? out.replace(/<title>[\s\S]*?<\/title>/, title)
|
|
76
|
+
: out.replace("</head>", ` ${title}\n</head>`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tags: Array<{ attr: "name" | "property"; key: string; value: unknown }> = [
|
|
80
|
+
{ attr: "name", key: "description", value: meta.description },
|
|
81
|
+
{ attr: "property", key: "og:title", value: meta.title },
|
|
82
|
+
{ attr: "property", key: "og:description", value: meta.description },
|
|
83
|
+
];
|
|
84
|
+
for (const tag of tags) {
|
|
85
|
+
if (typeof tag.value !== "string") {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const rendered = `<meta ${tag.attr}="${tag.key}" content="${escapeHtml(tag.value)}" />`;
|
|
89
|
+
const pattern = new RegExp(`<meta[^>]*${tag.attr}="${tag.key}"[^>]*>`);
|
|
90
|
+
out = pattern.test(out)
|
|
91
|
+
? out.replace(pattern, rendered)
|
|
92
|
+
: out.replace("</head>", ` ${rendered}\n</head>`);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
41
95
|
}
|
|
42
96
|
|
|
43
97
|
/**
|
|
@@ -45,11 +99,11 @@ export function injectShell(template: string, markup: string, payload: string):
|
|
|
45
99
|
* lalu lewatkan ke Vite `transformIndexHtml` (inject `/@vite/client`, dsb).
|
|
46
100
|
*/
|
|
47
101
|
export function createDevShell(server: ViteDevServer, options: { templatePath: string }): Shell {
|
|
48
|
-
return async (markup, payload, url) => {
|
|
102
|
+
return async (markup, payload, url, meta) => {
|
|
49
103
|
const template = existsSync(options.templatePath)
|
|
50
104
|
? readFileSync(options.templatePath, "utf8")
|
|
51
105
|
: DEFAULT_TEMPLATE;
|
|
52
|
-
const html = injectShell(template, markup, payload);
|
|
106
|
+
const html = injectShell(template, markup, payload, meta);
|
|
53
107
|
return await server.transformIndexHtml(url, html);
|
|
54
108
|
};
|
|
55
109
|
}
|
|
@@ -57,7 +111,7 @@ export function createDevShell(server: ViteDevServer, options: { templatePath: s
|
|
|
57
111
|
/** Shell untuk produksi: baca `dist/client/index.html` sekali dari cwd. */
|
|
58
112
|
export function createFileShell(options: { clientDir?: string } = {}): Shell {
|
|
59
113
|
let cached: string | null = null;
|
|
60
|
-
return async (markup, payload) => {
|
|
114
|
+
return async (markup, payload, _url, meta) => {
|
|
61
115
|
if (cached === null) {
|
|
62
116
|
const clientDir = resolve(options.clientDir ?? process.cwd(), "dist/client");
|
|
63
117
|
const file = join(clientDir, "index.html");
|
|
@@ -72,6 +126,6 @@ export function createFileShell(options: { clientDir?: string } = {}): Shell {
|
|
|
72
126
|
}
|
|
73
127
|
cached = readFileSync(file, "utf8");
|
|
74
128
|
}
|
|
75
|
-
return injectShell(cached, markup, payload);
|
|
129
|
+
return injectShell(cached, markup, payload, meta);
|
|
76
130
|
};
|
|
77
131
|
}
|
package/src/server/shutdown.ts
CHANGED
|
@@ -6,6 +6,12 @@ export interface ShutdownOptions {
|
|
|
6
6
|
log?: (message: string) => void;
|
|
7
7
|
/** Injectable untuk test; default `process.exit`. */
|
|
8
8
|
exit?: (code: number) => void;
|
|
9
|
+
/**
|
|
10
|
+
* Dipanggil setelah drain penuh, sebelum exit (Addendum B.4: onShutdown
|
|
11
|
+
* plugin). Berbagi batas waktu dengan drain; error dilaporkan, tidak
|
|
12
|
+
* menggagalkan shutdown.
|
|
13
|
+
*/
|
|
14
|
+
onShutdown?: () => void | Promise<void>;
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -50,13 +56,24 @@ export function installGracefulShutdown(
|
|
|
50
56
|
// abaikan: fallback timeout tetap ada
|
|
51
57
|
}
|
|
52
58
|
|
|
53
|
-
server.close((error?: Error) => {
|
|
54
|
-
clearTimeout(timer);
|
|
59
|
+
server.close(async (error?: Error) => {
|
|
55
60
|
if (error !== undefined) {
|
|
61
|
+
clearTimeout(timer);
|
|
56
62
|
log(`[moons] penutupan server gagal: ${error.message}`);
|
|
57
63
|
exit(1);
|
|
58
64
|
return;
|
|
59
65
|
}
|
|
66
|
+
if (options.onShutdown !== undefined) {
|
|
67
|
+
// Tetap berbagi batas waktu dengan drain: timer di-ref kembali agar
|
|
68
|
+
// proses tidak keluar sebelum hook selesai.
|
|
69
|
+
(timer as { ref?: () => void }).ref?.();
|
|
70
|
+
try {
|
|
71
|
+
await options.onShutdown();
|
|
72
|
+
} catch (hookError) {
|
|
73
|
+
log(`[moons] onShutdown gagal: ${String(hookError)}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
clearTimeout(timer);
|
|
60
77
|
log("[moons] shutdown selesai (drain penuh)");
|
|
61
78
|
exit(0);
|
|
62
79
|
});
|
|
@@ -26,7 +26,8 @@ export function createTestServer(
|
|
|
26
26
|
baseUrl = "http://localhost",
|
|
27
27
|
): TestServer {
|
|
28
28
|
const shell: Shell =
|
|
29
|
-
options.shell ??
|
|
29
|
+
options.shell ??
|
|
30
|
+
((markup, payload, _url, meta) => injectShell(DEFAULT_TEMPLATE, markup, payload, meta));
|
|
30
31
|
|
|
31
32
|
const handler = createRequestHandler({
|
|
32
33
|
routes: options.routes,
|
|
@@ -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
|
+
}
|