@mandujs/core 0.54.30 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.54.30",
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",
@@ -18,6 +18,7 @@ import { describe, expect, test } from "bun:test";
18
18
  import { mkdtemp, rm, writeFile } from "node:fs/promises";
19
19
  import { tmpdir } from "node:os";
20
20
  import path from "node:path";
21
+ import { pathToFileURL } from "node:url";
21
22
  import {
22
23
  _testOnly_generateJsxDevRuntimeShimSource,
23
24
  _testOnly_generateJsxRuntimeShimSource,
@@ -32,10 +33,9 @@ describe("JSX runtime shim sources (issues #322 / #323)", () => {
32
33
  // the import statement, not comment mentions of the path.)
33
34
  expect(source).not.toMatch(/from\s*['"]react\/jsx-dev-runtime['"]/);
34
35
 
35
- // Correct: jsxDEV + Fragment come from bare 'react' (→ _react.js).
36
- expect(source).toMatch(
37
- /import\s*\{\s*jsxDEV\s*,\s*Fragment\s*\}\s*from\s*['"]react['"]/,
38
- );
36
+ // Correct: jsxDEV (+ jsx/jsxs fallback inputs) come from bare 'react'
37
+ // (→ _react.js). Import includes jsxDEV and resolves from "react".
38
+ expect(source).toMatch(/import\s*\{[^}]*\bjsxDEV\b[^}]*\}\s*from\s*['"]react['"]/);
39
39
 
40
40
  // Shim still re-exports what the import map expects.
41
41
  expect(source).toContain("export { jsxDEV, Fragment }");
@@ -83,4 +83,58 @@ describe("JSX runtime shim sources (issues #322 / #323)", () => {
83
83
  await rm(dir, { recursive: true, force: true });
84
84
  }
85
85
  });
86
+
87
+ // #323 re-regression (0.54.30): a SOURCE/static check passed but at RUNTIME
88
+ // `_react.js`'s jsxDEV was `undefined` (jsxDEV is a React dev-only export;
89
+ // a production-built _react.js drops it while keeping jsx/jsxs). The shim
90
+ // re-exported that undefined → "jsxDEV is not a function". Guard the VALUE:
91
+ // build the dev shim against a `react` stub whose jsxDEV is undefined (the
92
+ // exact failing condition) and assert the shim still exposes a callable
93
+ // jsxDEV via its jsx/jsxs fallback.
94
+ test("dev shim exposes a callable jsxDEV even when react.jsxDEV is undefined (runtime value)", async () => {
95
+ const dir = await mkdtemp(path.join(tmpdir(), "mandu-jsx-shim-rt-"));
96
+ try {
97
+ // Stub for bare 'react' = a production-built _react.js: jsx/jsxs are real
98
+ // functions, jsxDEV is missing.
99
+ const reactStub = path.join(dir, "react-stub.js");
100
+ await writeFile(
101
+ reactStub,
102
+ [
103
+ "export const jsx = (type) => ({ type, runtime: 'jsx' });",
104
+ "export const jsxs = (type) => ({ type, runtime: 'jsxs' });",
105
+ "export const Fragment = Symbol.for('react.fragment');",
106
+ "export const jsxDEV = undefined;",
107
+ ].join("\n"),
108
+ "utf-8",
109
+ );
110
+
111
+ // Generate the real shim source, but resolve its bare `react` import to
112
+ // the stub so we can drive the undefined-jsxDEV condition.
113
+ const shimSource = _testOnly_generateJsxDevRuntimeShimSource().replace(
114
+ /from\s*['"]react['"]/g,
115
+ `from ${JSON.stringify(reactStub.replace(/\\/g, "/"))}`,
116
+ );
117
+ const shimSrc = path.join(dir, "_jsx-dev-runtime.src.js");
118
+ await writeFile(shimSrc, shimSource, "utf-8");
119
+
120
+ const result = await Bun.build({
121
+ entrypoints: [shimSrc],
122
+ target: "browser",
123
+ define: { "process.env.NODE_ENV": JSON.stringify("production") },
124
+ });
125
+ expect(result.success).toBe(true);
126
+
127
+ const outPath = path.join(dir, "_jsx-dev-runtime.built.js");
128
+ await writeFile(outPath, await result.outputs[0]!.text(), "utf-8");
129
+ const mod = await import(`${pathToFileURL(outPath).href}?t=${Date.now()}`);
130
+
131
+ // The whole point of #323's re-regression: jsxDEV must be CALLABLE.
132
+ expect(typeof mod.jsxDEV).toBe("function");
133
+ // Fallback routes static-children to jsxs, otherwise jsx.
134
+ expect(mod.jsxDEV("div", {}, undefined, false).runtime).toBe("jsx");
135
+ expect(mod.jsxDEV("div", {}, undefined, true).runtime).toBe("jsxs");
136
+ } finally {
137
+ await rm(dir, { recursive: true, force: true });
138
+ }
139
+ });
86
140
  });
@@ -660,12 +660,24 @@ function generateJsxDevRuntimeShimSource(): string {
660
660
  * Development JSX 변환용
661
661
  *
662
662
  * #323: import map의 'react/jsx-dev-runtime'이 다시 이 셰임을 가리키므로
663
- * 'react/jsx-dev-runtime'에서 import하면 순환 self-import가 되어 jsxDEV가
664
- * undefined가 된다. 대신 'react'(→ _react.js, external 없이 react 전체를
665
- * 인라인하며 jsxDEV/Fragment를 re-export하는 vendor 번들)에서 가져와
666
- * 순환을 끊는다.
663
+ * 'react/jsx-dev-runtime'에서 import하면 순환 self-import가 된다. 대신
664
+ * 'react'(→ _react.js, react 전체를 인라인하는 vendor 번들)에서 가져온다.
665
+ *
666
+ * #323 재발(0.54.30): jsxDEV는 React의 **dev 전용** export다. _react.js가
667
+ * production NODE_ENV로 번들되면 'react/jsx-dev-runtime'이 production 변형으로
668
+ * 해소되어 jsxDEV 값이 undefined가 된다(jsx/jsxs는 둘 다 존재). 그 결과 dev
669
+ * island가 'jsxDEV is not a function'으로 전멸했다. _react.js가 어떤 NODE_ENV로
670
+ * 빌드되든 안전하도록, jsxDEV가 함수가 아니면 jsx/jsxs로 폴백한다(dev 경고만
671
+ * 잃고 렌더는 정상). isStaticChildren일 때는 jsxs로 라우팅한다.
667
672
  */
668
- import { jsxDEV, Fragment } from 'react';
673
+ import { jsx, jsxs, jsxDEV as __manduReactJsxDEV, Fragment } from 'react';
674
+
675
+ const jsxDEV =
676
+ typeof __manduReactJsxDEV === 'function'
677
+ ? __manduReactJsxDEV
678
+ : function jsxDEV(type, config, key, isStaticChildren) {
679
+ return (isStaticChildren ? jsxs : jsx)(type, config, key);
680
+ };
669
681
 
670
682
  // Named exports
671
683
  export { jsxDEV, Fragment };
@@ -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