@mandujs/core 0.54.19 → 0.54.21

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.
@@ -93,7 +93,7 @@ import {
93
93
  } from "./static-files";
94
94
  export { __clearStaticEtagCacheForTests } from "./static-files";
95
95
  import { extractShellHtml, createPPRResponse } from "./ppr";
96
- import { renderPageResponse, type InlineClientHydrationTarget } from "./page-render-response";
96
+ import { renderPageResponse, type InlineClientHydrationTarget } from "./page-render-response";
97
97
  import { isRedirectResponse } from "./redirect";
98
98
  import { isNotFoundResponse } from "./not-found";
99
99
  import { newId } from "../id";
@@ -479,12 +479,13 @@ export type ErrorLoader = () => Promise<{ default: ErrorComponent }>;
479
479
  * - component: React 컴포넌트
480
480
  * - filling: Slot의 ManduFilling 인스턴스 (loader 포함)
481
481
  */
482
- export interface PageRegistration {
483
- component: React.ComponentType<{
484
- params: Record<string, string>;
485
- loaderData?: unknown;
486
- __manduHydration?: InlineClientHydrationTarget;
487
- }>;
482
+ export interface PageRegistration {
483
+ component: React.ComponentType<{
484
+ params: Record<string, string>;
485
+ searchParams: Record<string, string>;
486
+ loaderData?: unknown;
487
+ __manduHydration?: InlineClientHydrationTarget;
488
+ }>;
488
489
  filling?: ManduFilling<unknown>;
489
490
  /** #186: page 모듈의 static `metadata` export (선택) */
490
491
  metadata?: Metadata;
@@ -506,20 +507,23 @@ export type PageHandler = () => Promise<PageRegistration>;
506
507
  */
507
508
  export type MetadataHandler = () => Promise<unknown>;
508
509
 
509
- export interface AppContext {
510
- routeId: string;
511
- url: string;
512
- params: Record<string, string>;
513
- /** SSR loader에서 로드한 데이터 */
514
- loaderData?: unknown;
515
- __manduHydration?: InlineClientHydrationTarget;
516
- }
517
-
518
- type RouteComponent = (props: {
519
- params: Record<string, string>;
520
- loaderData?: unknown;
521
- __manduHydration?: InlineClientHydrationTarget;
522
- }) => React.ReactElement;
510
+ export interface AppContext {
511
+ routeId: string;
512
+ url: string;
513
+ params: Record<string, string>;
514
+ /** 요청 URL의 쿼리스트링 (generateMetadata와 동일한 값) */
515
+ searchParams: Record<string, string>;
516
+ /** SSR loader에서 로드한 데이터 */
517
+ loaderData?: unknown;
518
+ __manduHydration?: InlineClientHydrationTarget;
519
+ }
520
+
521
+ type RouteComponent = (props: {
522
+ params: Record<string, string>;
523
+ searchParams: Record<string, string>;
524
+ loaderData?: unknown;
525
+ __manduHydration?: InlineClientHydrationTarget;
526
+ }) => React.ReactElement;
523
527
  type CreateAppFn = (context: AppContext) => React.ReactElement;
524
528
 
525
529
  // ========== Server Registry (인스턴스별 분리) ==========
@@ -1095,8 +1099,8 @@ async function wrapWithLayouts(
1095
1099
  }
1096
1100
 
1097
1101
  // Default createApp implementation (registry 기반)
1098
- function createDefaultAppFactory(registry: ServerRegistry) {
1099
- return function defaultCreateApp(context: AppContext): React.ReactElement {
1102
+ function createDefaultAppFactory(registry: ServerRegistry) {
1103
+ return function defaultCreateApp(context: AppContext): React.ReactElement {
1100
1104
  const Component = registry.routeComponents.get(context.routeId);
1101
1105
 
1102
1106
  if (!Component) {
@@ -1106,102 +1110,103 @@ function createDefaultAppFactory(registry: ServerRegistry) {
1106
1110
  );
1107
1111
  }
1108
1112
 
1109
- return React.createElement(Component, {
1110
- params: context.params,
1111
- loaderData: context.loaderData,
1112
- __manduHydration: context.__manduHydration,
1113
- });
1114
- };
1115
- }
1116
-
1117
- async function resolveInlineClientHydrationTarget(
1118
- route: {
1119
- id: string;
1120
- componentModule?: string;
1121
- clientModule?: string;
1122
- clientExportName?: string;
1123
- hydration?: HydrationConfig;
1124
- boundaries?: unknown[];
1125
- },
1126
- rootDir: string,
1127
- src: string,
1128
- ): Promise<InlineClientHydrationTarget | undefined> {
1129
- if (!route.clientModule || !src) {
1130
- return undefined;
1131
- }
1132
- if (route.boundaries && route.boundaries.length > 0) {
1133
- return undefined;
1134
- }
1135
-
1136
- const legacyRuntimeScan = process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN === "1";
1137
- if (!legacyRuntimeScan) {
1138
- return undefined;
1139
- }
1140
-
1141
- try {
1142
- const module = await import(path.join(rootDir, route.clientModule));
1143
- const component = resolveInlineClientHydrationComponent(module, route);
1144
-
1145
- if (!component) return undefined;
1146
-
1147
- return {
1148
- routeId: route.id,
1149
- src,
1150
- priority: route.hydration?.priority ?? "visible",
1151
- component,
1152
- legacyRuntimeScan,
1153
- sourceFile: route.componentModule ?? route.clientModule,
1154
- };
1155
- } catch (error) {
1156
- console.warn(
1157
- `[Mandu] Failed to resolve inline client hydration target for "${route.id}":`,
1158
- error,
1159
- );
1160
- return undefined;
1161
- }
1162
- }
1163
-
1164
- function resolveInlineClientHydrationComponent(
1165
- module: Record<string, unknown>,
1166
- route: { id: string; clientModule?: string; clientExportName?: string },
1167
- ): unknown {
1168
- if (module.default) return module.default;
1169
-
1170
- const candidates = [
1171
- route.clientExportName && route.clientExportName !== "default" ? route.clientExportName : undefined,
1172
- inferInlineClientExportNameFromPath(route.clientModule),
1173
- inferInlineClientExportNameFromRouteId(route.id),
1174
- ].filter((candidate, index, values): candidate is string =>
1175
- !!candidate && values.indexOf(candidate) === index
1176
- );
1177
-
1178
- for (const candidate of candidates) {
1179
- if (module[candidate]) return module[candidate];
1180
- }
1181
-
1182
- const runtimeExports = Object.keys(module).filter((name) => name !== "__esModule");
1183
- return runtimeExports.length === 1 ? module[runtimeExports[0]] : undefined;
1184
- }
1185
-
1186
- function inferInlineClientExportNameFromPath(clientModulePath: string | undefined): string | undefined {
1187
- if (!clientModulePath) return undefined;
1188
- const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
1189
- const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
1190
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix)
1191
- ? withoutClientSuffix
1192
- : undefined;
1193
- }
1194
-
1195
- function inferInlineClientExportNameFromRouteId(routeId: string): string | undefined {
1196
- const pascal = routeId
1197
- .split(/[^A-Za-z0-9]+/)
1198
- .filter(Boolean)
1199
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1200
- .join("");
1201
- return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : undefined;
1202
- }
1203
-
1204
- const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1113
+ return React.createElement(Component, {
1114
+ params: context.params,
1115
+ searchParams: context.searchParams,
1116
+ loaderData: context.loaderData,
1117
+ __manduHydration: context.__manduHydration,
1118
+ });
1119
+ };
1120
+ }
1121
+
1122
+ async function resolveInlineClientHydrationTarget(
1123
+ route: {
1124
+ id: string;
1125
+ componentModule?: string;
1126
+ clientModule?: string;
1127
+ clientExportName?: string;
1128
+ hydration?: HydrationConfig;
1129
+ boundaries?: unknown[];
1130
+ },
1131
+ rootDir: string,
1132
+ src: string,
1133
+ ): Promise<InlineClientHydrationTarget | undefined> {
1134
+ if (!route.clientModule || !src) {
1135
+ return undefined;
1136
+ }
1137
+ if (route.boundaries && route.boundaries.length > 0) {
1138
+ return undefined;
1139
+ }
1140
+
1141
+ const legacyRuntimeScan = process.env.MANDU_LEGACY_RUNTIME_PARTIAL_SCAN === "1";
1142
+ if (!legacyRuntimeScan) {
1143
+ return undefined;
1144
+ }
1145
+
1146
+ try {
1147
+ const module = await import(path.join(rootDir, route.clientModule));
1148
+ const component = resolveInlineClientHydrationComponent(module, route);
1149
+
1150
+ if (!component) return undefined;
1151
+
1152
+ return {
1153
+ routeId: route.id,
1154
+ src,
1155
+ priority: route.hydration?.priority ?? "visible",
1156
+ component,
1157
+ legacyRuntimeScan,
1158
+ sourceFile: route.componentModule ?? route.clientModule,
1159
+ };
1160
+ } catch (error) {
1161
+ console.warn(
1162
+ `[Mandu] Failed to resolve inline client hydration target for "${route.id}":`,
1163
+ error,
1164
+ );
1165
+ return undefined;
1166
+ }
1167
+ }
1168
+
1169
+ function resolveInlineClientHydrationComponent(
1170
+ module: Record<string, unknown>,
1171
+ route: { id: string; clientModule?: string; clientExportName?: string },
1172
+ ): unknown {
1173
+ if (module.default) return module.default;
1174
+
1175
+ const candidates = [
1176
+ route.clientExportName && route.clientExportName !== "default" ? route.clientExportName : undefined,
1177
+ inferInlineClientExportNameFromPath(route.clientModule),
1178
+ inferInlineClientExportNameFromRouteId(route.id),
1179
+ ].filter((candidate, index, values): candidate is string =>
1180
+ !!candidate && values.indexOf(candidate) === index
1181
+ );
1182
+
1183
+ for (const candidate of candidates) {
1184
+ if (module[candidate]) return module[candidate];
1185
+ }
1186
+
1187
+ const runtimeExports = Object.keys(module).filter((name) => name !== "__esModule");
1188
+ return runtimeExports.length === 1 ? module[runtimeExports[0]] : undefined;
1189
+ }
1190
+
1191
+ function inferInlineClientExportNameFromPath(clientModulePath: string | undefined): string | undefined {
1192
+ if (!clientModulePath) return undefined;
1193
+ const basename = path.basename(clientModulePath).replace(/\.[cm]?[jt]sx?$/, "");
1194
+ const withoutClientSuffix = basename.replace(/\.(client|island)$/, "");
1195
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(withoutClientSuffix)
1196
+ ? withoutClientSuffix
1197
+ : undefined;
1198
+ }
1199
+
1200
+ function inferInlineClientExportNameFromRouteId(routeId: string): string | undefined {
1201
+ const pascal = routeId
1202
+ .split(/[^A-Za-z0-9]+/)
1203
+ .filter(Boolean)
1204
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
1205
+ .join("");
1206
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(pascal) ? pascal : undefined;
1207
+ }
1208
+
1209
+ const INTERNAL_CACHE_ENDPOINT = "/_mandu/cache";
1205
1210
 
1206
1211
  // ========== Request Handler ==========
1207
1212
 
@@ -2114,21 +2119,21 @@ function extractTitleText(titleHtml: string): string | null {
2114
2119
  /**
2115
2120
  * SSR 렌더링 (Streaming/Non-streaming)
2116
2121
  */
2117
- async function renderPageSSR(
2118
- route: {
2119
- id: string;
2120
- pattern: string;
2121
- layoutChain?: string[];
2122
- streaming?: boolean;
2123
- hydration?: HydrationConfig;
2124
- errorModule?: string;
2125
- loadingModule?: string;
2126
- notFoundModule?: string;
2127
- componentModule?: string;
2128
- clientModule?: string;
2129
- clientExportName?: string;
2130
- boundaries?: unknown[];
2131
- },
2122
+ async function renderPageSSR(
2123
+ route: {
2124
+ id: string;
2125
+ pattern: string;
2126
+ layoutChain?: string[];
2127
+ streaming?: boolean;
2128
+ hydration?: HydrationConfig;
2129
+ errorModule?: string;
2130
+ loadingModule?: string;
2131
+ notFoundModule?: string;
2132
+ componentModule?: string;
2133
+ clientModule?: string;
2134
+ clientExportName?: string;
2135
+ boundaries?: unknown[];
2136
+ },
2132
2137
  params: Record<string, string>,
2133
2138
  loaderData: unknown,
2134
2139
  url: string,
@@ -2138,30 +2143,31 @@ async function renderPageSSR(
2138
2143
  ): Promise<Result<Response>> {
2139
2144
  const settings = registry.settings;
2140
2145
  const defaultAppCreator = createDefaultAppFactory(registry);
2141
- const appCreator = registry.createAppFn || defaultAppCreator;
2142
-
2143
- try {
2144
- const useStreaming = route.streaming !== undefined
2145
- ? route.streaming
2146
- : settings.streaming;
2147
- const needsIslandHydration = !!(
2148
- route.hydration &&
2149
- route.hydration.strategy !== "none" &&
2150
- settings.bundleManifest
2151
- );
2152
- const routeBundle = settings.bundleManifest?.bundles[route.id];
2153
- const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
2154
- const inlineClientHydration = needsIslandHydration && !useStreaming
2155
- ? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
2156
- : undefined;
2157
-
2158
- let app = appCreator({
2159
- routeId: route.id,
2160
- url,
2161
- params,
2162
- loaderData,
2163
- __manduHydration: inlineClientHydration,
2164
- });
2146
+ const appCreator = registry.createAppFn || defaultAppCreator;
2147
+
2148
+ try {
2149
+ const useStreaming = route.streaming !== undefined
2150
+ ? route.streaming
2151
+ : settings.streaming;
2152
+ const needsIslandHydration = !!(
2153
+ route.hydration &&
2154
+ route.hydration.strategy !== "none" &&
2155
+ settings.bundleManifest
2156
+ );
2157
+ const routeBundle = settings.bundleManifest?.bundles[route.id];
2158
+ const bundleSrc = routeBundle?.js ? `${routeBundle.js}?t=${Date.now()}` : "";
2159
+ const inlineClientHydration = needsIslandHydration && !useStreaming
2160
+ ? await resolveInlineClientHydrationTarget(route, settings.rootDir, bundleSrc)
2161
+ : undefined;
2162
+
2163
+ let app = appCreator({
2164
+ routeId: route.id,
2165
+ url,
2166
+ params,
2167
+ searchParams: extractSearchParams(url),
2168
+ loaderData,
2169
+ __manduHydration: inlineClientHydration,
2170
+ });
2165
2171
 
2166
2172
  // Phase 18.β — per-route Suspense wrapper (Next.js `loading.tsx` parity).
2167
2173
  // If the route declared a `loading.tsx`, wrap the page element in a
@@ -2189,13 +2195,13 @@ async function renderPageSSR(
2189
2195
 
2190
2196
  // Island 래핑: 레이아웃 적용 전에 페이지 콘텐츠만 island div로 감쌈
2191
2197
  // 이렇게 하면 레이아웃은 island 바깥에 위치하여 하이드레이션 시 레이아웃이 유지됨
2192
- const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
2198
+ const needsIslandWrap = needsIslandHydration && bundleSrc.length > 0 && !inlineClientHydration;
2193
2199
 
2194
- if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
2195
- console.warn(
2196
- `[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
2197
- `Run mandu build/generate and ensure the route has a clientModule.`,
2198
- );
2200
+ if (needsIslandHydration && bundleSrc.length === 0 && settings.isDev) {
2201
+ console.warn(
2202
+ `[Mandu] Hydration requested for route "${route.id}" but no client bundle was found. ` +
2203
+ `Run mandu build/generate and ensure the route has a clientModule.`,
2204
+ );
2199
2205
  }
2200
2206
 
2201
2207
  if (needsIslandWrap) {
@@ -2216,7 +2222,7 @@ async function renderPageSSR(
2216
2222
  // #186: layout chain + page metadata 병합
2217
2223
  const builtMeta = await buildSSRMetadata(route, params, url, registry);
2218
2224
 
2219
- const pageResponse = await renderPageResponse({
2225
+ const pageResponse = await renderPageResponse({
2220
2226
  app,
2221
2227
  useStreaming,
2222
2228
  title: builtMeta.title,
@@ -2227,12 +2233,12 @@ async function renderPageSSR(
2227
2233
  routePattern: route.pattern,
2228
2234
  layoutChain: route.layoutChain,
2229
2235
  hydration: route.hydration,
2230
- bundleManifest: settings.bundleManifest,
2231
- loaderData,
2232
- cssPath: settings.cssPath,
2233
- islandPreWrapped: needsIslandWrap,
2234
- inlineClientHydration,
2235
- transitions: settings.transitions,
2236
+ bundleManifest: settings.bundleManifest,
2237
+ loaderData,
2238
+ cssPath: settings.cssPath,
2239
+ islandPreWrapped: needsIslandWrap,
2240
+ inlineClientHydration,
2241
+ transitions: settings.transitions,
2236
2242
  prefetch: settings.prefetch,
2237
2243
  spa: settings.spa,
2238
2244
  devtools: settings.devtools,
@@ -2422,6 +2428,7 @@ async function renderNotFoundPage(
2422
2428
  // streaming, no island bundling — a 404 page is plain).
2423
2429
  let app: React.ReactElement = React.createElement(NotFoundComponent, {
2424
2430
  params,
2431
+ searchParams: extractSearchParams(req.url),
2425
2432
  loaderData,
2426
2433
  });
2427
2434
  if (route.layoutChain && route.layoutChain.length > 0) {
@@ -3450,6 +3457,7 @@ async function handleRequestInternal(
3450
3457
  }
3451
3458
  const rawApp = React.createElement(registration.component, {
3452
3459
  params: {},
3460
+ searchParams: extractSearchParams(req.url),
3453
3461
  loaderData,
3454
3462
  });
3455
3463
  // Issue #198 — pre-resolve in case the not-found component is async.