@mandujs/core 0.43.0 → 0.43.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.43.0",
3
+ "version": "0.43.1",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,300 @@
1
+ /**
2
+ * Manifest → runtime registry wiring.
3
+ *
4
+ * Lives in `@mandujs/core/runtime` (not in `@mandujs/cli`) because it
5
+ * runs at *server* boot, not at build time. Deploy adapters generate
6
+ * SSR entry files that need to call this — having it here means the
7
+ * generated entry imports a single, public package (`@mandujs/core`)
8
+ * instead of reaching into a CLI subpath that has no `exports` map.
9
+ *
10
+ * @module core/runtime/handlers
11
+ */
12
+ import fs from "node:fs/promises";
13
+ import path from "node:path";
14
+
15
+ import {
16
+ registerApiHandler,
17
+ registerPageLoader,
18
+ registerPageHandler,
19
+ registerLayoutLoader,
20
+ registerNotFoundHandler,
21
+ registerMetadataHandler,
22
+ registerWSHandler,
23
+ type PageRegistration,
24
+ } from "./server";
25
+ import { registerManifest } from "./registry";
26
+ import { needsHydration, type RoutesManifest } from "../spec/schema";
27
+
28
+ type RouteModule = Record<string, unknown>;
29
+
30
+ /**
31
+ * Structural shape of a `ManduFilling` instance as seen by the registrar.
32
+ * Mirrors the subset of the public API we depend on (handle/hasWS/getWSHandlers)
33
+ * without importing the concrete class — that would pull in runtime deps
34
+ * the CLI shouldn't need at import time.
35
+ */
36
+ interface FillingInstance {
37
+ handle: (req: Request, params?: Record<string, string>) => Response | Promise<Response>;
38
+ hasWS?: () => boolean;
39
+ getWSHandlers?: () => Parameters<typeof registerWSHandler>[1];
40
+ }
41
+
42
+ function isFillingInstance(value: unknown): value is FillingInstance {
43
+ return (
44
+ typeof value === "object" &&
45
+ value !== null &&
46
+ typeof (value as { handle?: unknown }).handle === "function"
47
+ );
48
+ }
49
+
50
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] as const;
51
+
52
+ type HttpMethod = (typeof HTTP_METHODS)[number];
53
+
54
+ function isHttpMethod(method: string): method is HttpMethod {
55
+ return (HTTP_METHODS as readonly string[]).includes(method);
56
+ }
57
+
58
+ function hasHttpMethodHandlers(module: RouteModule): boolean {
59
+ return HTTP_METHODS.some((method) => typeof module[method] === "function");
60
+ }
61
+
62
+ function createMethodDispatcher(module: RouteModule, routeId: string) {
63
+ return async (req: Request, params: Record<string, string> = {}) => {
64
+ const method = req.method.toUpperCase();
65
+ const handler = (isHttpMethod(method) ? module[method] : undefined) as
66
+ | ((request: Request, context?: { params: Record<string, string> }) => Response | Promise<Response>)
67
+ | undefined;
68
+
69
+ if (!handler) {
70
+ return Response.json(
71
+ {
72
+ error: `Method ${method} not allowed for route ${routeId}`,
73
+ },
74
+ {
75
+ status: 405,
76
+ headers: {
77
+ Allow: HTTP_METHODS.filter((m) => typeof module[m] === "function").join(", "),
78
+ },
79
+ }
80
+ );
81
+ }
82
+
83
+ return handler(req, { params });
84
+ };
85
+ }
86
+
87
+ export interface RegisterHandlersOptions {
88
+ /**
89
+ * Module import function (dev: importFresh, start: standard import).
90
+ * The optional `opts.changedFile` is forwarded into Phase 7.0 B5's
91
+ * incremental bundled-import: when the changed file is not in the
92
+ * module's import graph, `importFn` returns the cached bundle in ~0.1 ms
93
+ * instead of re-running Bun.build.
94
+ */
95
+ importFn: (modulePath: string, opts?: { changedFile?: string }) => Promise<unknown>;
96
+ /** Set for tracking already registered layout paths */
97
+ registeredLayouts: Set<string>;
98
+ /** Clear layout cache on reload */
99
+ isReload?: boolean;
100
+ /**
101
+ * Phase 7.0 B5 wire-up — on a live SSR reload, the changed file that
102
+ * triggered the reload. Omit for cold boot / wildcard (full
103
+ * invalidation). Forwarded to `importFn` so the incremental
104
+ * `bundledImport` can skip rebuilds for modules the file isn't part of.
105
+ */
106
+ changedFile?: string;
107
+ }
108
+
109
+ /**
110
+ * Register manifest routes as server handlers.
111
+ * Shared between dev/build/start commands and deploy-target SSR entries.
112
+ */
113
+ export async function registerManifestHandlers(
114
+ manifest: RoutesManifest,
115
+ rootDir: string,
116
+ options: RegisterHandlersOptions
117
+ ): Promise<void> {
118
+ const { importFn, registeredLayouts, isReload = false, changedFile } = options;
119
+ const importOpts: { changedFile?: string } | undefined =
120
+ changedFile !== undefined ? { changedFile } : undefined;
121
+
122
+ if (isReload) {
123
+ registeredLayouts.clear();
124
+ }
125
+
126
+ // Expose the live manifest through the runtime registry so user code can
127
+ // read generated artifacts via `getGenerated("routes")` / `getManifest()`
128
+ // — the only official path. See `packages/core/src/runtime/registry.ts`.
129
+ registerManifest("routes", manifest);
130
+
131
+ for (const route of manifest.routes) {
132
+ // Issue #206: metadata routes (sitemap/robots/llms.txt/manifest)
133
+ // register a thunk that lazily imports the user module. The
134
+ // runtime dispatcher invokes the default export on each request
135
+ // so HMR reloads pick up edits automatically (same pattern as
136
+ // API routes below).
137
+ if (route.kind === "metadata") {
138
+ const modulePath = path.resolve(rootDir, route.module);
139
+ registerMetadataHandler(route.id, async () => {
140
+ return importFn(modulePath, importOpts);
141
+ });
142
+ console.log(` 🗺️ Metadata: ${route.pattern} -> ${route.id}`);
143
+ continue;
144
+ }
145
+
146
+ if (route.kind === "api") {
147
+ const modulePath = path.resolve(rootDir, route.module);
148
+ try {
149
+ const module = (await importFn(modulePath, importOpts)) as RouteModule;
150
+ let handler: unknown = module.default ?? module.handler ?? module;
151
+
152
+ // 1) ManduFilling instance
153
+ if (isFillingInstance(handler)) {
154
+ console.log(` 🔄 ManduFilling wrapped: ${route.id}`);
155
+ const filling = handler;
156
+
157
+ // WebSocket 핸들러 등록
158
+ if (typeof filling.hasWS === "function" && filling.hasWS() && filling.getWSHandlers) {
159
+ registerWSHandler(route.id, filling.getWSHandlers());
160
+ console.log(` 🔌 WebSocket: ${route.pattern} -> ${route.id}`);
161
+ }
162
+
163
+ handler = async (req: Request, params?: Record<string, string>) => {
164
+ return filling.handle(req, params);
165
+ };
166
+ }
167
+ // 2) Route module with HTTP method exports (GET/POST/...)
168
+ else if (handler && typeof handler === "object" && hasHttpMethodHandlers(handler as RouteModule)) {
169
+ handler = createMethodDispatcher(handler as RouteModule, route.id);
170
+ }
171
+
172
+ if (typeof handler !== "function") {
173
+ console.warn(` ⚠️ API handler conversion failed: ${route.id} (type: ${typeof handler})`);
174
+ continue;
175
+ }
176
+
177
+ registerApiHandler(route.id, handler as (req: Request, params?: Record<string, string>) => Response | Promise<Response>);
178
+ console.log(` 📡 API: ${route.pattern} -> ${route.id}`);
179
+ } catch (error) {
180
+ console.error(` ❌ Failed to load API handler: ${route.id}`, error);
181
+ }
182
+ } else if (route.kind === "page" && route.componentModule) {
183
+ const componentPath = path.resolve(rootDir, route.componentModule);
184
+ const isIsland = needsHydration(route);
185
+ const hasLayout = route.layoutChain && route.layoutChain.length > 0;
186
+
187
+ // Register layout loaders
188
+ if (route.layoutChain) {
189
+ for (const layoutPath of route.layoutChain) {
190
+ if (!registeredLayouts.has(layoutPath)) {
191
+ const absLayoutPath = path.resolve(rootDir, layoutPath);
192
+ registerLayoutLoader(layoutPath, (async () => {
193
+ // Layout modules must export a default component. Runtime
194
+ // validation in `renderToHTML` / page-loader asserts this —
195
+ // so casting the unknown `importFn` result is safe here.
196
+ return importFn(absLayoutPath, importOpts);
197
+ }) as Parameters<typeof registerLayoutLoader>[1]);
198
+ registeredLayouts.add(layoutPath);
199
+ console.log(` 🎨 Layout: ${layoutPath}`);
200
+ }
201
+ }
202
+ }
203
+
204
+ // Use PageHandler if slotModule exists (filling.loader support)
205
+ if (route.slotModule) {
206
+ registerPageHandler(route.id, async () => {
207
+ const mod = (await importFn(componentPath, importOpts)) as Record<string, unknown>;
208
+ // Normalize the page module shape. Users write pages in two styles:
209
+ // (a) `export default function Page() {…}` + `export const filling = …`
210
+ // (b) `export default { component: …, filling: … }`
211
+ // Spreading a function default drops the component silently (you get
212
+ // the function's own props like `name`/`length`, not the function).
213
+ // Auto-promote form (a) to form (b) so both work without surprises.
214
+ const rawDefault = mod.default as unknown;
215
+
216
+ let registration: PageRegistration;
217
+ if (typeof rawDefault === "function") {
218
+ registration = {
219
+ component: rawDefault as PageRegistration["component"],
220
+ filling: mod.filling as PageRegistration["filling"],
221
+ };
222
+ } else if (typeof rawDefault === "object" && rawDefault !== null) {
223
+ registration = { ...(rawDefault as unknown as PageRegistration) };
224
+ } else {
225
+ throw new Error(
226
+ `[Mandu] Page module '${route.id}' has no default export. ` +
227
+ `Expected a React component or { component, filling } object.`,
228
+ );
229
+ }
230
+
231
+ // #186: page 모듈의 metadata / generateMetadata 를 registration에 실어서
232
+ // ensurePageRouteMetadata가 registry 캐시에 저장할 수 있게 전달.
233
+ if (mod.metadata && typeof mod.metadata === "object") {
234
+ registration.metadata = mod.metadata as PageRegistration["metadata"];
235
+ }
236
+ if (typeof mod.generateMetadata === "function") {
237
+ registration.generateMetadata = mod.generateMetadata as PageRegistration["generateMetadata"];
238
+ }
239
+ return registration;
240
+ });
241
+ console.log(
242
+ ` 📄 Page: ${route.pattern} -> ${route.id} (with loader)${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
243
+ );
244
+ } else {
245
+ registerPageLoader(route.id, (() => importFn(componentPath, importOpts)) as Parameters<typeof registerPageLoader>[1]);
246
+ console.log(
247
+ ` 📄 Page: ${route.pattern} -> ${route.id}${isIsland ? " 🏝️" : ""}${hasLayout ? " 🎨" : ""}`
248
+ );
249
+ }
250
+ }
251
+ }
252
+
253
+ // Phase 6.3: register `app/not-found.tsx` if it exists. Global, one per
254
+ // app — the server falls through to the built-in 404 if unregistered.
255
+ await registerAppNotFound(rootDir, importFn, importOpts);
256
+ }
257
+
258
+ /**
259
+ * Phase 6.3: look for `app/not-found.tsx` (or its variants) at the
260
+ * project root and register it as the app-level 404 handler. Silent
261
+ * no-op if no file exists — the server's built-in 404 covers that case.
262
+ */
263
+ async function registerAppNotFound(
264
+ rootDir: string,
265
+ importFn: (modulePath: string, opts?: { changedFile?: string }) => Promise<unknown>,
266
+ importOpts?: { changedFile?: string },
267
+ ): Promise<void> {
268
+ const candidates = [
269
+ "app/not-found.tsx",
270
+ "app/not-found.ts",
271
+ "app/not-found.jsx",
272
+ "app/not-found.js",
273
+ ];
274
+ for (const rel of candidates) {
275
+ const abs = path.resolve(rootDir, rel);
276
+ try {
277
+ await fs.access(abs);
278
+ } catch {
279
+ continue;
280
+ }
281
+ registerNotFoundHandler(async () => {
282
+ const module = (await importFn(abs, importOpts)) as Record<string, unknown>;
283
+ const rawDefault = module.default as unknown;
284
+ if (typeof rawDefault === "function") {
285
+ return {
286
+ component: rawDefault as PageRegistration["component"],
287
+ filling: module.filling as PageRegistration["filling"],
288
+ };
289
+ }
290
+ if (typeof rawDefault === "object" && rawDefault !== null) {
291
+ return { ...(rawDefault as PageRegistration) };
292
+ }
293
+ throw new Error(
294
+ `[Mandu] app/not-found.tsx has no valid default export (type: ${typeof rawDefault})`,
295
+ );
296
+ });
297
+ console.log(` 🚫 Not-Found: ${rel}`);
298
+ return;
299
+ }
300
+ }
@@ -3,6 +3,10 @@ export * from "./streaming-ssr";
3
3
  export { extractShellHtml, createPPRResponse, PPR_SHELL_MARKER } from "./ppr";
4
4
  export * from "./router";
5
5
  export * from "./server";
6
+ export {
7
+ registerManifestHandlers,
8
+ type RegisterHandlersOptions,
9
+ } from "./handlers";
6
10
  export { redirect, isManduRedirect, isRedirectResponse, REDIRECT_BRAND } from "./redirect";
7
11
  export type { RedirectStatus, RedirectOptions } from "./redirect";
8
12
  export { notFound, isNotFoundResponse, NOT_FOUND_BRAND } from "./not-found";