@mandujs/core 0.43.0 → 0.44.0
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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `resolveReactCompilerConfig` — #240 Phase 2 auto-detect tests.
|
|
3
|
+
*
|
|
4
|
+
* The probe only fires when `enabled` is undefined. Explicit `true` /
|
|
5
|
+
* `false` veto the probe so user intent always wins.
|
|
6
|
+
*
|
|
7
|
+
* Cache lifetime is per-process; we reset it between cases so a probe
|
|
8
|
+
* from one fixture doesn't leak to the next.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
11
|
+
import fs from "node:fs/promises";
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
resolveReactCompilerConfig,
|
|
17
|
+
_resetReactCompilerConfigCache,
|
|
18
|
+
} from "../react-compiler-config";
|
|
19
|
+
|
|
20
|
+
async function makeRoot(prefix: string): Promise<string> {
|
|
21
|
+
return fs.mkdtemp(path.join(os.tmpdir(), `mandu-rc-${prefix}-`));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
_resetReactCompilerConfigCache();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("resolveReactCompilerConfig", () => {
|
|
29
|
+
it("explicit enabled:true honours the user even when peers are missing", async () => {
|
|
30
|
+
const root = await makeRoot("explicit-on");
|
|
31
|
+
const result = resolveReactCompilerConfig({ enabled: true }, root);
|
|
32
|
+
expect(result.enabled).toBe(true);
|
|
33
|
+
expect(result.autoDetected).toBe(false);
|
|
34
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("explicit enabled:false vetos the probe", async () => {
|
|
38
|
+
const root = await makeRoot("explicit-off");
|
|
39
|
+
const result = resolveReactCompilerConfig({ enabled: false }, root);
|
|
40
|
+
expect(result.enabled).toBe(false);
|
|
41
|
+
expect(result.autoDetected).toBe(false);
|
|
42
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("undefined enabled + missing peers → disabled silently", async () => {
|
|
46
|
+
const root = await makeRoot("auto-no-peers");
|
|
47
|
+
// Empty rootDir — no node_modules, no package.json, no peer deps.
|
|
48
|
+
const result = resolveReactCompilerConfig(undefined, root);
|
|
49
|
+
expect(result.enabled).toBe(false);
|
|
50
|
+
expect(result.autoDetected).toBe(false);
|
|
51
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("forwards compilerConfig regardless of enabled state", async () => {
|
|
55
|
+
const root = await makeRoot("compiler-config");
|
|
56
|
+
const cfg = { compilationMode: "annotation" };
|
|
57
|
+
const result = resolveReactCompilerConfig(
|
|
58
|
+
{ enabled: true, compilerConfig: cfg },
|
|
59
|
+
root,
|
|
60
|
+
);
|
|
61
|
+
expect(result.compilerConfig).toBe(cfg);
|
|
62
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("caches by (rootDir, explicit-enabled) — second call hits cache", async () => {
|
|
66
|
+
const root = await makeRoot("cache");
|
|
67
|
+
const a = resolveReactCompilerConfig(undefined, root);
|
|
68
|
+
const b = resolveReactCompilerConfig(undefined, root);
|
|
69
|
+
// Same identity — cache hit returns the stored object.
|
|
70
|
+
expect(a).toBe(b);
|
|
71
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("treats explicit-true vs auto as separate cache keys", async () => {
|
|
75
|
+
const root = await makeRoot("cache-key");
|
|
76
|
+
const auto = resolveReactCompilerConfig(undefined, root);
|
|
77
|
+
const explicit = resolveReactCompilerConfig({ enabled: true }, root);
|
|
78
|
+
expect(auto).not.toBe(explicit);
|
|
79
|
+
expect(auto.enabled).toBe(false);
|
|
80
|
+
expect(explicit.enabled).toBe(true);
|
|
81
|
+
await fs.rm(root, { recursive: true, force: true });
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -36,6 +36,12 @@ export {
|
|
|
36
36
|
type FormatCompilerReportOptions,
|
|
37
37
|
} from "./react-compiler-lint";
|
|
38
38
|
|
|
39
|
+
export {
|
|
40
|
+
resolveReactCompilerConfig,
|
|
41
|
+
type RawReactCompilerConfig,
|
|
42
|
+
type ResolvedReactCompilerConfig,
|
|
43
|
+
} from "./react-compiler-config";
|
|
44
|
+
|
|
39
45
|
/**
|
|
40
46
|
* Subset of `ManduConfig.guard` consumed by `defaultBundlerPlugins()`.
|
|
41
47
|
* We deliberately don't import the full `ManduConfig` type to keep the
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React Compiler config resolver (#240 Phase 2 — auto-detect).
|
|
3
|
+
*
|
|
4
|
+
* The `experimental.reactCompiler` block in `mandu.config.ts` has three
|
|
5
|
+
* meaningful states for the `enabled` field:
|
|
6
|
+
*
|
|
7
|
+
* - `true` — user explicitly opts in. The transform plugin runs and
|
|
8
|
+
* warns if peer deps (`@babel/core`, `babel-plugin-react-compiler`)
|
|
9
|
+
* are missing.
|
|
10
|
+
* - `false` — user explicitly opts out. Plugin never runs.
|
|
11
|
+
* - `undefined` (the default) — Phase 2: probe whether the peer deps
|
|
12
|
+
* are installed in the project. If both resolve, treat as enabled
|
|
13
|
+
* so installing `babel-plugin-react-compiler` is the only step
|
|
14
|
+
* needed to turn auto-memoization on (zero-config goal of #240).
|
|
15
|
+
* If either is missing, stay disabled silently — no warning, no
|
|
16
|
+
* surface change for projects that haven't asked for the Compiler.
|
|
17
|
+
*
|
|
18
|
+
* The probe is synchronous (`Bun.resolveSync`) so it composes with the
|
|
19
|
+
* non-async `manduClientPlugins()` gate. Resolutions are cached per
|
|
20
|
+
* `(rootDir, enabled)` pair because the bundler asks for plugins many
|
|
21
|
+
* times during a single build (one for each entry / shim / island).
|
|
22
|
+
*
|
|
23
|
+
* @module core/bundler/plugins/react-compiler-config
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface RawReactCompilerConfig {
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
compilerConfig?: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ResolvedReactCompilerConfig {
|
|
32
|
+
/**
|
|
33
|
+
* Final on/off decision after applying auto-detect. Always a concrete
|
|
34
|
+
* boolean — callers do not need to repeat the probe.
|
|
35
|
+
*/
|
|
36
|
+
enabled: boolean;
|
|
37
|
+
/** Forwarded to `babel-plugin-react-compiler`. */
|
|
38
|
+
compilerConfig?: Record<string, unknown>;
|
|
39
|
+
/**
|
|
40
|
+
* `true` when `enabled` was implicitly resolved from peer-dep probe
|
|
41
|
+
* (vs. set explicitly by the user). Surfaced so the bundler's plugin
|
|
42
|
+
* can suppress the "peer dep missing" warning — the implicit path
|
|
43
|
+
* already short-circuits before the plugin runs, but a future caller
|
|
44
|
+
* that bypasses this resolver would otherwise spam the warning.
|
|
45
|
+
*/
|
|
46
|
+
autoDetected: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const cache = new Map<string, ResolvedReactCompilerConfig>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Probe whether `@babel/core` and `babel-plugin-react-compiler` resolve
|
|
53
|
+
* from `rootDir`. Both must be present — the transform plugin loads
|
|
54
|
+
* them as a pair. Returns `false` on any resolution failure (missing
|
|
55
|
+
* dep, broken symlink, weird workspace layout) so the failure mode is
|
|
56
|
+
* "stay off" rather than "blow up boot".
|
|
57
|
+
*/
|
|
58
|
+
function peerDepsInstalled(rootDir: string): boolean {
|
|
59
|
+
try {
|
|
60
|
+
Bun.resolveSync("@babel/core", rootDir);
|
|
61
|
+
Bun.resolveSync("babel-plugin-react-compiler", rootDir);
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the user's `experimental.reactCompiler` block into a final
|
|
70
|
+
* on/off decision plus carried-over compiler options.
|
|
71
|
+
*
|
|
72
|
+
* Cache key includes `rootDir` and the explicit-enabled value so we
|
|
73
|
+
* can have, in tests, two projects in the same process with different
|
|
74
|
+
* enablement states.
|
|
75
|
+
*/
|
|
76
|
+
export function resolveReactCompilerConfig(
|
|
77
|
+
raw: RawReactCompilerConfig | undefined,
|
|
78
|
+
rootDir: string,
|
|
79
|
+
): ResolvedReactCompilerConfig {
|
|
80
|
+
const explicit = raw?.enabled;
|
|
81
|
+
const cacheKey = `${rootDir}::${explicit ?? "auto"}`;
|
|
82
|
+
const hit = cache.get(cacheKey);
|
|
83
|
+
if (hit) return hit;
|
|
84
|
+
|
|
85
|
+
let enabled: boolean;
|
|
86
|
+
let autoDetected = false;
|
|
87
|
+
if (explicit === true) {
|
|
88
|
+
enabled = true;
|
|
89
|
+
} else if (explicit === false) {
|
|
90
|
+
enabled = false;
|
|
91
|
+
} else {
|
|
92
|
+
enabled = peerDepsInstalled(rootDir);
|
|
93
|
+
autoDetected = enabled;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result: ResolvedReactCompilerConfig = {
|
|
97
|
+
enabled,
|
|
98
|
+
compilerConfig: raw?.compilerConfig,
|
|
99
|
+
autoDetected,
|
|
100
|
+
};
|
|
101
|
+
cache.set(cacheKey, result);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Test-only — drop cached probes between fixture setups. */
|
|
106
|
+
export function _resetReactCompilerConfigCache(): void {
|
|
107
|
+
cache.clear();
|
|
108
|
+
}
|
|
@@ -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
|
+
}
|
package/src/runtime/index.ts
CHANGED
|
@@ -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";
|