@mandujs/core 0.54.31 → 0.54.32

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/runtime/server.ts +191 -88
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.31",
3
+ "version": "0.54.32",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -117,11 +117,12 @@ import {
117
117
  resolvePrerenderedFile,
118
118
  type PrerenderIndex,
119
119
  } from "../bundler/prerender";
120
- import {
121
- buildOverlayErrorHtml,
122
- buildPayloadFromError,
123
- shouldInjectOverlay,
124
- } from "../dev-error-overlay";
120
+ import {
121
+ buildOverlayErrorHtml,
122
+ buildPayloadFromError,
123
+ OVERLAY_CUSTOM_EVENT,
124
+ shouldInjectOverlay,
125
+ } from "../dev-error-overlay";
125
126
  // Phase 18.μ — i18n dispatch. `resolveLocale()` is pure (no side effects),
126
127
  // `createTranslator()` binds a per-request `t()` to the registry.
127
128
  import {
@@ -209,15 +210,24 @@ export interface ServerOptions {
209
210
  * 자동으로 캐싱됨 (Next.js `export const revalidate` 등가).
210
211
  * - `false`/undefined : 캐시 비활성화
211
212
  */
212
- cache?: boolean | CacheStore | CacheConfig;
213
- /**
214
- * Internal management token for local CLI/runtime control endpoints.
215
- * When set, token-protected endpoints such as `/_mandu/cache` become available.
216
- */
217
- managementToken?: string;
218
- /**
219
- * Issue #192enable CSS View Transitions auto-inject (default `true`).
220
- * When `true`, every SSR response gets
213
+ cache?: boolean | CacheStore | CacheConfig;
214
+ /**
215
+ * Internal management token for local CLI/runtime control endpoints.
216
+ * When set, token-protected endpoints such as `/_mandu/cache` become available.
217
+ */
218
+ managementToken?: string;
219
+ /**
220
+ * Phase 18.κoverride initial `.mandu/client/*` health state when
221
+ * starting a dev server.
222
+ */
223
+ clientBundleHealthy?: boolean;
224
+ /** Optional failure reason surfaced by dev overlay when unhealthy. */
225
+ clientBundleFailureReason?: string;
226
+ /** Optional route ID associated with the latest client-bundle failure. */
227
+ clientBundleFailureRouteId?: string;
228
+ /**
229
+ * Issue #192 — enable CSS View Transitions auto-inject (default `true`).
230
+ * When `true`, every SSR response gets
221
231
  * `<style>@view-transition{navigation:auto}</style>` in its `<head>`,
222
232
  * giving supported browsers a default crossfade on cross-document
223
233
  * navigation. Pass `false` to suppress (typically wired from
@@ -533,19 +543,29 @@ type CreateAppFn = (context: AppContext) => React.ReactElement;
533
543
  * 같은 프로세스에서 여러 서버를 띄울 때 핸들러가 섞이는 문제 방지
534
544
  */
535
545
  export interface ServerRegistrySettings {
536
- isDev: boolean;
537
- hmrPort?: number;
538
- bundleManifest?: BundleManifest;
539
- rootDir: string;
540
- publicDir: string;
541
- cors?: CorsOptions | false;
542
- streaming: boolean;
543
- rateLimit?: NormalizedRateLimitOptions | false;
544
- /**
545
- * CSS 파일 경로 (SSR 링크 주입용)
546
- * - string: 해당 경로로 <link> 주입
547
- * - false: CSS 링크 주입 비활성화
548
- * - undefined: false로 처리 (404 방지)
546
+ isDev: boolean;
547
+ hmrPort?: number;
548
+ bundleManifest?: BundleManifest;
549
+ rootDir: string;
550
+ publicDir: string;
551
+ cors?: CorsOptions | false;
552
+ streaming: boolean;
553
+ rateLimit?: NormalizedRateLimitOptions | false;
554
+ /**
555
+ * Internal flag controlled by the CLI dev bundler lifecycle.
556
+ * `false` disables serving `.mandu/client/*` while the latest
557
+ * client bundle build is unhealthy.
558
+ */
559
+ clientBundleHealthy?: boolean;
560
+ /** Last client bundle failure message emitted by the dev bundler. */
561
+ clientBundleFailureReason?: string;
562
+ /** Route ID associated with the last client bundle failure. */
563
+ clientBundleFailureRouteId?: string;
564
+ /**
565
+ * CSS 파일 경로 (SSR 링크 주입용)
566
+ * - string: 해당 경로로 <link> 주입
567
+ * - false: CSS 링크 주입 비활성화
568
+ * - undefined: false로 처리 (404 방지)
549
569
  */
550
570
  cssPath?: string | false;
551
571
  /** ISR/SWR 캐시 스토어 */
@@ -700,14 +720,17 @@ export class ServerRegistry {
700
720
  readonly pageGenerateMetadata: Map<string, GenerateMetadata> = new Map();
701
721
  readonly layoutMetadata: Map<string, Metadata | null> = new Map();
702
722
  readonly layoutGenerateMetadata: Map<string, GenerateMetadata> = new Map();
703
- settings: ServerRegistrySettings = {
704
- isDev: false,
705
- rootDir: process.cwd(),
706
- publicDir: "public",
707
- cors: false,
708
- streaming: false,
709
- rateLimit: false,
710
- };
723
+ settings: ServerRegistrySettings = {
724
+ isDev: false,
725
+ rootDir: process.cwd(),
726
+ clientBundleHealthy: true,
727
+ clientBundleFailureReason: undefined,
728
+ clientBundleFailureRouteId: undefined,
729
+ publicDir: "public",
730
+ cors: false,
731
+ streaming: false,
732
+ rateLimit: false,
733
+ };
711
734
 
712
735
  registerApiHandler(routeId: string, handler: ApiHandler): void {
713
736
  this.apiHandlers.set(routeId, handler);
@@ -3187,15 +3210,70 @@ function paramsInStaticSet(
3187
3210
  if (allMatch) return true;
3188
3211
  }
3189
3212
  return false;
3190
- }
3191
- // ─── End Issue #214 ─────────────────────────────────────────────────────────
3192
-
3193
- async function handleRequestInternal(
3194
- req: Request,
3195
- router: Router,
3196
- registry: ServerRegistry,
3197
- skipMiddleware: boolean = false
3198
- ): Promise<Result<Response>> {
3213
+ }
3214
+ // ─── End Issue #214 ─────────────────────────────────────────────────────────
3215
+
3216
+ function buildClientBundleFailureResponse(pathname: string, settings: ServerRegistrySettings): Response {
3217
+ const reason = settings.clientBundleFailureReason ?? "Client bundle build failed.";
3218
+ const routeId = settings.clientBundleFailureRouteId;
3219
+ const failureDetails = routeId ? ` Route: ${routeId}.` : "";
3220
+ const extension = path.extname(pathname).toLowerCase();
3221
+ const isCss = extension === ".css";
3222
+ const payload = buildPayloadFromError(
3223
+ new Error(`${reason}${failureDetails}`),
3224
+ {
3225
+ kind: "manual",
3226
+ routeId,
3227
+ url: pathname,
3228
+ },
3229
+ );
3230
+ const payloadJson = JSON.stringify(payload).replace(/</g, "\\u003c");
3231
+ const reasonPayload = JSON.stringify({
3232
+ message: reason,
3233
+ routeId,
3234
+ pathname,
3235
+ }).replace(/</g, "\\u003c");
3236
+
3237
+ if (isCss) {
3238
+ const cssBody = `/* Mandu: client bundle unavailable in dev. ${reason}${failureDetails} */`;
3239
+ return new Response(cssBody, {
3240
+ status: 503,
3241
+ headers: {
3242
+ "Content-Type": "text/css; charset=utf-8",
3243
+ "Cache-Control": "no-cache, no-store, must-revalidate",
3244
+ },
3245
+ });
3246
+ }
3247
+
3248
+ const jsBody = [
3249
+ "(function(){",
3250
+ `var payload = ${payloadJson};`,
3251
+ "if (typeof console !== \"undefined\" && console && console.warn) {",
3252
+ `console.warn("Mandu: client bundle is unavailable in dev mode.", ${reasonPayload});`,
3253
+ "}",
3254
+ "if (typeof window !== \"undefined\") {",
3255
+ "try {",
3256
+ `window.dispatchEvent(new CustomEvent(${JSON.stringify(OVERLAY_CUSTOM_EVENT)}, { detail: payload }));`,
3257
+ "} catch (_) {}",
3258
+ "}",
3259
+ "})();",
3260
+ ].join("");
3261
+
3262
+ return new Response(jsBody, {
3263
+ status: 503,
3264
+ headers: {
3265
+ "Content-Type": "application/javascript; charset=utf-8",
3266
+ "Cache-Control": "no-cache, no-store, must-revalidate",
3267
+ },
3268
+ });
3269
+ }
3270
+
3271
+ async function handleRequestInternal(
3272
+ req: Request,
3273
+ router: Router,
3274
+ registry: ServerRegistry,
3275
+ skipMiddleware: boolean = false
3276
+ ): Promise<Result<Response>> {
3199
3277
  const url = new URL(req.url);
3200
3278
  const pathname = url.pathname;
3201
3279
  const settings = registry.settings;
@@ -3217,16 +3295,20 @@ async function handleRequestInternal(
3217
3295
  // HMR signals "full reload" — user saves a file, reloads, and the old
3218
3296
  // prerendered output wins. Production `mandu start` always runs with
3219
3297
  // `isDev: false`, so prod behavior is unchanged.
3220
- if (!settings.isDev) {
3221
- const prerendered = await tryServePrerendered(pathname, settings, req.method, req);
3222
- if (prerendered) {
3223
- if (settings.cors && isCorsRequest(req)) {
3224
- const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
3225
- return ok(applyCorsToResponse(prerendered, req, corsOptions));
3226
- }
3227
- return ok(prerendered);
3228
- }
3229
- }
3298
+ if (!settings.isDev) {
3299
+ const prerendered = await tryServePrerendered(pathname, settings, req.method, req);
3300
+ if (prerendered) {
3301
+ if (settings.cors && isCorsRequest(req)) {
3302
+ const corsOptions: CorsOptions = typeof settings.cors === 'object' ? settings.cors : {};
3303
+ return ok(applyCorsToResponse(prerendered, req, corsOptions));
3304
+ }
3305
+ return ok(prerendered);
3306
+ }
3307
+ }
3308
+
3309
+ if (settings.isDev && settings.clientBundleHealthy === false && pathname.startsWith("/.mandu/client/")) {
3310
+ return ok(buildClientBundleFailureResponse(pathname, settings));
3311
+ }
3230
3312
 
3231
3313
  // ─── Phase 18.μ — i18n dispatch ─────────────────────────────────────────
3232
3314
  // Runs AFTER γ's prerendered check (static HTML per-locale is already
@@ -3750,12 +3832,15 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3750
3832
  cors = false,
3751
3833
  streaming = false,
3752
3834
  rateLimit = false,
3753
- cssPath: cssPathOption,
3754
- registry = defaultRegistry,
3755
- guardConfig = null,
3756
- cache: cacheOption,
3757
- managementToken,
3758
- transitions,
3835
+ cssPath: cssPathOption,
3836
+ registry = defaultRegistry,
3837
+ guardConfig = null,
3838
+ cache: cacheOption,
3839
+ managementToken,
3840
+ clientBundleHealthy = true,
3841
+ clientBundleFailureReason,
3842
+ clientBundleFailureRouteId,
3843
+ transitions,
3759
3844
  prefetch,
3760
3845
  spa,
3761
3846
  devtools,
@@ -3857,16 +3942,19 @@ export function startServer(manifest: RoutesManifest, options: ServerOptions = {
3857
3942
  });
3858
3943
 
3859
3944
  // Registry settings 저장 (초기값)
3860
- registry.settings = {
3861
- isDev,
3862
- hmrPort,
3863
- bundleManifest,
3864
- rootDir,
3865
- publicDir,
3866
- cors: corsOptions,
3867
- streaming,
3868
- rateLimit: rateLimitOptions,
3869
- cssPath,
3945
+ registry.settings = {
3946
+ isDev,
3947
+ hmrPort,
3948
+ bundleManifest,
3949
+ rootDir,
3950
+ publicDir,
3951
+ clientBundleHealthy,
3952
+ clientBundleFailureReason,
3953
+ clientBundleFailureRouteId,
3954
+ cors: corsOptions,
3955
+ streaming,
3956
+ rateLimit: rateLimitOptions,
3957
+ cssPath,
3870
3958
  managementToken,
3871
3959
  transitions,
3872
3960
  prefetch,
@@ -4158,10 +4246,19 @@ export const routeComponents = defaultRegistry.routeComponents;
4158
4246
  export interface AppFetchHandlerOptions {
4159
4247
  /** Project root (used for module path validation). Required. */
4160
4248
  rootDir: string;
4161
- /** Bundle manifest (Island hydration). Optional in pure-SSR apps. */
4162
- bundleManifest?: BundleManifest;
4163
- /** CORS config — `true` allows all origins, object for fine-grained rules. */
4164
- cors?: boolean | CorsOptions;
4249
+ /** Bundle manifest (Island hydration). Optional in pure-SSR apps. */
4250
+ bundleManifest?: BundleManifest;
4251
+ /**
4252
+ * Phase 18.κ — override initial `.mandu/client/*` health state when using
4253
+ * a runtime-neutral fetch handler.
4254
+ */
4255
+ clientBundleHealthy?: boolean;
4256
+ /** Optional failure reason surfaced by dev overlay when unhealthy. */
4257
+ clientBundleFailureReason?: string;
4258
+ /** Optional route ID associated with the latest client-bundle failure. */
4259
+ clientBundleFailureRouteId?: string;
4260
+ /** CORS config — `true` allows all origins, object for fine-grained rules. */
4261
+ cors?: boolean | CorsOptions;
4165
4262
  /** Streaming SSR toggle. Default: `false`. */
4166
4263
  streaming?: boolean;
4167
4264
  /** Rate limit policy. Memory-backed; edge runtimes should prefer durable stores. */
@@ -4221,11 +4318,14 @@ export function createAppFetchHandler(
4221
4318
  manifest: RoutesManifest,
4222
4319
  options: AppFetchHandlerOptions
4223
4320
  ): (req: Request) => Promise<Response> {
4224
- const {
4225
- rootDir,
4226
- bundleManifest,
4227
- cors = false,
4228
- streaming = false,
4321
+ const {
4322
+ rootDir,
4323
+ bundleManifest,
4324
+ clientBundleHealthy = true,
4325
+ clientBundleFailureReason,
4326
+ clientBundleFailureRouteId,
4327
+ cors = false,
4328
+ streaming = false,
4229
4329
  rateLimit = false,
4230
4330
  cssPath = false,
4231
4331
  registry = defaultRegistry,
@@ -4236,15 +4336,18 @@ export function createAppFetchHandler(
4236
4336
  const corsOptions: CorsOptions | false = cors === true ? {} : cors;
4237
4337
  const rateLimitOptions = normalizeRateLimitOptions(rateLimit);
4238
4338
 
4239
- registry.settings = {
4240
- isDev: false,
4241
- bundleManifest,
4242
- rootDir,
4243
- publicDir: "public",
4244
- cors: corsOptions,
4245
- streaming,
4246
- rateLimit: rateLimitOptions,
4247
- cssPath,
4339
+ registry.settings = {
4340
+ isDev: false,
4341
+ bundleManifest,
4342
+ rootDir,
4343
+ publicDir: "public",
4344
+ clientBundleHealthy,
4345
+ clientBundleFailureReason,
4346
+ clientBundleFailureRouteId,
4347
+ cors: corsOptions,
4348
+ streaming,
4349
+ rateLimit: rateLimitOptions,
4350
+ cssPath,
4248
4351
  edge,
4249
4352
  };
4250
4353