diff --git a/.changeset/issue-2890-react-typegen-testing-loop.md b/.changeset/issue-2890-react-typegen-testing-loop.md new file mode 100644 index 000000000..74417e35d --- /dev/null +++ b/.changeset/issue-2890-react-typegen-testing-loop.md @@ -0,0 +1,6 @@ +--- +"@fluojs/cli": minor +"@fluojs/react": minor +--- + +Add deterministic React typegen check and watch workflows with versioned artifact diagnostics, atomic writes, stable exit codes, and a documented consumer testing loop. diff --git a/docs/CONTEXT.ko.md b/docs/CONTEXT.ko.md index 07f4375eb..0e6a685c7 100644 --- a/docs/CONTEXT.ko.md +++ b/docs/CONTEXT.ko.md @@ -54,7 +54,7 @@ GraphQL package-surface discoverability는 `packages/graphql/README.ko.md`, [`do React package discoverability는 `packages/react/README.ko.md`, [`docs/reference/package-surface.ko.md`](./reference/package-surface.ko.md), [`docs/reference/package-chooser.ko.md`](./reference/package-chooser.ko.md), [`docs/contracts/release-governance.ko.md`](./contracts/release-governance.ko.md), `examples/react-stable-ssr/README.ko.md`, `examples/react-vite-ssr/README.ko.md`로 나뉜다. Runtime-neutral root는 `ReactServerEntry`, `createReactServerEntry(...)`, `renderReactResponse(...)`를 노출하고, lazy `react-dom/server` `renderToReadableStream(...)`을 통해 stream하며, 명시적인 `bootstrapScripts`, `bootstrapModules`, trusted `bootstrapScriptContent`, `assetMap` hydration metadata를 받는다. `@fluojs/react/vite`는 이미 로드한 Vite manifest를 파싱하고, `@fluojs/react/client`는 request-scoped route snapshot을 사용하는 progressive real-anchor full-document navigation을 제공한다. 명시적으로 불안정한 `@fluojs/react/experimental/rsc` subpath는 root와 stable client에서 분리되고, React, React DOM, 선택한 Flight renderer를 정확히 `19.2.6`으로 고정하며, Web Streams/build capability를 검증하고, `createReactRscManifest(...)`로 client-reference 및 server-to-client module map을 snapshot하고, `createReactFlightResponse(...)`로 application-encoded Flight payload를 반환하며, `createReactServerFunctionRegistry(...)`와 `createReactServerFunctionClient(...)`로 signed Server Function reference 및 bounded JSON action transport를 prototype한다. Flight와 action은 모두 일반 fluo HTTP dispatch를 사용한다. Server Functions에는 explicit POST endpoint, exact origin allowlist, non-simple request marker, application-owned Web Crypto와 32 byte 이상의 secret, serialization/body/result limit, action 또는 guard authorization이 필요하다. 자동 Vite manifest discovery, 임의 inline serialization, 자동 `"use server"` transform/export discovery, built-in renderer/build plugin, file route, client route grammar, SPA document swapping, client data cache, prefetch, Next.js/TanStack/Angular router compatibility는 제공하지 않는다. -React page catalog 및 route diagnostics discoverability는 `packages/react/README.ko.md`, `packages/runtime/README.ko.md`, `packages/cli/README.ko.md`, `packages/studio/README.ko.md`, [`docs/reference/package-surface.ko.md`](./reference/package-surface.ko.md), [`docs/reference/toolchain-contract-matrix.ko.md`](./reference/toolchain-contract-matrix.ko.md)로 나뉜다. `@Path(...)`는 runtime-neutral `react-page` marker를 기록하고, runtime은 authoritative compiled `HandlerDescriptor` 값을 immutable route inspection entry로 투영하며, `createReactPageCatalog(...)`는 HTTP matching, conflict detection, dispatch에 참여하지 않고 marker가 있는 React page만 노출한다. Stable `@fluojs/react/typegen` tooling subpath는 이 catalog를 deterministic path-only TypeScript declaration, absolute href builder, route-bound real-anchor props, 기존 client API에 일반 href string을 resolve하는 typed `push`/`replace` method로 변환하고 versioned route를 거부한다. Adapterless `fluo inspect`는 같은 compiled route를 직렬화하고, Studio는 live/static artifact 양쪽에서 `kind`와 parameter name만 담는 `params` field를 검증하고 표시하며 legacy entry는 일반 HTTP diagnostics로 기본 처리한다. +React page catalog 및 route diagnostics discoverability는 `packages/react/README.ko.md`, `packages/runtime/README.ko.md`, `packages/cli/README.ko.md`, `packages/studio/README.ko.md`, [`docs/contracts/testing-guide.ko.md`](./contracts/testing-guide.ko.md), [`docs/reference/package-surface.ko.md`](./reference/package-surface.ko.md), [`docs/reference/toolchain-contract-matrix.ko.md`](./reference/toolchain-contract-matrix.ko.md)로 나뉜다. `@Path(...)`는 runtime-neutral `react-page` marker를 기록하고, runtime은 authoritative compiled `HandlerDescriptor` 값을 immutable route inspection entry로 투영하며, `createReactPageCatalog(...)`는 HTTP matching, conflict detection, dispatch에 참여하지 않고 marker가 있는 React page만 노출한다. Stable `@fluojs/react/typegen` tooling subpath는 이 catalog를 deterministic path-only TypeScript declaration, absolute href builder, route-bound real-anchor props, 기존 client API에 일반 href string을 resolve하는 typed `push`/`replace` method로 변환하고 versioned route를 거부한다. `fluo typegen --check`는 stable status code를 가진 exact non-mutating CI comparison을 제공하고 bounded `--watch` generation은 coalesced module-directory change를 serialize하며 마지막 valid atomic artifact를 보존한다. Canonical consumer testing loop는 React-specific testing runtime을 추가하지 않고 render-policy unit, real `createTestApp(...)` dispatch, generated-route compile fixture, hydration mismatch reporting, JavaScript-disabled native form coverage를 결합한다. Adapterless `fluo inspect`는 같은 compiled route를 직렬화하고, Studio는 live/static artifact 양쪽에서 `kind`와 parameter name만 담는 `params` field를 검증하고 표시하며 legacy entry는 일반 HTTP diagnostics로 기본 처리한다. Serialization response-ownership discoverability는 `packages/serialization/README.ko.md`, `packages/runtime/README.ko.md`, [`docs/reference/package-surface.ko.md`](./reference/package-surface.ko.md), [`docs/getting-started/migrate-from-nestjs.ko.md`](./getting-started/migrate-from-nestjs.ko.md), [`book/beginner/ch07-serialization.ko.md`](../book/beginner/ch07-serialization.ko.md), package-local `packages/serialization/src/serializer-interceptor.test.ts` unit coverage, 그리고 `packages/runtime/src/application.test.ts`의 runtime request-pipeline coverage로 나뉜다. `@fluojs/serialization`은 decorator-aware response serialization, `excludeExtraneous` 같은 class-level `Expose(...)` option을 표현하는 `ExposeClassOptions`, `TransformFunction`, `serialize(value)`, HTTP `SerializerInterceptor` response-boundary 통합을 담당한다. Runtime은 commit되지 않은 framework-managed 결과를 소유하므로 success response를 쓰기 전에 `SerializerInterceptor`가 이를 shaping할 수 있다. Handler 또는 runtime response helper가 `RequestContext.response`를 commit한 뒤에는 `SerializerInterceptor`가 serialization을 우회하고 `next.handle()`에서 받은 값을 그대로 반환한다. 다른 interceptor는 chain 결과를 계속 변환할 수 있다. 이와 별개로 dispatcher는 commit된 response를 확인하고 두 번째 success-response write를 건너뛴다. Request-facing interceptor behavior는 serialization-to-testing workspace dependency edge를 추가하지 않고 `bootstrapApplication(...)`과 `app.dispatch(...)`를 쓰는 runtime request pipeline에서 계속 검증해야 한다. diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md index 058fdc16a..d37ea1bff 100644 --- a/docs/CONTEXT.md +++ b/docs/CONTEXT.md @@ -54,7 +54,7 @@ GraphQL package-surface discoverability is split across `packages/graphql/README React package discoverability is split across `packages/react/README.md`, [`docs/reference/package-surface.md`](./reference/package-surface.md), [`docs/reference/package-chooser.md`](./reference/package-chooser.md), [`docs/contracts/release-governance.md`](./contracts/release-governance.md), `examples/react-stable-ssr/README.md`, and `examples/react-vite-ssr/README.md`: the runtime-neutral root exposes `ReactServerEntry`, `createReactServerEntry(...)`, and `renderReactResponse(...)`, streams through lazy `react-dom/server` `renderToReadableStream(...)`, and accepts explicit `bootstrapScripts`, `bootstrapModules`, trusted `bootstrapScriptContent`, and `assetMap` hydration metadata. `@fluojs/react/vite` parses already-loaded Vite manifests, and `@fluojs/react/client` provides progressive real-anchor, full-document navigation with request-scoped route snapshots. The explicitly unstable `@fluojs/react/experimental/rsc` subpath is isolated from the root and stable client, pins React, React DOM, and the selected Flight renderer to exact `19.2.6`, validates Web Streams/build capabilities, snapshots client-reference and server-to-client module maps with `createReactRscManifest(...)`, returns application-encoded Flight payloads with `createReactFlightResponse(...)`, and prototypes signed Server Function references plus bounded JSON action transport with `createReactServerFunctionRegistry(...)` and `createReactServerFunctionClient(...)`. Both Flight and actions stay on ordinary fluo HTTP dispatch. Server Functions require an explicit POST endpoint, exact origin allowlist, non-simple request marker, application-owned Web Crypto and 32-byte-or-longer secret, serialization/body/result limits, and action or guard authorization. The package does not provide automatic Vite manifest discovery, arbitrary inline serialization, automatic `"use server"` transforms/export discovery, a built-in renderer/build plugin, file routes, a client route grammar, SPA document swapping, client data caches, prefetch, or Next.js/TanStack/Angular router compatibility. -React page catalog and route diagnostics discoverability is split across `packages/react/README.md`, `packages/runtime/README.md`, `packages/cli/README.md`, `packages/studio/README.md`, [`docs/reference/package-surface.md`](./reference/package-surface.md), and [`docs/reference/toolchain-contract-matrix.md`](./reference/toolchain-contract-matrix.md): `@Path(...)` records a runtime-neutral `react-page` marker, runtime projects authoritative compiled `HandlerDescriptor` values into immutable route inspection entries, and `createReactPageCatalog(...)` exposes only the marked React pages without participating in HTTP matching, conflict detection, or dispatch. The stable `@fluojs/react/typegen` tooling subpath turns that catalog into deterministic path-only TypeScript declarations, absolute href builders, route-bound real-anchor props, and typed `push`/`replace` methods that resolve ordinary href strings into the existing client APIs while rejecting versioned routes. Adapterless `fluo inspect` serializes the same compiled routes, while Studio validates and displays their `kind` and parameter-name-only `params` fields in both live and static artifacts, defaulting legacy entries to ordinary HTTP diagnostics. +React page catalog and route diagnostics discoverability is split across `packages/react/README.md`, `packages/runtime/README.md`, `packages/cli/README.md`, `packages/studio/README.md`, [`docs/contracts/testing-guide.md`](./contracts/testing-guide.md), [`docs/reference/package-surface.md`](./reference/package-surface.md), and [`docs/reference/toolchain-contract-matrix.md`](./reference/toolchain-contract-matrix.md): `@Path(...)` records a runtime-neutral `react-page` marker, runtime projects authoritative compiled `HandlerDescriptor` values into immutable route inspection entries, and `createReactPageCatalog(...)` exposes only the marked React pages without participating in HTTP matching, conflict detection, or dispatch. The stable `@fluojs/react/typegen` tooling subpath turns that catalog into deterministic path-only TypeScript declarations, absolute href builders, route-bound real-anchor props, and typed `push`/`replace` methods that resolve ordinary href strings into the existing client APIs while rejecting versioned routes. `fluo typegen --check` provides exact non-mutating CI comparison with stable status codes, while bounded `--watch` generation serializes coalesced module-directory changes and preserves the last valid atomic artifact. The canonical consumer testing loop combines render-policy units, real `createTestApp(...)` dispatch, generated-route compile fixtures, hydration mismatch reporting, and JavaScript-disabled native form coverage without introducing a React-specific testing runtime. Adapterless `fluo inspect` serializes the same compiled routes, while Studio validates and displays their `kind` and parameter-name-only `params` fields in both live and static artifacts, defaulting legacy entries to ordinary HTTP diagnostics. Serialization response-ownership discoverability is split across `packages/serialization/README.md`, `packages/runtime/README.md`, [`docs/reference/package-surface.md`](./reference/package-surface.md), [`docs/getting-started/migrate-from-nestjs.md`](./getting-started/migrate-from-nestjs.md), [`book/beginner/ch07-serialization.md`](../book/beginner/ch07-serialization.md), package-local `packages/serialization/src/serializer-interceptor.test.ts` unit coverage, and runtime request-pipeline coverage in `packages/runtime/src/application.test.ts`: `@fluojs/serialization` owns decorator-aware response serialization, `ExposeClassOptions` class-level `Expose(...)` options such as `excludeExtraneous`, `TransformFunction`, `serialize(value)`, and HTTP `SerializerInterceptor` response-boundary integration. The runtime owns uncommitted framework-managed results, allowing `SerializerInterceptor` to shape them before the success response is written. Once a handler or runtime response helper commits `RequestContext.response`, `SerializerInterceptor` bypasses serialization and returns the value it received from `next.handle()` unchanged. Other interceptors may still transform the chain result. Independently, the dispatcher sees the committed response and skips a second success-response write. Request-facing interceptor behavior should remain covered through the runtime request pipeline with `bootstrapApplication(...)` and `app.dispatch(...)`, rather than adding a serialization-to-testing workspace dependency edge. diff --git a/docs/contracts/testing-guide.ko.md b/docs/contracts/testing-guide.ko.md index c49c2351b..25f15f91f 100644 --- a/docs/contracts/testing-guide.ko.md +++ b/docs/contracts/testing-guide.ko.md @@ -52,6 +52,24 @@ fluo의 테스트 설정은 런타임 모델과 같습니다. 표준 decorator, `@fluojs/testing`은 `engines.node >=20.0.0`을 선언합니다. Mock helper와 `DeepMocked` type은 Vitest-compatible mock type boundary를 의도적으로 사용합니다. `DeepMocked`는 root `@fluojs/testing` 패키지, `@fluojs/testing/types`, `@fluojs/testing/mock`에서 사용할 수 있습니다. Vitest를 실행하지 않는 소비자는 `@fluojs/testing/app`, `@fluojs/testing/module`, harness subpath 같은 non-mock entrypoint를 우선 사용하세요. +## React Consumer Loop + +React consumer coverage는 synthetic React test runtime을 추가하지 않고 같은 ladder를 확장합니다. + +1. Pure helper를 통해 render policy와 metadata composition을 unit test합니다. +2. Direct page return, missing-renderer diagnostic, DTO, request scope, response ownership을 + `createTestApp({ rootModule })`와 `app.request(...).send()`로 검증합니다. +3. Positive/negative generated-route fixture를 TypeScript로 compile하고 CI에서 non-mutating + `fluo typegen ... --check`를 실행합니다. +4. Aligned hydration과 `onRecoverableError`로 보고되는 의도적인 mismatch를 검증합니다. +5. Production Playwright hydration과 JavaScript-disabled native form submit을 일반 + `POST` → `303` → `GET` path로 실행합니다. + +Evidence는 owning seam에 분산해 유지합니다. `@fluojs/react` unit, `@fluojs/cli` typegen 및 compile fixture, +`@fluojs/testing` request dispatch, official React example의 hydration/browser test가 각각 자기 boundary를 +검증합니다. 일반 fixture가 이미 real HTTP dispatcher와 application renderer를 compose할 수 있으므로 반복 +setup만으로 React-specific helper를 정당화하지 않습니다. + ## 명령어 (Commands) | 명령어 | 용도 | diff --git a/docs/contracts/testing-guide.md b/docs/contracts/testing-guide.md index c27f4dfc4..9ae66c604 100644 --- a/docs/contracts/testing-guide.md +++ b/docs/contracts/testing-guide.md @@ -52,6 +52,24 @@ Keep manual `FrameworkRequest`/`FrameworkResponse` stubs, `makeRequest(...)`, ra `@fluojs/testing` declares `engines.node >=20.0.0`. Its mock helpers and `DeepMocked` type intentionally use a Vitest-compatible mock type boundary. `DeepMocked` is available from the root `@fluojs/testing` package, `@fluojs/testing/types`, and `@fluojs/testing/mock`; consumers that do not run Vitest should prefer non-mock entrypoints such as `@fluojs/testing/app`, `@fluojs/testing/module`, or harness subpaths. +## React Consumer Loop + +React consumer coverage extends the same ladder without adding a synthetic React test runtime: + +1. Unit-test render policies and metadata composition through pure helpers. +2. Cover direct page returns, missing-renderer diagnostics, DTOs, request scopes, and response + ownership with `createTestApp({ rootModule })` and `app.request(...).send()`. +3. Compile positive and negative generated-route fixtures with TypeScript and run non-mutating + `fluo typegen ... --check` in CI. +4. Cover aligned hydration plus a deliberate mismatch reported through `onRecoverableError`. +5. Run production Playwright hydration and a JavaScript-disabled native form submission through the + ordinary `POST` → `303` → `GET` path. + +Keep this evidence distributed at the owning seams: `@fluojs/react` units, `@fluojs/cli` typegen and +compile fixtures, `@fluojs/testing` request dispatch, and the official React example's hydration and +browser tests. Repeated setup alone does not justify a React-specific helper when ordinary fixtures +already compose the real HTTP dispatcher and application renderer. + ## Commands | Command | Use | diff --git a/docs/reference/toolchain-contract-matrix.ko.md b/docs/reference/toolchain-contract-matrix.ko.md index 28c21aee4..265f8f306 100644 --- a/docs/reference/toolchain-contract-matrix.ko.md +++ b/docs/reference/toolchain-contract-matrix.ko.md @@ -27,6 +27,10 @@ | **개발 watch restart** | 생성된 애플리케이션 프로젝트에서 `fluo dev` 실행 | Node 스타터는 기본적으로 fluo가 소유한 restart runner를 사용합니다. 이 runner는 filesystem burst를 debounce하고, restart 전에 content hash를 비교하며, noisy output/cache/editor 경로를 무시하고, 앱 로그 전용 출력·색상 보존·restart clear/header 동작을 일관되게 유지합니다. 이 동작은 true HMR이 아니라 restart-on-watch입니다. Node 디버깅에는 `fluo dev --raw-watch` 또는 `FLUO_DEV_RAW_WATCH=1`로 runtime-native `node --watch` 명령을 복원할 수 있습니다. Bun/Deno/Workers는 `fluo dev` 추상성을 유지하되 runtime-owned watch/reload 동작(`bun --watch src/main.ts`, `deno run --watch --allow-env --allow-net src/main.ts`, `wrangler dev --show-interactive-dev-session=false`)을 기본값으로 사용하고, fluo 소유 restart runner가 필요하면 `fluo dev --runner fluo` 또는 `FLUO_DEV_RUNNER=fluo`를 사용합니다. | | **HTTP starter testing layout** | 생성된 non-Deno HTTP 애플리케이션 starter | 빠른 unit test는 `src/greeting/` 아래에 생성하고, slice/module graph test는 `src/greeting/greeting.slice.test.ts`에 두며, app dispatch test는 `src/app.test.ts`, 기본 e2e 스타일 suite는 `createTestApp({ rootModule })`와 `app.request(...).send()`를 사용하는 `test/app.e2e.test.ts`에 생성합니다. 생성된 Vitest config는 `src/**/*.test.ts`와 `test/**/*.test.ts`를 모두 포함하고, 지원되는 script는 `test`, `test:watch`, `test:cov`, `test:e2e`를 포함합니다. 기존 `src/app.e2e.test.ts` 사용자는 request helper를 바꾸지 않고 해당 test를 `test/app.e2e.test.ts`로 이동할 수 있습니다. | | **React starter verification** | 생성된 `react-vite-ssr` project | `typecheck`, `test`, `build`, `start`가 generated project lifecycle을 검증합니다. `test:browser`는 build된 server를 시작해 streamed HTML, generated manifest asset, warning-free hydration, real-anchor navigation, full-document `router.push(...)` navigation을 검증합니다. | +| **React page type generation** | `fluo typegen --output ` | Runtime log를 suppress한 short-lived generation child에서 selected module을 bootstrap하고 child exit를 기다린 뒤 authoritative compiled `HandlerDescriptor`를 `createReactPageCatalog(...)`로 project하고 versioned artifact를 atomically publish합니다. `CREATE`, `UPDATE`, `UNCHANGED`를 보고하며 identical output은 다시 쓰지 않습니다. | +| **React page type check** | `fluo typegen --output --check` | Target write 없이 같은 bootstrap과 generation을 수행합니다. Exact current byte는 `UNCHANGED`/`0`, missing, stale, malformed, unsupported-version target은 각각 전용 diagnostic과 exit code를 사용합니다. | +| **React page type watch** | `fluo typegen --output --watch` | Ready 전에 generate하고 module directory만 recursively watch하며 100 ms change burst를 coalesce하고 regeneration을 serialize합니다. 자체 output/temp file을 무시하고 generation failure 뒤 마지막 valid artifact를 보존하며 shutdown 또는 setup failure에서 watcher/signal을 해제합니다. | +| **React consumer testing loop** | Vitest + `createTestApp(...)` + TypeScript compile fixture + Playwright | Render-policy unit, direct page/missing-renderer request dispatch, typed route id/param 및 stale generation, aligned/mismatched hydration, production asset, JavaScript-disabled native form fallback을 검증합니다. 기존 fixture가 real package seam을 compose하므로 React-specific testing helper는 제공하지 않습니다. | | **비-Node production lifecycle** | 생성된 Bun, Deno, Cloudflare Workers package script | Bun 생성 프로젝트는 `dev: fluo dev`를 유지한 뒤 `bun build ./src/main.ts --outdir ./dist --target bun`으로 빌드하고 `bun dist/main.js`로 시작합니다. Deno 생성 프로젝트는 `dev: fluo dev`를 유지한 뒤 `deno compile --allow-env --allow-net --output dist/app src/main.ts`로 빌드하고 `./dist/app`을 실행합니다. Cloudflare Workers 생성 프로젝트는 `dev: fluo dev`를 유지하고, `wrangler deploy --dry-run`으로 빌드 검증하며, `preview: wrangler dev --remote --show-interactive-dev-session=false`, `deploy: wrangler deploy`를 노출하고, Wrangler native publish flow를 사용하도록 의도적으로 `start`를 생략합니다. | | **리소스 생성** | `fluo g ` | 일관된 명명 접미사 (`.service.ts`, `.controller.ts`) 산출. Request DTO는 `fluo g req users CreateUser`처럼 명시적 feature 디렉터리를 대상으로 지정할 수 있습니다. `fluo g module User --with-test`는 `src/users/user.slice.test.ts`를 생성하고, `fluo g resource User --with-slice-test`는 resource-level provider override coverage를 `src/users/user.slice.test.ts`에 생성하며, `fluo g e2e users`는 `createTestApp({ rootModule })` 기반 `test/users.e2e.test.ts`를 생성합니다. | | **진단 (JSON)** | `fluo inspect --json` | 런타임이 생산한 graph, readiness, health, diagnostics, compiled route inspection data를 JSON 형식으로 내보냅니다. Route entry는 effective path/version과 parameter name을 포함하며 React page는 `kind: 'react-page'`, 일반 handler는 `kind: 'http'`를 사용합니다. 출력 모드를 고르지 않으면 JSON이 기본 출력 모드입니다. `--timing`은 명시적인 `--json` flag 유무와 관계없이 snapshot 옆에 bootstrap timing diagnostics를 포함할 수 있습니다. | @@ -34,6 +38,35 @@ | **진단 report** | `fluo inspect --report --output artifacts/inspect-report.json` | 안정적인 요약, 런타임이 생산한 snapshot, diagnostics, bootstrap timing을 포함하는 CI/support triage JSON report를 씁니다. `--output `는 명시적 artifact 경로이며 inspection이 애플리케이션 write를 소유하게 만들지 않습니다. | | **진단 (Mermaid)** | `fluo inspect --mermaid` | snapshot-to-Mermaid 렌더링을 선택적 `@fluojs/studio` 계약에 위임합니다. CLI는 Studio renderer를 로드하고 Mermaid text를 stdout 또는 `--output `에 쓰며, 그래프 렌더링 의미론을 소유하지 않습니다. | +## React typegen artifact 및 process contract + +Generated source는 현재 `@fluojs/react/typegen` artifact version으로 시작하고 completion marker로 끝납니다. +Check mode는 missing target을 parsing 전에 분류하고 exact byte를 unchanged, incomplete current artifact를 +malformed, 다른 recognized version을 unsupported, byte가 다른 complete current artifact를 stale로 분류합니다. +Check mode와 failed generation은 target을 쓰지 않습니다. + +Default write, check, watch generation은 application과 tooling namespace를 하나의 short-lived child에서 +함께 import하고, child exit를 기다리며, 완료 전에 parent의 IPC/error/exit listener를 제거합니다. 따라서 +반복 watch 실행에서 loader와 native module graph lifetime이 bounded합니다. 명시적인 programmatic +`loadReactTypegenModules` override는 compatibility 예외입니다. Generation은 caller process에 남고 +TypeScript 및 native input에 caller가 제공한 namespace를 사용합니다. + +| mode/result | stdout | stderr | exit code | target mutation | +| --- | --- | --- | ---: | --- | +| write `CREATE` / `UPDATE` / `UNCHANGED` | ` ` | 없음 | `0` | Atomic create/update; unchanged는 write를 생략합니다. | +| check `UNCHANGED` | `UNCHANGED ` | 없음 | `0` | 없음. | +| check `MISSING` | 없음 | `MISSING : ` | `2` | 없음. | +| check `STALE` | 없음 | `STALE : ` | `3` | 없음. | +| check `MALFORMED` | 없음 | `MALFORMED : ` | `4` | 없음. | +| check `UNSUPPORTED_VERSION` | 없음 | `UNSUPPORTED_VERSION : ` | `5` | 없음. | +| command/setup/bootstrap/filesystem failure | 없음 | failure message | `1` | Partial artifact를 publish하지 않습니다. | +| watch ready/regeneration | initial action과 startup-buffered action, `WATCHING `, 이후 action | recoverable `ERROR : ` | signal shutdown은 `0`, watcher/command failure는 `1` | Watcher는 startup generation 전에 활성화되고 buffered rerun 뒤 readiness를 보고합니다. Complete atomic generation만 publish하며 failed generation은 마지막 valid file을 보존합니다. | + +현재 version의 check target은 complete body를 canonical generator grammar로 재현할 수 있어야 하며 그렇지 +않으면 `MALFORMED`입니다. 이전 catalog 때문에 차이가 나는 complete generated artifact는 `STALE`입니다. +Watch generation은 source scanner나 별도 route discovery path를 추가하지 않고 현재 native `.js`와 `.mjs` +dependency graph를 평가합니다. + ## inspect artifact output contract `fluo inspect`는 한 번에 하나의 주요 artifact 출력 모드만 지원합니다. 가능한 모드는 `--json`, `--mermaid`, `--report`입니다. `--timing`은 JSON 출력에 bootstrap timing diagnostics를 덧붙이며, 명시적인 출력 모드를 고르지 않으면 JSON을 기본값으로 사용합니다. `--output `는 선택된 payload를 요청한 경로에 쓰고, 필요한 parent directory를 생성하며, 해당 payload의 terminal 출력을 생략합니다. `--output`이 없으면 선택된 payload를 stdout에 쓰므로 CI artifact용 shell redirection도 계속 유효합니다. diff --git a/docs/reference/toolchain-contract-matrix.md b/docs/reference/toolchain-contract-matrix.md index 10fe9d495..95dece89a 100644 --- a/docs/reference/toolchain-contract-matrix.md +++ b/docs/reference/toolchain-contract-matrix.md @@ -27,6 +27,10 @@ | **Development watch restart** | `fluo dev` in a generated application project | Uses the fluo-owned restart runner by default for Node starters. The runner debounces filesystem bursts, hashes content before restart, ignores noisy output/cache/editor paths, and keeps app-log-only output, color preservation, and restart clear/header behavior consistent. This is restart-on-watch, not true HMR. `fluo dev --raw-watch` or `FLUO_DEV_RAW_WATCH=1` restores the runtime-native `node --watch` command for Node debugging. Bun/Deno/Workers keep the `fluo dev` abstraction but default to runtime-owned watch/reload behavior (`bun --watch src/main.ts`, `deno run --watch --allow-env --allow-net src/main.ts`, or `wrangler dev --show-interactive-dev-session=false`); use `fluo dev --runner fluo` or `FLUO_DEV_RUNNER=fluo` when those projects need the fluo-owned restart runner instead. | | **HTTP starter testing layout** | Generated non-Deno HTTP application starters | Generates fast unit tests under `src/greeting/`, a slice/module graph test at `src/greeting/greeting.slice.test.ts`, app dispatch tests in `src/app.test.ts`, and the default e2e-style suite at `test/app.e2e.test.ts` using `createTestApp({ rootModule })` plus `app.request(...).send()`. Generated Vitest config includes both `src/**/*.test.ts` and `test/**/*.test.ts`, while supported scripts include `test`, `test:watch`, `test:cov`, and `test:e2e`. Existing `src/app.e2e.test.ts` users can move those tests to `test/app.e2e.test.ts` without changing the request helper. | | **React starter verification** | Generated `react-vite-ssr` project | `typecheck`, `test`, `build`, and `start` cover the generated project lifecycle. `test:browser` starts the built server and verifies streamed HTML, generated manifest assets, warning-free hydration, real-anchor navigation, and full-document `router.push(...)` navigation. | +| **React page type generation** | `fluo typegen --output ` | Bootstraps the selected module with runtime logs suppressed in a short-lived generation child, waits for child exit, projects authoritative compiled `HandlerDescriptor` values through `createReactPageCatalog(...)`, and atomically publishes a versioned artifact. Reports `CREATE`, `UPDATE`, or `UNCHANGED`; identical output is not rewritten. | +| **React page type check** | `fluo typegen --output --check` | Performs the same bootstrap and generation without target writes. Exact current bytes return `UNCHANGED`/`0`; missing, stale, malformed, and unsupported-version targets have dedicated diagnostics and exit codes. | +| **React page type watch** | `fluo typegen --output --watch` | Generates before readiness, recursively watches only the module directory, coalesces 100 ms change bursts, serializes regeneration, ignores its own output/temp files, preserves the last valid artifact after generation failure, and releases watchers/signals on shutdown or setup failure. | +| **React consumer testing loop** | Vitest + `createTestApp(...)` + TypeScript compile fixtures + Playwright | Covers render-policy units, direct page/missing-renderer request dispatch, typed route ids/params and stale generation, aligned and mismatched hydration, production assets, and JavaScript-disabled native form fallback. Existing fixtures compose real package seams; no React-specific testing helper is provided. | | **Non-Node production lifecycle** | Generated Bun, Deno, and Cloudflare Workers package scripts | Bun generated projects keep `dev: fluo dev`, then build with `bun build ./src/main.ts --outdir ./dist --target bun` and start with `bun dist/main.js`. Deno generated projects keep `dev: fluo dev`, then build with `deno compile --allow-env --allow-net --output dist/app src/main.ts` and start with `./dist/app`. Cloudflare Workers generated projects keep `dev: fluo dev`, build with `wrangler deploy --dry-run`, expose `preview: wrangler dev --remote --show-interactive-dev-session=false`, expose `deploy: wrangler deploy`, and intentionally omit `start` so deployment uses Wrangler's native publish flow. | | **Resource Generation** | `fluo g ` | Produces consistent naming suffixes (`.service.ts`, `.controller.ts`). Request DTOs may target an explicit feature directory with `fluo g req users CreateUser`. `fluo g module User --with-test` emits `src/users/user.slice.test.ts`, `fluo g resource User --with-slice-test` emits resource-level provider override coverage in `src/users/user.slice.test.ts`, and `fluo g e2e users` emits `test/users.e2e.test.ts` with `createTestApp({ rootModule })`. | | **Diagnostics (JSON)** | `fluo inspect --json` | Exports runtime-produced graph, readiness, health, diagnostics, and compiled route inspection data in JSON format. Route entries contain effective path/version and parameter names, with `kind: 'react-page'` for React pages and `kind: 'http'` for ordinary handlers. JSON is also the default output mode when no output mode is selected. `--timing` may be used with or without an explicit `--json` flag to include bootstrap timing diagnostics next to the snapshot. | @@ -34,6 +38,36 @@ | **Diagnostics report** | `fluo inspect --report --output artifacts/inspect-report.json` | Writes a CI/support triage JSON report containing a stable summary, the runtime-produced snapshot, diagnostics, and bootstrap timing. `--output ` is an explicit artifact path and does not make inspection own application writes. | | **Diagnostics (Mermaid)** | `fluo inspect --mermaid` | Delegates snapshot-to-Mermaid rendering to the optional `@fluojs/studio` contract. The CLI loads Studio's renderer, writes the Mermaid text to stdout or `--output `, and does not own graph rendering semantics. | +## React typegen artifact and process contract + +Generated source starts with the current `@fluojs/react/typegen` artifact version and ends with a +completion marker. Check mode classifies a missing target before parsing, exact bytes as unchanged, +an incomplete current artifact as malformed, a different recognized version as unsupported, and a +complete current artifact with different bytes as stale. Neither check mode nor a failed generation +writes the target. + +Default write, check, and watch generations co-import the application and tooling namespaces in one +short-lived child, wait for its exit, and remove parent IPC/error/exit listeners before completing. +This bounds loader and native module-graph lifetime across repeated watch runs. The explicit +programmatic `loadReactTypegenModules` override is the compatibility exception: generation remains +in the caller process and uses those supplied namespaces for TypeScript and native inputs. + +| mode/result | stdout | stderr | exit code | target mutation | +| --- | --- | --- | ---: | --- | +| write `CREATE` / `UPDATE` / `UNCHANGED` | ` ` | none | `0` | Atomic create/update; unchanged skips the write. | +| check `UNCHANGED` | `UNCHANGED ` | none | `0` | Never. | +| check `MISSING` | none | `MISSING : ` | `2` | Never. | +| check `STALE` | none | `STALE : ` | `3` | Never. | +| check `MALFORMED` | none | `MALFORMED : ` | `4` | Never. | +| check `UNSUPPORTED_VERSION` | none | `UNSUPPORTED_VERSION : ` | `5` | Never. | +| command/setup/bootstrap/filesystem failure | none | failure message | `1` | No partial artifact is published. | +| watch ready/regeneration | initial action and any startup-buffered action, `WATCHING `, then actions | recoverable `ERROR : ` | `0` on signal shutdown; `1` on watcher/command failure | The watcher is active before startup generation and readiness follows its buffered rerun. Only complete atomic generations publish; failed generations preserve the last valid file. | + +A current-version check target is `MALFORMED` unless its complete body can be reproduced by the +canonical generator grammar. A complete generated artifact that differs only because its catalog is +older is `STALE`. Watch generations evaluate current native `.js` and `.mjs` dependency graphs +without adding a source scanner or another route discovery path. + ## inspect artifact output contract `fluo inspect` supports exactly one primary artifact output mode at a time: `--json`, `--mermaid`, or `--report`. `--timing` augments JSON output with bootstrap timing diagnostics and defaults to JSON when no explicit output mode is selected. `--output ` writes the selected payload to the requested path, creating parent directories when needed, and omits terminal output for that payload. Without `--output`, the selected payload is written to stdout so shell redirection remains valid for CI artifacts. diff --git a/examples/react-vite-ssr/README.ko.md b/examples/react-vite-ssr/README.ko.md index 4d260e7c6..af763f42c 100644 --- a/examples/react-vite-ssr/README.ko.md +++ b/examples/react-vite-ssr/README.ko.md @@ -51,6 +51,22 @@ non-200 response, hydration warning/error, identifier-prefix mismatch, hydrate URL과 server-rendered route state가 일치하지 않는 client navigation, `POST` → `303` → `GET` flow를 완료하지 못하는 native form이 있으면 실패합니다. +## canonical consumer test map + +이 예제는 canonical React consumer loop의 바깥쪽 절반을 담당하고 package 및 CLI fixture는 더 작은 unit과 +generated type을 검증합니다. + +| layer | executable evidence | +| --- | --- | +| Render-policy unit | `packages/react/src/render-policy.test.ts`가 composition과 diagnostic을 직접 검증합니다. | +| Real request dispatch | `src/app.test.ts`가 `createTestApp(...)`로 direct page return, DTO failure, guard/interceptor behavior, native mutation response를 검증합니다. | +| Generated-route compile/check | `packages/cli/src/commands/typegen-navigation.test.ts`가 positive/negative route-id/params fixture를 compile하고 `typegen.test.ts`가 non-mutating stale check를 검증합니다. | +| Hydration | `src/hydration.test.ts`가 warning-free interaction과 `onRecoverableError` 기반 mismatch reporting을 모두 검증합니다. | +| Production 및 no JavaScript | `tests/production-hydration.spec.ts`가 build asset과 hydration을 검증한 뒤 `javaScriptEnabled: false`로 native form을 submit합니다. | + +React-specific testing helper는 추가하지 않습니다. 일반 fixture가 반복 setup을 제거하는 동안 +`createTestApp(...)`, React DOM, TypeScript, Playwright가 real ownership boundary를 계속 실행합니다. + ## native form mutation workflow `ProductDocument`는 label, required input, submit button, 일반 route action, 명시적인 multipart encoding을 @@ -111,7 +127,7 @@ examples/react-vite-ssr/ │ ├── entry-client.ts # Browser-only hydrateRoot(...) entry │ ├── entry-server.ts # 명시적 Vite server-entry selector │ ├── hydration.ts # server/client 공유 identifierPrefix -│ ├── hydration.test.ts # DOM-equivalent hydration interaction 및 warning 검증 +│ ├── hydration.test.ts # Aligned interaction 및 recoverable mismatch reporting │ ├── main.ts # 생성된 manifest를 로드하고 Fastify 시작 │ ├── page.ts # 공유 document, native form, client router, interactive counter │ └── recommendations.ts # Lazy Suspense content diff --git a/examples/react-vite-ssr/README.md b/examples/react-vite-ssr/README.md index 14b633890..27b5221fe 100644 --- a/examples/react-vite-ssr/README.md +++ b/examples/react-vite-ssr/README.md @@ -53,6 +53,23 @@ missing or non-200 bootstrap/style assets, hydration warnings or errors, an iden mismatch, a counter that does not hydrate, client navigation whose URL and server-rendered route state do not agree, or a native form that cannot complete its `POST` → `303` → `GET` flow. +## canonical consumer test map + +This example is the outer half of the canonical React consumer loop, while package and CLI fixtures +cover the smaller units and generated types: + +| layer | executable evidence | +| --- | --- | +| Render-policy unit | `packages/react/src/render-policy.test.ts` covers composition and diagnostics directly. | +| Real request dispatch | `src/app.test.ts` uses `createTestApp(...)` for a direct page return, DTO failures, guard/interceptor behavior, and native mutation responses. | +| Generated-route compile/check | `packages/cli/src/commands/typegen-navigation.test.ts` compiles positive and negative route-id/params fixtures; `typegen.test.ts` covers non-mutating stale checks. | +| Hydration | `src/hydration.test.ts` covers both warning-free interaction and mismatch reporting through `onRecoverableError`. | +| Production and no JavaScript | `tests/production-hydration.spec.ts` verifies built assets and hydration, then submits the native form with `javaScriptEnabled: false`. | + +No React-specific testing helper is added. Ordinary fixtures remove repeated setup while +`createTestApp(...)`, React DOM, TypeScript, and Playwright continue to exercise the real ownership +boundaries. + ## native form mutation workflow `ProductDocument` renders a real form with a label, required input, submit button, ordinary route @@ -114,7 +131,7 @@ examples/react-vite-ssr/ │ ├── entry-client.ts # Browser-only hydrateRoot(...) entry │ ├── entry-server.ts # Explicit Vite server-entry selector │ ├── hydration.ts # Shared server/client identifierPrefix -│ ├── hydration.test.ts # DOM-equivalent hydration interaction and warning check +│ ├── hydration.test.ts # Aligned interaction and recoverable mismatch reporting │ ├── main.ts # Loads the generated manifest and starts Fastify │ ├── page.ts # Shared document, native form, client router, and interactive counter │ └── recommendations.ts # Lazy Suspense content diff --git a/examples/react-vite-ssr/src/app.test.ts b/examples/react-vite-ssr/src/app.test.ts index d488ca598..280e505a3 100644 --- a/examples/react-vite-ssr/src/app.test.ts +++ b/examples/react-vite-ssr/src/app.test.ts @@ -1,4 +1,7 @@ +import { Module } from '@fluojs/core'; +import { Path, ReactModule, Router } from '@fluojs/react'; import { createTestApp } from '@fluojs/testing'; +import { createElement } from 'react'; import { describe, expect, it } from 'vitest'; import { createReactViteExampleModule } from './app'; @@ -29,6 +32,42 @@ function readHtml(body: unknown): string { } describe('react-vite-ssr example', () => { + it('reports a missing application page renderer through real request dispatch', async () => { + // Given: an explicit React page returns one element without configuring renderPage. + const diagnostics: string[] = []; + + @Router('/missing-renderer') + class MissingRendererRouter { + @Path('/') + show() { + return createElement('main', null, 'Missing renderer'); + } + } + + @Module({ + imports: [ReactModule.forRoot({ + controllers: [MissingRendererRouter], + onDiagnostic(diagnostic) { + diagnostics.push(diagnostic.code); + }, + })], + }) + class MissingRendererModule {} + + const app = await createTestApp({ rootModule: MissingRendererModule }); + + try { + // When: the virtual HTTP client dispatches the page request. + const response = await app.request('GET', '/missing-renderer/').send(); + + // Then: HTTP owns the failure response and React emits its stable configuration diagnostic. + expect(response.status).toBe(500); + expect(diagnostics).toEqual(['react-ssr-missing-page-renderer']); + } finally { + await app.close(); + } + }); + it('streams a DTO-bound page with Vite hydration assets', async () => { // Given: a fluo React module backed by a loaded Vite manifest. const AppModule = createReactViteExampleModule({ diff --git a/examples/react-vite-ssr/src/hydration.test.ts b/examples/react-vite-ssr/src/hydration.test.ts index 88263f8b0..ab13b1b28 100644 --- a/examples/react-vite-ssr/src/hydration.test.ts +++ b/examples/react-vite-ssr/src/hydration.test.ts @@ -32,4 +32,23 @@ describe('react-vite-ssr hydration', () => { expect(consoleError).not.toHaveBeenCalled(); root.unmount(); }); + + it('reports a server and client render mismatch through React hydration diagnostics', async () => { + // Given: the client tree does not reproduce the server-rendered text. + const recoverableErrors: unknown[] = []; + const container = document.createElement('div'); + container.innerHTML = renderToString(createElement('p', null, 'Server catalog')); + document.body.append(container); + + // When: React hydrates the mismatched client tree. + const root = hydrateRoot(container, createElement('p', null, 'Client catalog'), { + onRecoverableError(error) { + recoverableErrors.push(error); + }, + }); + + // Then: the application-owned hydration boundary receives the mismatch report. + await vi.waitFor(() => expect(recoverableErrors.length).toBeGreaterThan(0)); + root.unmount(); + }); }); diff --git a/packages/cli/README.ko.md b/packages/cli/README.ko.md index 648a0e9ba..933f1a50b 100644 --- a/packages/cli/README.ko.md +++ b/packages/cli/README.ko.md @@ -356,13 +356,42 @@ builder를 생성합니다. ```bash fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts fluo typegen ./src/admin.ts --export AdminModule --output ./src/generated/admin-pages.ts +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --check +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --watch ``` `--export` 기본값은 `AppModule`입니다. 명령은 CLI loader로 TypeScript source를 로드하고 application을 bootstrap한 다음 `app.dispatcher.describeRoutes()`를 읽어 `createReactPageCatalog(...)`와 `generateReactPageTypes(...)`를 호출하고 application을 닫습니다. Output path는 현재 working directory를 기준으로 resolve됩니다. 파일이 없으면 `CREATE`, content가 stale하면 `UPDATE`, byte 단위로 -같으면 `UNCHANGED`를 보고합니다. +같으면 `UNCHANGED`를 보고합니다. Write는 complete temporary file 하나를 atomic rename으로 publish하며 +`UNCHANGED`는 target을 다시 쓰지 않습니다. + +Default generation은 application과 일치하는 tooling namespace를 하나의 short-lived child process에서 +평가하고, 해당 process가 종료된 뒤에만 결과를 check하거나 publish합니다. 따라서 반복되는 watch +generation이 watcher process에 application module graph나 TypeScript loader resource를 유지하지 않습니다. +Programmatic caller가 `TypegenCommandRuntimeOptions.loadReactTypegenModules`를 제공하면 의도적으로 caller +process에서 generation하며, 반환된 namespace가 TypeScript, `.js`, `.mjs` input 모두에 authoritative합니다. + +`--check`는 같은 authoritative bootstrap과 generation을 수행하지만 target을 쓰지 않습니다. Exact byte를 +비교해 stable status 하나를 보고합니다. `UNCHANGED`는 stdout과 exit code `0`, `MISSING`, `STALE`, +`MALFORMED`, `UNSUPPORTED_VERSION`은 stderr와 각각 exit code `2`, `3`, `4`, `5`를 사용합니다. Argument, +현재 version의 target은 complete canonical generated body여야 합니다. Syntax 또는 structure가 손상되면 +`MALFORMED`, 이전 catalog에서 생성된 complete artifact이면 `STALE`입니다. Argument, bootstrap, +generation, filesystem 및 그 밖의 command failure는 exit code `1`을 사용합니다. Programmatic caller는 +root package가 export하는 `TYPEGEN_EXIT_CODES`를 사용할 수 있습니다. + +`--watch`는 bounded development integration입니다. CLI는 startup generation 전에 application module +directory의 recursive watcher를 설치합니다. 해당 generation과 그 실행 중 관찰된 change를 한 번으로 +coalesce한 rerun이 모두 성공한 뒤에만 `WATCHING `를 출력합니다. Readiness 이후 filesystem +burst는 100 ms 동안 coalesce되고 generation은 serialize되며 output과 그 temporary file event는 +무시됩니다. 각 generation은 authoritative bootstrap 전에 변경된 native `.js`와 `.mjs` dependency를 +포함한 현재 application module graph를 평가합니다. Regeneration failure는 `ERROR : `를 +출력하고 마지막 valid artifact를 보존한 채 다음 change를 기다립니다. Watcher failure는 cleanup 뒤 code +`1`로 종료됩니다. `SIGINT`와 `SIGTERM`은 watcher를 닫고 signal handler를 제거하며 active generation을 +기다린 뒤 code `0`으로 종료됩니다. Module directory 밖의 파일은 의도적으로 watch boundary 밖에 +있습니다. Source scanner나 두 번째 route discovery system을 기대하지 말고 command를 다시 실행하거나 +의도한 source root의 module path를 선택하세요. 생성된 `reactPageRoutes` object는 stable catalog `id`를 key로 사용합니다. Dynamic `href(...)`, `link(...)`, `push(...)`, `replace(...)` method는 모든 path param을 요구하고 각 값을 URI-encode하며 @@ -406,10 +435,11 @@ Catalog만으로는 URI versioning과 header, media-type, custom version strateg | `runInspectCommand(argv, options?)` | inspect orchestration, compiled route JSON/report emission, Studio Mermaid delegation에 대한 프로그래밍적 접근을 제공합니다. | | `InspectCommandRuntimeOptions` | cwd, stream, prompt, Studio renderer loading 같은 `runInspectCommand(...)`와 `runCli(...)` inspect runtime override 타입입니다. | | `typegenUsage()` | help surface와 test에서 사용하는 현재 `fluo typegen` usage text를 반환합니다. | -| `runTypegenCommand(argv, options?)` | bootstrap-resolved React page type generation과 deterministic artifact write에 대한 프로그래밍적 접근을 제공합니다. | -| `TypegenCommandRuntimeOptions` | cwd, stream, tooling module loading 같은 `runTypegenCommand(...)`와 `runCli(...)` typegen runtime override 타입입니다. | +| `TYPEGEN_EXIT_CODES` | Typegen automation이 사용하는 stable `SUCCESS`, `ERROR`, `MISSING`, `STALE`, `MALFORMED`, `UNSUPPORTED_VERSION` process code입니다. | +| `runTypegenCommand(argv, options?)` | bootstrap-resolved React page generation, non-mutating check, bounded watch mode에 대한 프로그래밍적 접근을 제공합니다. | +| `TypegenCommandRuntimeOptions` | cwd, stream, tooling module loading 같은 `runTypegenCommand(...)`와 `runCli(...)` typegen runtime override 타입입니다. `loadReactTypegenModules`를 제공하면 해당 namespace를 사용하는 caller-process generation을 선택하고, 생략하면 short-lived generation child를 사용합니다. | -프로그래밍 방식 진입점은 호출자 프로세스의 소유권을 보존합니다. `runCli(...)`, `runNewCommand(...)`, `runInspectCommand(...)`, `runTypegenCommand(...)`는 `process.exit(...)`를 호출하지 않고 숫자 exit code를 반환하며, prompt 취소는 command runner를 통해 exit code `0`으로 해석됩니다. dependency 설치나 git 초기화 같은 setup 작업은 해석된 `fluo new` 옵션이 요청한 경우에만 실행됩니다. `runGenerateCommand(...)`는 구조화된 `GenerateResult`를 반환합니다. 파일 쓰기 없이 생성 파일과 module-wiring action을 미리 보려면 `dryRun: true`를 전달하세요. 호출자가 제공한 prompt hook은 공개 패키지 엔트리포인트의 `CliPromptCancelledError`를 throw해 CLI 내부 파일에 의존하지 않고 정상 취소를 표현할 수 있습니다. +프로그래밍 방식 진입점은 호출자 프로세스의 소유권을 보존합니다. `runCli(...)`, `runNewCommand(...)`, `runInspectCommand(...)`, `runTypegenCommand(...)`는 `process.exit(...)`를 호출하지 않고 숫자 exit code를 반환하며 typegen caller는 그 결과를 `TYPEGEN_EXIT_CODES`와 비교할 수 있습니다. Prompt 취소는 command runner를 통해 exit code `0`으로 해석됩니다. dependency 설치나 git 초기화 같은 setup 작업은 해석된 `fluo new` 옵션이 요청한 경우에만 실행됩니다. `runGenerateCommand(...)`는 구조화된 `GenerateResult`를 반환합니다. 파일 쓰기 없이 생성 파일과 module-wiring action을 미리 보려면 `dryRun: true`를 전달하세요. 호출자가 제공한 prompt hook은 공개 패키지 엔트리포인트의 `CliPromptCancelledError`를 throw해 CLI 내부 파일에 의존하지 않고 정상 취소를 표현할 수 있습니다. ## 관련 패키지 diff --git a/packages/cli/README.md b/packages/cli/README.md index 8edb54f2f..70d92af93 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -356,13 +356,44 @@ bootstrap-resolved route catalog: ```bash fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts fluo typegen ./src/admin.ts --export AdminModule --output ./src/generated/admin-pages.ts +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --check +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --watch ``` `--export` defaults to `AppModule`. The command loads TypeScript source through the CLI loader, bootstraps the application, reads `app.dispatcher.describeRoutes()`, calls `createReactPageCatalog(...)` and `generateReactPageTypes(...)`, then closes the application. Output paths are resolved from the current working directory. A missing file is reported as `CREATE`, stale -content as `UPDATE`, and byte-identical content as `UNCHANGED`. +content as `UPDATE`, and byte-identical content as `UNCHANGED`. Writes publish one complete temporary +file with an atomic rename, and `UNCHANGED` never rewrites the target. + +Default generation evaluates the application and matching tooling namespaces in one short-lived +child process, waits for that process to exit, and only then checks or publishes the result. Repeated +watch generations therefore do not retain application module graphs or TypeScript loader resources +in the watcher process. Programmatic callers that provide +`TypegenCommandRuntimeOptions.loadReactTypegenModules` intentionally keep generation in the caller +process; the returned namespaces remain authoritative for TypeScript, `.js`, and `.mjs` inputs. + +`--check` performs the same authoritative bootstrap and generation but never writes the target. It +compares exact bytes and reports one stable status. `UNCHANGED` goes to stdout with exit code `0`; +`MISSING`, `STALE`, `MALFORMED`, and `UNSUPPORTED_VERSION` go to stderr with exit codes `2`, `3`, `4`, +and `5`. A current-version target must be one complete canonical generated body: syntax or structural +corruption is `MALFORMED`, while a complete artifact from an older catalog is `STALE`. Argument, +bootstrap, generation, filesystem, and other command failures use exit code `1`. The root package +exports `TYPEGEN_EXIT_CODES` for programmatic callers. + +`--watch` is the bounded development integration. The CLI installs the recursive application-module +watcher before startup generation. It prints `WATCHING ` only after that generation and +one coalesced rerun for any changes observed while it ran have completed successfully. Filesystem +bursts after readiness are coalesced for 100 ms, generations are serialized, and events for the +output or its temporary files are ignored. Each generation evaluates a current application module +graph, including changed native `.js` and `.mjs` dependencies, before the authoritative bootstrap. +A regeneration failure prints `ERROR : `, preserves the last valid artifact, and +waits for a later change. A watcher failure exits with code `1` after cleanup. `SIGINT` and `SIGTERM` +close the watcher, remove signal handlers, wait for an active generation, and exit with code `0`. +Files outside the module directory are intentionally outside this watch boundary; run the command +again or choose a module path at the intended source root instead of expecting source scanning or a +second route discovery system. The generated `reactPageRoutes` object keys routes by stable catalog `id`. Its dynamic `href(...)`, `link(...)`, `push(...)`, and `replace(...)` methods require all path params and URI-encode each value; @@ -407,10 +438,11 @@ The package can be used programmatically to trigger CLI actions from within othe | `runInspectCommand(argv, options?)` | Programmatic access to inspect orchestration, compiled route JSON/report emission, and Studio Mermaid delegation. | | `InspectCommandRuntimeOptions` | Type for `runInspectCommand(...)` and `runCli(...)` inspect runtime overrides such as cwd, streams, prompts, and Studio renderer loading. | | `typegenUsage()` | Returns the current `fluo typegen` usage text for help surfaces and tests. | -| `runTypegenCommand(argv, options?)` | Programmatic access to bootstrap-resolved React page type generation and deterministic artifact writes. | -| `TypegenCommandRuntimeOptions` | Type for `runTypegenCommand(...)` and `runCli(...)` typegen runtime overrides such as cwd, streams, and tooling module loading. | +| `TYPEGEN_EXIT_CODES` | Stable `SUCCESS`, `ERROR`, `MISSING`, `STALE`, `MALFORMED`, and `UNSUPPORTED_VERSION` process codes used by typegen automation. | +| `runTypegenCommand(argv, options?)` | Programmatic access to bootstrap-resolved React page generation, non-mutating checks, and bounded watch mode. | +| `TypegenCommandRuntimeOptions` | Type for `runTypegenCommand(...)` and `runCli(...)` typegen runtime overrides such as cwd, streams, and tooling module loading. Supplying `loadReactTypegenModules` selects caller-process generation with those namespaces; omitting it uses short-lived generation children. | -Programmatic entry points preserve caller process ownership. `runCli(...)`, `runNewCommand(...)`, `runInspectCommand(...)`, and `runTypegenCommand(...)` return numeric exit codes instead of calling `process.exit(...)`; prompt cancellation resolves as exit code `0` through the command runner, and setup actions such as dependency installation or git initialization only run when the resolved `fluo new` options request them. `runGenerateCommand(...)` returns a structured `GenerateResult`; pass `dryRun: true` to preview generated file and module-wiring actions without writing files. Caller-supplied prompt hooks can throw `CliPromptCancelledError` from the public package entrypoint to express normal cancellation without depending on CLI-internal files. +Programmatic entry points preserve caller process ownership. `runCli(...)`, `runNewCommand(...)`, `runInspectCommand(...)`, and `runTypegenCommand(...)` return numeric exit codes instead of calling `process.exit(...)`; typegen callers can compare those results with `TYPEGEN_EXIT_CODES`. Prompt cancellation resolves as exit code `0` through the command runner, and setup actions such as dependency installation or git initialization only run when the resolved `fluo new` options request them. `runGenerateCommand(...)` returns a structured `GenerateResult`; pass `dryRun: true` to preview generated file and module-wiring actions without writing files. Caller-supplied prompt hooks can throw `CliPromptCancelledError` from the public package entrypoint to express normal cancellation without depending on CLI-internal files. ## Related Packages diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9305a6464..9f51b2fb3 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -9,7 +9,7 @@ import { migrateUsage, runMigrateCommand } from './commands/migrate.js'; import { type NewCommandRuntimeOptions, runNewCommand } from './commands/new.js'; import { addUsage, runAddCommand, runUpgradeCommand, upgradeUsage } from './commands/package-workflow.js'; import { runScriptCommand, scriptUsage } from './commands/scripts.js'; -import { type TypegenCommandRuntimeOptions, runTypegenCommand } from './commands/typegen.js'; +import { runTypegenCommand, type TypegenCommandRuntimeOptions } from './commands/typegen.js'; import { type DevRunnerRuntime, runNodeRestartRunner } from './dev-runner/node-restart-runner.js'; import { builtInGeneratorCollection, generatorManifest, generatorOptionSchemas, resolveGeneratorKind } from './generators/manifest.js'; import { renderAliasList, renderHelpTable } from './help.js'; @@ -146,7 +146,7 @@ const TOP_LEVEL_COMMAND_HELP: TopLevelCommandHelpEntry[] = [ { aliases: [], command: 'add', description: 'Install @fluojs packages with the detected package manager.' }, { aliases: [], command: 'upgrade', description: 'Report latest CLI state and migration workflow guidance.' }, { aliases: [], command: 'inspect', description: 'Inspect runtime platform snapshot/diagnostics and emit timing optionally.' }, - { aliases: [], command: 'typegen', description: 'Generate path-only React page route types and absolute href builders.' }, + { aliases: [], command: 'typegen', description: 'Generate, check, or watch path-only React page route types.' }, { aliases: [], command: 'migrate', description: 'Run NestJS-to-fluo codemods (dry-run by default).' }, { aliases: ['--version', '-v'], command: 'version', description: 'Print the installed fluo CLI version.' }, { aliases: [], command: 'help', description: 'Show top-level or command-specific help.' }, @@ -485,7 +485,7 @@ function parseCommand(argv: string[]): ParsedCommand { * * @param argv Argument vector to execute. Defaults to the current process arguments without the node/bin prefix. * @param runtime Optional runtime overrides shared by the top-level dispatcher and delegated commands. - * @returns `0` when the command completes successfully, otherwise `1` after writing the error message to `stderr`. + * @returns `0` when the command completes successfully, otherwise the delegated command exit code. */ export async function runCli( argv = process.argv.slice(2), diff --git a/packages/cli/src/commands/typegen-artifact.test.ts b/packages/cli/src/commands/typegen-artifact.test.ts new file mode 100644 index 000000000..d81afe8fe --- /dev/null +++ b/packages/cli/src/commands/typegen-artifact.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + type TypegenArtifactFileSystem, + writeTypegenArtifact, +} from './typegen-artifact.js'; + +describe('typegen artifact commits', () => { + it('preserves the last valid artifact when the atomic replacement fails', async () => { + // Given: one valid target and a filesystem that fails only when committing its temporary replacement. + const outputPath = '/project/src/generated/react-pages.ts'; + const files = new Map([[outputPath, 'last valid artifact\n']]); + const commitError = new Error('rename failed'); + const fileSystem: TypegenArtifactFileSystem = { + mkdir: vi.fn(async () => undefined), + readFile: vi.fn(async (path) => { + const content = files.get(path); + if (content === undefined) { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }); + } + return content; + }), + rename: vi.fn(async () => { + throw commitError; + }), + rm: vi.fn(async (path) => { + files.delete(path); + }), + writeFile: vi.fn(async (path, content) => { + files.set(path, content); + }), + }; + + // When: the command prepares a complete replacement but cannot atomically publish it. + const action = writeTypegenArtifact(outputPath, 'next complete artifact\n', fileSystem); + + // Then: the original target remains valid and the temporary file is removed. + await expect(action).rejects.toBe(commitError); + expect(files.get(outputPath)).toBe('last valid artifact\n'); + expect([...files.keys()]).toEqual([outputPath]); + expect(fileSystem.rm).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/commands/typegen-artifact.ts b/packages/cli/src/commands/typegen-artifact.ts new file mode 100644 index 000000000..91e2b0d48 --- /dev/null +++ b/packages/cli/src/commands/typegen-artifact.ts @@ -0,0 +1,132 @@ +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { basename, dirname, join } from 'node:path'; + +/** Filesystem boundary used by deterministic typegen artifact commits. */ +export type TypegenArtifactFileSystem = { + readonly mkdir: (path: string) => Promise; + readonly readFile: (path: string) => Promise; + readonly rename: (source: string, destination: string) => Promise; + readonly rm: (path: string) => Promise; + readonly writeFile: (path: string, content: string) => Promise; +}; + +type ArtifactInspection = + | { readonly status: 'malformed' } + | { readonly status: 'unsupported-version'; readonly version: number } + | { readonly status: 'valid'; readonly version: number }; + +/** Stable non-mutating comparison result for an existing typegen target. */ +export type TypegenArtifactCheck = + | { readonly status: 'MALFORMED' } + | { readonly status: 'MISSING' } + | { readonly status: 'STALE' } + | { readonly status: 'UNCHANGED' } + | { readonly status: 'UNSUPPORTED_VERSION'; readonly version: number }; + +/** Stable write action reported after an atomic typegen artifact commit. */ +export type TypegenArtifactWriteAction = 'CREATE' | 'UNCHANGED' | 'UPDATE'; + +const NODE_FILE_SYSTEM: TypegenArtifactFileSystem = { + async mkdir(path) { + await mkdir(path, { recursive: true }); + }, + readFile: (path) => readFile(path, 'utf8'), + rename, + async rm(path) { + await rm(path, { force: true }); + }, + async writeFile(path, content) { + await writeFile(path, content, { encoding: 'utf8', flag: 'wx' }); + }, +}; + +let temporaryFileSequence = 0; + +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'; +} + +async function readExistingArtifact( + outputPath: string, + fileSystem: TypegenArtifactFileSystem, +): Promise { + try { + return await fileSystem.readFile(outputPath); + } catch (error: unknown) { + if (isMissingFileError(error)) { + return undefined; + } + throw error; + } +} + +/** + * Compares an existing artifact with expected generated bytes without writing. + * + * @param outputPath Target artifact path. + * @param expectedSource Authoritative generated source. + * @param inspect Structural artifact classifier. + * @param fileSystem Filesystem boundary used to read the target. + * @returns A stable unchanged, missing, stale, malformed, or unsupported-version result. + */ +export async function checkTypegenArtifact( + outputPath: string, + expectedSource: string, + inspect: (source: string) => ArtifactInspection, + fileSystem: TypegenArtifactFileSystem = NODE_FILE_SYSTEM, +): Promise { + const existingSource = await readExistingArtifact(outputPath, fileSystem); + if (existingSource === undefined) { + return { status: 'MISSING' }; + } + if (existingSource === expectedSource) { + return { status: 'UNCHANGED' }; + } + + const inspection = inspect(existingSource); + if (inspection.status === 'malformed') { + return { status: 'MALFORMED' }; + } + if (inspection.status === 'unsupported-version') { + return { status: 'UNSUPPORTED_VERSION', version: inspection.version }; + } + return { status: 'STALE' }; +} + +/** + * Atomically publishes generated source while skipping byte-identical rewrites. + * + * @param outputPath Target artifact path. + * @param source Complete generated source to publish. + * @param fileSystem Filesystem boundary used for the atomic replacement. + * @returns The stable create, update, or unchanged action. + */ +export async function writeTypegenArtifact( + outputPath: string, + source: string, + fileSystem: TypegenArtifactFileSystem = NODE_FILE_SYSTEM, +): Promise { + const existingSource = await readExistingArtifact(outputPath, fileSystem); + if (existingSource === source) { + return 'UNCHANGED'; + } + + await fileSystem.mkdir(dirname(outputPath)); + temporaryFileSequence += 1; + const temporaryPath = join( + dirname(outputPath), + `.${basename(outputPath)}.${String(process.pid)}.${String(temporaryFileSequence)}.tmp`, + ); + let committed = false; + try { + await fileSystem.writeFile(temporaryPath, source); + await fileSystem.rename(temporaryPath, outputPath); + committed = true; + } finally { + if (!committed) { + await fileSystem.rm(temporaryPath); + } + } + + return existingSource === undefined ? 'CREATE' : 'UPDATE'; +} diff --git a/packages/cli/src/commands/typegen-generation-child.ts b/packages/cli/src/commands/typegen-generation-child.ts new file mode 100644 index 000000000..3b3d45d87 --- /dev/null +++ b/packages/cli/src/commands/typegen-generation-child.ts @@ -0,0 +1,62 @@ +import type { TypegenGenerationMessage } from './typegen-generation-protocol.js'; +import { createProcessIsolatedTypegenSource } from './typegen-isolated-source.js'; +import type { ParsedTypegenArgs } from './typegen-options.js'; +import { TypegenCommandError } from './typegen-options.js'; + +function readGenerationArgs(): { readonly cwd: string; readonly exportName: string; readonly modulePath: string } { + const [cwd, modulePath, exportName] = process.argv.slice(2); + if (cwd === undefined || modulePath === undefined || exportName === undefined) { + throw new TypegenCommandError('Typegen generation process received incomplete arguments.'); + } + return { cwd, exportName, modulePath }; +} + +function sendGenerationMessage(message: TypegenGenerationMessage): Promise { + if (process.send === undefined) { + throw new TypegenCommandError('Typegen generation process requires an IPC channel.'); + } + const send = process.send.bind(process); + return new Promise((resolve, reject) => { + send(message, (error) => { + if (error === null) { + resolve(); + return; + } + reject(error); + }); + }); +} + +async function runGenerationChild(): Promise { + let message: TypegenGenerationMessage; + try { + const request = readGenerationArgs(); + const parsed: ParsedTypegenArgs = { + check: false, + exportName: request.exportName, + modulePath: request.modulePath, + outputPath: '', + watch: false, + }; + message = { + kind: 'source', + source: await createProcessIsolatedTypegenSource(parsed, request.cwd), + }; + } catch (error: unknown) { + process.exitCode = 1; + message = { + kind: 'error', + message: error instanceof Error ? error.message : String(error), + }; + } + + try { + await sendGenerationMessage(message); + } finally { + if (process.connected) { + process.disconnect(); + } + } +} + +await runGenerationChild(); diff --git a/packages/cli/src/commands/typegen-generation-lifecycle.test.ts b/packages/cli/src/commands/typegen-generation-lifecycle.test.ts new file mode 100644 index 000000000..71231d110 --- /dev/null +++ b/packages/cli/src/commands/typegen-generation-lifecycle.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { + type TypegenGenerationChild, + waitForTypegenGenerationChild, +} from './typegen-generation-process.js'; + +class FakeGenerationChild implements TypegenGenerationChild { + readonly errorListeners = new Set<(error: Error) => void>(); + readonly exitListeners = new Set<(code: number | null, signal: NodeJS.Signals | null) => void>(); + readonly messageListeners = new Set<(message: unknown) => void>(); + + offError(listener: (error: Error) => void): void { + this.errorListeners.delete(listener); + } + + offExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): void { + this.exitListeners.delete(listener); + } + + offMessage(listener: (message: unknown) => void): void { + this.messageListeners.delete(listener); + } + + onError(listener: (error: Error) => void): void { + this.errorListeners.add(listener); + } + + onExit(listener: (code: number | null, signal: NodeJS.Signals | null) => void): void { + this.exitListeners.add(listener); + } + + onMessage(listener: (message: unknown) => void): void { + this.messageListeners.add(listener); + } + + emitExit(code: number | null, signal: NodeJS.Signals | null): void { + for (const listener of this.exitListeners) { + listener(code, signal); + } + } + + emitMessage(message: unknown): void { + for (const listener of this.messageListeners) { + listener(message); + } + } +} + +describe('fluo typegen generation child lifecycle', () => { + it('waits for process exit and removes every completion listener', async () => { + // Given: a generation child that has sent source but has not exited. + const child = new FakeGenerationChild(); + let state: 'pending' | 'resolved' = 'pending'; + const result = waitForTypegenGenerationChild(child).then((source) => { + state = 'resolved'; + return source; + }); + + // When: source arrives before the process exit event. + child.emitMessage({ kind: 'source', source: 'generated source' }); + await Promise.resolve(); + + // Then: completion remains pending until exit and leaves no retained listeners afterward. + expect(state).toBe('pending'); + expect(child.errorListeners.size).toBe(1); + expect(child.exitListeners.size).toBe(1); + expect(child.messageListeners.size).toBe(1); + child.emitExit(0, null); + await expect(result).resolves.toBe('generated source'); + expect(child.errorListeners.size).toBe(0); + expect(child.exitListeners.size).toBe(0); + expect(child.messageListeners.size).toBe(0); + }); +}); diff --git a/packages/cli/src/commands/typegen-generation-process.test.ts b/packages/cli/src/commands/typegen-generation-process.test.ts new file mode 100644 index 000000000..8fb97f496 --- /dev/null +++ b/packages/cli/src/commands/typegen-generation-process.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; +import { runTypegenCommand } from './typegen.js'; + +const fixturesDirectory = join(dirname(fileURLToPath(import.meta.url)), '../fixtures'); +const cliRoot = join(fixturesDirectory, '../..'); +const tempDirectories: string[] = []; + +function applicationSource(markerPath: string): string { + return [ + "import { writeFileSync } from 'node:fs';", + "import { defineModule } from '@fluojs/runtime';", + "import { Path, ReactModule, Router } from '@fluojs/react';", + '', + `writeFileSync(${JSON.stringify(markerPath)}, String(process.pid));`, + "@Router('/products')", + 'export class ProductRouter {', + " @Path('/:productId')", + ' show() {}', + '}', + 'export class AppModule {}', + 'defineModule(AppModule, { imports: [ReactModule.forRoot({ controllers: [ProductRouter] })] });', + '', + ].join('\n'); +} + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error: unknown) { + if (typeof error === 'object' && error !== null && 'code' in error && error.code === 'ESRCH') { + return false; + } + throw error; + } +} + +afterEach(async () => { + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }); + } +}); + +describe('fluo typegen generation process', () => { + it('settles every isolated generation process before repeated commands complete', async () => { + // Given: one buildless TypeScript application generated repeatedly in a long-lived caller process. + const cwd = await mkdtemp(join(fixturesDirectory, 'typegen-generation-process-')); + tempDirectories.push(cwd); + const markerPath = join(cwd, 'generation.pid'); + const modulePath = join(cwd, 'app.ts'); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const fixtureTsconfig = await readFile(join(fixturesDirectory, 'tsconfig.json'), 'utf8'); + await writeFile(join(cwd, 'tsconfig.json'), fixtureTsconfig.replaceAll('../../../../', '../../../../../'), 'utf8'); + await writeFile(modulePath, applicationSource(markerPath), 'utf8'); + + // When: the caller completes many default generations. + for (let generation = 0; generation < 12; generation += 1) { + const stderr: string[] = []; + const exitCode = await runTypegenCommand([modulePath, '--output', outputPath], { + cwd: cliRoot, + stderr: { write: (message) => stderr.push(message) }, + stdout: { write: () => undefined }, + }); + + // Then: command completion observes current output with no retained generation process. + expect(stderr).toEqual([]); + expect(exitCode).toBe(0); + const generationPid = Number.parseInt(await readFile(markerPath, 'utf8'), 10); + expect(generationPid).not.toBe(process.pid); + expect(isProcessRunning(generationPid)).toBe(false); + } + expect(await readFile(outputPath, 'utf8')).toContain('/products/:productId'); + }, 60_000); +}); diff --git a/packages/cli/src/commands/typegen-generation-process.ts b/packages/cli/src/commands/typegen-generation-process.ts new file mode 100644 index 000000000..da4b1c54c --- /dev/null +++ b/packages/cli/src/commands/typegen-generation-process.ts @@ -0,0 +1,114 @@ +import { fork } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { dirname, extname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { parseTypegenGenerationMessage, type TypegenGenerationMessage } from './typegen-generation-protocol.js'; +import { TypegenCommandError } from './typegen-options.js'; + +type TypegenGenerationRequest = { + readonly cwd: string; + readonly exportName: string; + readonly modulePath: string; +}; + +/** Listener boundary for one short-lived typegen generation child. */ +export type TypegenGenerationChild = { + readonly offError: (listener: (error: Error) => void) => void; + readonly offExit: (listener: (code: number | null, signal: NodeJS.Signals | null) => void) => void; + readonly offMessage: (listener: (message: unknown) => void) => void; + readonly onError: (listener: (error: Error) => void) => void; + readonly onExit: (listener: (code: number | null, signal: NodeJS.Signals | null) => void) => void; + readonly onMessage: (listener: (message: unknown) => void) => void; +}; + +type TypegenGenerationSpawner = (request: TypegenGenerationRequest) => TypegenGenerationChild; + +function createGenerationChild(request: TypegenGenerationRequest): TypegenGenerationChild { + const currentPath = fileURLToPath(import.meta.url); + const extension = extname(currentPath); + const childPath = join(dirname(currentPath), `typegen-generation-child${extension}`); + const execArgv = extension === '.ts' + ? ['--experimental-import-meta-resolve', '--import', createRequire(import.meta.url).resolve('tsx')] + : ['--experimental-import-meta-resolve']; + const child = fork(childPath, [request.cwd, request.modulePath, request.exportName], { + cwd: request.cwd, + execArgv, + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + return { + offError: (listener) => child.off('error', listener), + offExit: (listener) => child.off('exit', listener), + offMessage: (listener) => child.off('message', listener), + onError: (listener) => child.once('error', listener), + onExit: (listener) => child.once('exit', listener), + onMessage: (listener) => child.on('message', listener), + }; +} + +function describeExit(code: number | null, signal: NodeJS.Signals | null): string { + if (signal !== null) { + return `signal ${signal}`; + } + return code === null ? 'without an exit code' : `with exit code ${String(code)}`; +} + +/** + * Waits for a generation child to return source and exit, then removes every completion listener. + * + * @param child Generation child listener boundary. + * @returns Generated source after successful process exit. + */ +export function waitForTypegenGenerationChild(child: TypegenGenerationChild): Promise { + return new Promise((resolve, reject) => { + let message: TypegenGenerationMessage | undefined; + const cleanup = () => { + child.offError(onError); + child.offExit(onExit); + child.offMessage(onMessage); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const onMessage = (value: unknown) => { + try { + message = parseTypegenGenerationMessage(value); + } catch (error: unknown) { + message = { + kind: 'error', + message: error instanceof Error ? error.message : String(error), + }; + } + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + cleanup(); + if (message?.kind === 'error') { + reject(new TypegenCommandError(message.message)); + return; + } + if (message?.kind === 'source' && code === 0) { + resolve(message.source); + return; + } + reject(new TypegenCommandError(`Typegen generation process exited ${describeExit(code, signal)} before returning source.`)); + }; + child.onError(onError); + child.onExit(onExit); + child.onMessage(onMessage); + }); +} + +/** + * Runs one default typegen generation in a short-lived child process. + * + * @param request Consumer directory, application module path, and selected export. + * @param spawnGeneration Child-process factory used by the default runtime and lifecycle tests. + * @returns Generated source after the child process has exited. + */ +export async function runTypegenGenerationProcess( + request: TypegenGenerationRequest, + spawnGeneration: TypegenGenerationSpawner = createGenerationChild, +): Promise { + return waitForTypegenGenerationChild(spawnGeneration(request)); +} diff --git a/packages/cli/src/commands/typegen-generation-protocol.ts b/packages/cli/src/commands/typegen-generation-protocol.ts new file mode 100644 index 000000000..cd14ccf58 --- /dev/null +++ b/packages/cli/src/commands/typegen-generation-protocol.ts @@ -0,0 +1,32 @@ +import { TypegenCommandError } from './typegen-options.js'; + +/** IPC outcome emitted by one isolated typegen generation child. */ +export type TypegenGenerationMessage = + | { readonly kind: 'error'; readonly message: string } + | { readonly kind: 'source'; readonly source: string }; + +/** + * Parses an untrusted generation child IPC payload. + * + * @param value IPC payload received from the child process. + * @returns A validated source or error message. + */ +export function parseTypegenGenerationMessage(value: unknown): TypegenGenerationMessage { + if (typeof value !== 'object' || value === null) { + throw new TypegenCommandError('Typegen generation process returned an invalid IPC message.'); + } + const kind = Reflect.get(value, 'kind'); + if (kind === 'error') { + const message = Reflect.get(value, 'message'); + if (typeof message === 'string') { + return { kind, message }; + } + } + if (kind === 'source') { + const source = Reflect.get(value, 'source'); + if (typeof source === 'string') { + return { kind, source }; + } + } + throw new TypegenCommandError('Typegen generation process returned an invalid IPC message.'); +} diff --git a/packages/cli/src/commands/typegen-isolated-source.ts b/packages/cli/src/commands/typegen-isolated-source.ts new file mode 100644 index 000000000..3f11533b4 --- /dev/null +++ b/packages/cli/src/commands/typegen-isolated-source.ts @@ -0,0 +1,95 @@ +import { existsSync } from 'node:fs'; +import { dirname, extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import type { ParsedTypegenArgs } from './typegen-options.js'; +import { TypegenCommandError } from './typegen-options.js'; +import { + createTypegenSource, + generateTypegenSource, + loadReactTypegenModules, + type ReactTypegenModules, +} from './typegen-source.js'; + +const TYPESCRIPT_MODULE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const TYPEGEN_MODULE_IDS = ['@fluojs/react', '@fluojs/react/typegen', '@fluojs/runtime'] as const; + +function isModuleNotFoundError(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND'); +} + +function resolveProjectModuleUrl(moduleId: string, cwd: string): string { + const parentUrls = [pathToFileURL(resolve(cwd, 'package.json')).href, import.meta.url]; + for (const parentUrl of parentUrls) { + try { + return import.meta.resolve(moduleId, parentUrl); + } catch (error: unknown) { + if (!isModuleNotFoundError(error)) { + throw error; + } + } + } + throw new TypegenCommandError(`Unable to resolve ${moduleId} from the inspected project.`); +} + +function requireNamespace(owner: object, name: string): object { + const value = Reflect.get(owner, name); + if (typeof value !== 'object' || value === null) { + throw new TypegenCommandError(`Required typegen namespace ${name} is unavailable.`); + } + return value; +} + +async function importNativeTypegenGraph(modulePath: string, cwd: string): Promise<{ + readonly application: object; + readonly modules: ReactTypegenModules; +}> { + const imports = { + application: pathToFileURL(modulePath).href, + react: resolveProjectModuleUrl(TYPEGEN_MODULE_IDS[0], cwd), + typegen: resolveProjectModuleUrl(TYPEGEN_MODULE_IDS[1], cwd), + runtime: resolveProjectModuleUrl(TYPEGEN_MODULE_IDS[2], cwd), + } as const; + const source = `${Object.entries(imports) + .map(([name, url]) => `import * as ${name} from ${JSON.stringify(url)};`) + .join('\n')}\nexport { application, react, runtime, typegen };\n`; + const graphUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}`; + const graph = await import(graphUrl); + return { + application: requireNamespace(graph, 'application'), + modules: { + react: requireNamespace(graph, 'react'), + runtime: requireNamespace(graph, 'runtime'), + typegen: requireNamespace(graph, 'typegen'), + }, + }; +} + +/** + * Generates source inside the default short-lived process while preserving one native module graph. + * + * @param parsed Parsed typegen command options. + * @param cwd Consumer project directory. + * @returns Complete deterministic generated source. + */ +export async function createProcessIsolatedTypegenSource( + parsed: ParsedTypegenArgs, + cwd: string, +): Promise { + const modulePath = resolve(cwd, parsed.modulePath); + if (TYPESCRIPT_MODULE_EXTENSIONS.has(extname(modulePath))) { + const tsconfigPath = resolve(dirname(modulePath), 'tsconfig.json'); + const modules = await loadReactTypegenModules(cwd, existsSync(tsconfigPath) ? tsconfigPath : false); + return createTypegenSource({ cwd, modules, parsed }); + } + + const imported = await importNativeTypegenGraph(modulePath, cwd); + return generateTypegenSource({ + application: imported.application, + modules: imported.modules, + parsed, + }); +} diff --git a/packages/cli/src/commands/typegen-loader-override.test.ts b/packages/cli/src/commands/typegen-loader-override.test.ts new file mode 100644 index 000000000..21b4cc147 --- /dev/null +++ b/packages/cli/src/commands/typegen-loader-override.test.ts @@ -0,0 +1,56 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { runTypegenCommand } from './typegen.js'; +import type { ReactTypegenModules } from './typegen-source.js'; + +const tempDirectories: string[] = []; + +afterEach(async () => { + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }); + } +}); + +describe('fluo typegen module loader override', () => { + it.each(['ts', 'js', 'mjs'] as const)('uses caller-supplied modules for a .%s application', async (extension) => { + // Given: a module loader whose output differs from the default package namespaces. + const cwd = await mkdtemp(join(tmpdir(), 'fluo-typegen-loader-override-')); + tempDirectories.push(cwd); + await writeFile(join(cwd, 'package.json'), '{"type":"module"}\n', 'utf8'); + const modulePath = join(cwd, `app.${extension}`); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const expectedSource = `// caller-supplied ${extension} typegen source\n`; + await writeFile(modulePath, 'export class AppModule {}\n', 'utf8'); + const close = vi.fn(async () => undefined); + const create = vi.fn(async () => ({ + close, + dispatcher: { describeRoutes: () => [{ id: `override-${extension}` }] }, + })); + const modules = { + react: { createReactPageCatalog: (descriptors: readonly object[]) => descriptors }, + runtime: { FluoFactory: Object.assign(() => undefined, { create }) }, + typegen: { generateReactPageTypes: () => expectedSource }, + } satisfies ReactTypegenModules; + const loadReactTypegenModules = vi.fn(async () => modules); + const stderr: string[] = []; + + // When: the programmatic command generates from that application extension. + const exitCode = await runTypegenCommand([modulePath, '--output', outputPath], { + cwd, + loadReactTypegenModules, + stderr: { write: (message) => stderr.push(message) }, + stdout: { write: () => undefined }, + }); + + // Then: generation preserves the caller's namespace identity instead of loading defaults. + expect(exitCode).toBe(0); + expect(stderr).toEqual([]); + expect(loadReactTypegenModules).toHaveBeenCalledExactlyOnceWith(cwd); + expect(create).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(await readFile(outputPath, 'utf8')).toBe(expectedSource); + }); +}); diff --git a/packages/cli/src/commands/typegen-native-module-process.test.ts b/packages/cli/src/commands/typegen-native-module-process.test.ts new file mode 100644 index 000000000..7d5c9deff --- /dev/null +++ b/packages/cli/src/commands/typegen-native-module-process.test.ts @@ -0,0 +1,124 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const fixturesDirectory = join(dirname(fileURLToPath(import.meta.url)), '../fixtures'); +const cliPath = join(fixturesDirectory, '../cli.ts'); +const tsxImport = createRequire(import.meta.url).resolve('tsx'); +const tempDirectories: string[] = []; + +function nativeApplicationSource(): string { + return [ + "import { defineModule } from '@fluojs/runtime';", + "import { ReactModule } from '@fluojs/react';", + "import { ProductRouter } from './router.mjs';", + '', + 'export class AppModule {}', + 'defineModule(AppModule, { imports: [ReactModule.forRoot({ controllers: [ProductRouter] })] });', + '', + ].join('\n'); +} + +function nativeRouterSource(basePath: string): string { + return [ + "import { Path, Router } from '@fluojs/react';", + '', + 'export class ProductRouter {', + ' show() {}', + '}', + "Path('/:productId')(ProductRouter.prototype, 'show', Object.getOwnPropertyDescriptor(ProductRouter.prototype, 'show'));", + `Router(${JSON.stringify(basePath)})(ProductRouter);`, + '', + ].join('\n'); +} + +function waitForAnyOutput(child: ChildProcess, output: string[], expected: readonly string[]): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ${expected.join(' or ')}. Output:\n${output.join('')}`)); + }, 15_000); + const inspect = () => { + const current = output.join(''); + const matched = expected.find((candidate) => current.includes(candidate)); + if (matched !== undefined) { + cleanup(); + resolve(matched); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`Typegen process exited with ${String(code)} before ${expected.join(' or ')}. Output:\n${output.join('')}`)); + }; + const cleanup = () => { + clearTimeout(timeout); + child.stdout?.off('data', inspect); + child.stderr?.off('data', inspect); + child.off('exit', onExit); + }; + child.stdout?.on('data', inspect); + child.stderr?.on('data', inspect); + child.once('exit', onExit); + inspect(); + }); +} + +afterEach(async () => { + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }); + } +}); + +describe('fluo typegen native module process lifecycle', () => { + it('reloads an imported native ESM router during watch regeneration', async () => { + // Given: a native application whose route metadata lives in a statically imported MJS child. + const cwd = await mkdtemp(join(fixturesDirectory, 'typegen-native-watch-process-')); + tempDirectories.push(cwd); + const modulePath = join(cwd, 'app.mjs'); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const routerPath = join(cwd, 'router.mjs'); + await writeFile(modulePath, nativeApplicationSource(), 'utf8'); + await writeFile(routerPath, nativeRouterSource('/products'), 'utf8'); + const output: string[] = []; + const child = spawn(process.execPath, [ + '--import', + tsxImport, + cliPath, + 'typegen', + modulePath, + '--output', + outputPath, + '--watch', + '--no-update-check', + ], { + cwd: join(fixturesDirectory, '../../../..'), + env: { ...process.env, TSX_TSCONFIG_PATH: join(fixturesDirectory, 'tsconfig.json') }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout?.on('data', (chunk: Buffer) => output.push(chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => output.push(chunk.toString())); + + try { + await waitForAnyOutput(child, output, [`WATCHING ${cwd}`]); + expect(await readFile(outputPath, 'utf8')).toContain('/products/:productId'); + output.length = 0; + + // When: only the imported native router module changes. + await writeFile(routerPath, nativeRouterSource('/inventory'), 'utf8'); + await waitForAnyOutput(child, output, [`UPDATE ${outputPath}`]); + + // Then: watch generation imports a fresh native dependency graph and publishes current routes. + expect(await readFile(outputPath, 'utf8')).toContain('/inventory/:productId'); + expect(await readFile(outputPath, 'utf8')).not.toContain('/products/:productId'); + } finally { + child.kill('SIGTERM'); + if (child.exitCode === null) { + await new Promise((resolve) => child.once('exit', resolve)); + } + } + }, 30_000); +}); diff --git a/packages/cli/src/commands/typegen-options.ts b/packages/cli/src/commands/typegen-options.ts new file mode 100644 index 000000000..6bf0f4171 --- /dev/null +++ b/packages/cli/src/commands/typegen-options.ts @@ -0,0 +1,75 @@ +/** Parsed lifecycle and path options for one typegen invocation. */ +export type ParsedTypegenArgs = { + readonly check: boolean; + readonly exportName: string; + readonly modulePath: string; + readonly outputPath: string; + readonly watch: boolean; +}; + +/** Invalid typegen command arguments or unavailable runtime tooling. */ +export class TypegenCommandError extends Error { + readonly name = 'TypegenCommandError'; +} + +/** + * Parses command arguments after `fluo typegen`. + * + * @param argv Raw typegen command arguments. + * @returns Parsed module, output, export, and lifecycle options. + */ +export function parseTypegenArgs(argv: readonly string[]): ParsedTypegenArgs { + let check = false; + let exportName = 'AppModule'; + let modulePath: string | undefined; + let outputPath: string | undefined; + let watch = false; + + for (let index = 0; index < argv.length; index += 1) { + const option = argv[index]; + if (option === '--check') { + check = true; + continue; + } + if (option === '--watch') { + watch = true; + continue; + } + if (option === '--output') { + const next = argv[index + 1]; + if (next === undefined || next.startsWith('-')) { + throw new TypegenCommandError('Expected --output to have a file path value.'); + } + outputPath = next; + index += 1; + continue; + } + if (option === '--export') { + const next = argv[index + 1]; + if (next === undefined || next.startsWith('-')) { + throw new TypegenCommandError('Expected --export to have a symbol name value.'); + } + exportName = next; + index += 1; + continue; + } + if (option?.startsWith('-')) { + throw new TypegenCommandError(`Unknown option for typegen command: ${option}`); + } + if (option !== undefined) { + if (modulePath !== undefined) { + throw new TypegenCommandError(`Unexpected extra positional argument: ${option}`); + } + modulePath = option; + } + } + + if (modulePath === undefined || outputPath === undefined) { + throw new TypegenCommandError('Usage: fluo typegen --output [--export ] [--check|--watch]'); + } + if (check && watch) { + throw new TypegenCommandError('fluo typegen accepts only one of --check or --watch.'); + } + + return { check, exportName, modulePath, outputPath, watch }; +} diff --git a/packages/cli/src/commands/typegen-process.test.ts b/packages/cli/src/commands/typegen-process.test.ts new file mode 100644 index 000000000..3a12d80b5 --- /dev/null +++ b/packages/cli/src/commands/typegen-process.test.ts @@ -0,0 +1,194 @@ +import { type ChildProcess, spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { afterEach, describe, expect, it } from 'vitest'; + +const fixturesDirectory = join(dirname(fileURLToPath(import.meta.url)), '../fixtures'); +const cliPath = join(fixturesDirectory, '../cli.ts'); +const tsxImport = createRequire(import.meta.url).resolve('tsx'); +const tempDirectories: string[] = []; + +async function runTypegenProcess(argv: readonly string[], tsconfigPath: string): Promise<{ + readonly exitCode: number | null; + readonly stderr: string; + readonly stdout: string; +}> { + const child = spawn(process.execPath, ['--import', tsxImport, cliPath, ...argv], { + cwd: join(fixturesDirectory, '../../../..'), + env: { ...process.env, TSX_TSCONFIG_PATH: tsconfigPath }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const stderr: Buffer[] = []; + const stdout: Buffer[] = []; + child.stderr?.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.stdout?.on('data', (chunk: Buffer) => stdout.push(chunk)); + const exitCode = await new Promise((resolve) => child.once('exit', resolve)); + return { + exitCode, + stderr: Buffer.concat(stderr).toString(), + stdout: Buffer.concat(stdout).toString(), + }; +} + +function applicationSource(basePath: string): string { + return [ + "import { defineModule } from '@fluojs/runtime';", + "import { Path, ReactModule, Router } from '@fluojs/react';", + '', + `@Router(${JSON.stringify(basePath)})`, + 'class ProductRouter {', + " @Path('/:productId')", + ' show(): void {}', + '}', + '', + 'export class AppModule {}', + 'defineModule(AppModule, { imports: [ReactModule.forRoot({ controllers: [ProductRouter] })] });', + '', + ].join('\n'); +} + +function importedApplicationSource(): string { + return [ + "import { defineModule } from '@fluojs/runtime';", + "import { ReactModule } from '@fluojs/react';", + "import { ProductRouter } from './router.js';", + '', + 'export class AppModule {}', + 'defineModule(AppModule, { imports: [ReactModule.forRoot({ controllers: [ProductRouter] })] });', + '', + ].join('\n'); +} + +function routerSource(basePath: string): string { + return [ + "import { Path, Router } from '@fluojs/react';", + '', + `@Router(${JSON.stringify(basePath)})`, + 'export class ProductRouter {', + " @Path('/:productId')", + ' show(): void {}', + '}', + '', + ].join('\n'); +} + +function waitForOutput(child: ChildProcess, output: string[], expected: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error(`Timed out waiting for ${expected}. Output:\n${output.join('')}`)); + }, 15_000); + const inspect = () => { + if (output.join('').includes(expected)) { + cleanup(); + resolve(); + } + }; + const onExit = (code: number | null) => { + cleanup(); + reject(new Error(`Typegen process exited with ${String(code)} before ${expected}. Output:\n${output.join('')}`)); + }; + const cleanup = () => { + clearTimeout(timeout); + child.stdout?.off('data', inspect); + child.off('exit', onExit); + }; + child.stdout?.on('data', inspect); + child.once('exit', onExit); + inspect(); + }); +} + +afterEach(async () => { + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }); + } +}); + +describe('fluo typegen process lifecycle', () => { + it('returns the documented stale exit code from a real non-mutating check process', async () => { + // Given: a real CLI process has generated an artifact that is then made stale without changing its schema. + const cwd = await mkdtemp(join(fixturesDirectory, 'typegen-check-process-')); + tempDirectories.push(cwd); + const modulePath = join(cwd, 'app.ts'); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const tsconfigPath = join(cwd, 'tsconfig.json'); + await writeFile(modulePath, applicationSource('/products'), 'utf8'); + const fixtureTsconfig = await readFile(join(fixturesDirectory, 'tsconfig.json'), 'utf8'); + await writeFile(tsconfigPath, fixtureTsconfig.replaceAll('../../../../', '../../../../../'), 'utf8'); + const generated = await runTypegenProcess([ + 'typegen', modulePath, '--output', outputPath, '--no-update-check', + ], tsconfigPath); + expect(generated.exitCode).toBe(0); + const stale = (await readFile(outputPath, 'utf8')).replaceAll('/products', '/stale-products'); + await writeFile(outputPath, stale, 'utf8'); + + // When: CI invokes the binary-facing check mode. + const checked = await runTypegenProcess([ + 'typegen', modulePath, '--output', outputPath, '--check', '--no-update-check', + ], tsconfigPath); + + // Then: the process exits with STALE=3, writes only an actionable error, and preserves the target. + expect(checked.exitCode).toBe(3); + expect(checked.stdout).toBe(''); + expect(checked.stderr).toContain(`STALE ${outputPath}`); + expect(await readFile(outputPath, 'utf8')).toBe(stale); + }, 30_000); + + it('reloads changed page metadata and exits cleanly on SIGTERM', async () => { + // Given: a standalone TypeScript application module watched by the real CLI process. + const cwd = await mkdtemp(join(fixturesDirectory, 'typegen-watch-process-')); + tempDirectories.push(cwd); + const modulePath = join(cwd, 'app.ts'); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const routerPath = join(cwd, 'router.ts'); + await writeFile(modulePath, importedApplicationSource(), 'utf8'); + await writeFile(routerPath, routerSource('/products'), 'utf8'); + const fixtureTsconfig = await readFile(join(fixturesDirectory, 'tsconfig.json'), 'utf8'); + await writeFile( + join(cwd, 'tsconfig.json'), + fixtureTsconfig.replaceAll('../../../../', '../../../../../'), + 'utf8', + ); + const output: string[] = []; + const child = spawn(process.execPath, [ + '--import', + tsxImport, + cliPath, + 'typegen', + modulePath, + '--output', + outputPath, + '--watch', + '--no-update-check', + ], { + cwd: join(fixturesDirectory, '../../../..'), + env: { ...process.env, TSX_TSCONFIG_PATH: join(cwd, 'tsconfig.json') }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.stdout?.on('data', (chunk: Buffer) => output.push(chunk.toString())); + child.stderr?.on('data', (chunk: Buffer) => output.push(chunk.toString())); + + try { + await waitForOutput(child, output, `WATCHING ${cwd}`); + + // When: the authoritative module changes and the parent sends terminal shutdown. + await writeFile(routerPath, routerSource('/inventory'), 'utf8'); + await waitForOutput(child, output, `UPDATE ${outputPath}`); + child.kill('SIGTERM'); + const exitCode = await new Promise((resolve) => child.once('exit', resolve)); + + // Then: a fresh bootstrap publishes the changed catalog and the watcher exits successfully. + expect(exitCode).toBe(0); + expect(await readFile(outputPath, 'utf8')).toContain('/inventory/:productId'); + expect(await readFile(outputPath, 'utf8')).not.toContain('/products/:productId'); + } finally { + if (child.exitCode === null) { + child.kill('SIGKILL'); + } + } + }, 30_000); +}); diff --git a/packages/cli/src/commands/typegen-source.ts b/packages/cli/src/commands/typegen-source.ts new file mode 100644 index 000000000..4357dbd81 --- /dev/null +++ b/packages/cli/src/commands/typegen-source.ts @@ -0,0 +1,211 @@ +import { existsSync } from 'node:fs'; +import { dirname, extname, resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +import { tsImport } from 'tsx/esm/api'; + +import type { ParsedTypegenArgs } from './typegen-options.js'; +import { TypegenCommandError } from './typegen-options.js'; + +/** Dynamically loaded package surfaces required by typegen. */ +export type ReactTypegenModules = { + readonly react: object; + readonly runtime: object; + readonly typegen: object; +}; + +/** Structural artifact result returned by the React typegen package. */ +export type ReactTypegenArtifactInspection = + | { readonly status: 'malformed' } + | { readonly status: 'unsupported-version'; readonly version: number } + | { readonly status: 'valid'; readonly version: number }; + +type CreateTypegenSourceOptions = { + readonly cwd: string; + readonly modules: ReactTypegenModules; + readonly parsed: ParsedTypegenArgs; +}; + +type GenerateTypegenSourceOptions = { + readonly application: object; + readonly modules: ReactTypegenModules; + readonly parsed: ParsedTypegenArgs; +}; + +const TYPESCRIPT_MODULE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); +const TYPEGEN_MODULE_IDS = ['@fluojs/react', '@fluojs/react/typegen', '@fluojs/runtime'] as const; +const SILENT_APPLICATION_LOGGER = Object.freeze({ + debug() {}, + error() {}, + log() {}, + warn() {}, +}); +let applicationImportSequence = 0; + +function isModuleNotFoundError(error: unknown): boolean { + return typeof error === 'object' + && error !== null + && 'code' in error + && (error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND'); +} + +async function importProjectModule(moduleId: string, cwd: string, tsconfig: string | false): Promise { + const importOptions = [ + { parentURL: pathToFileURL(resolve(cwd, 'package.json')).href, tsconfig }, + { parentURL: import.meta.url, tsconfig: false }, + ] as const; + for (const options of importOptions) { + try { + const imported = await tsImport(moduleId, options); + if (typeof imported !== 'object' || imported === null) { + throw new TypegenCommandError(`Resolved ${moduleId} to an invalid module namespace.`); + } + return imported; + } catch (error: unknown) { + if (!isModuleNotFoundError(error)) { + throw error; + } + } + } + throw new TypegenCommandError(`Unable to resolve ${moduleId} from the inspected project.`); +} + +async function importTypeScriptApplicationModule(modulePath: string): Promise { + const moduleUrl = pathToFileURL(modulePath).href; + const tsconfigPath = resolve(dirname(modulePath), 'tsconfig.json'); + return existsSync(tsconfigPath) + ? tsImport(moduleUrl, { parentURL: import.meta.url, tsconfig: tsconfigPath }) + : tsImport(moduleUrl, { parentURL: import.meta.url, tsconfig: false }); +} + +function requireNamespace(owner: object, name: string): object { + const value = Reflect.get(owner, name); + if (typeof value !== 'object' || value === null) { + throw new TypegenCommandError(`Required typegen namespace ${name} is unavailable.`); + } + return value; +} + +async function importNativeApplicationModule(modulePath: string): Promise { + applicationImportSequence += 1; + const source = `import * as application from ${JSON.stringify(pathToFileURL(modulePath).href)};\nexport { application };\n`; + const graphUrl = `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}#fluo-typegen-${String(applicationImportSequence)}`; + const graph = await tsImport(graphUrl, import.meta.url); + return requireNamespace(graph, 'application'); +} + +function requireFunction(owner: object, name: string): (...args: readonly unknown[]) => unknown { + const value = Reflect.get(owner, name); + if (typeof value !== 'function') { + throw new TypegenCommandError(`Required typegen function ${name} is unavailable.`); + } + return value; +} + +/** + * Loads typegen package entrypoints from the consumer project or CLI installation. + * + * @param cwd Consumer project directory used for package resolution. + * @param tsconfig TypeScript configuration path, or `false` for native package resolution. + * @returns React, React typegen, and runtime module namespaces. + */ +export async function loadReactTypegenModules(cwd: string, tsconfig: string | false = false): Promise { + const [react, typegen, runtime] = await Promise.all( + TYPEGEN_MODULE_IDS.map((moduleId) => importProjectModule(moduleId, cwd, tsconfig)), + ); + return { react, runtime, typegen }; +} + +/** + * Bootstraps the selected module and generates source from authoritative route descriptors. + * + * @param options Parsed command, consumer directory, module namespaces, and native import boundary. + * @returns Complete deterministic generated source. + */ +export async function createTypegenSource(options: CreateTypegenSourceOptions): Promise { + const modulePath = resolve(options.cwd, options.parsed.modulePath); + const importedApplication = TYPESCRIPT_MODULE_EXTENSIONS.has(extname(modulePath)) + ? await importTypeScriptApplicationModule(modulePath) + : await importNativeApplicationModule(modulePath); + return generateTypegenSource({ + application: importedApplication, + modules: options.modules, + parsed: options.parsed, + }); +} + +/** + * Generates source from one already-imported application and its matching tooling namespaces. + * + * @param options Application namespace, generation modules, and parsed command selection. + * @returns Complete deterministic generated source. + */ +export async function generateTypegenSource(options: GenerateTypegenSourceOptions): Promise { + const rootModule = Reflect.get(options.application, options.parsed.exportName); + if (typeof rootModule !== 'function') { + throw new TypegenCommandError(`Export "${options.parsed.exportName}" is not a module class constructor.`); + } + + const factory = Reflect.get(options.modules.runtime, 'FluoFactory'); + if (typeof factory !== 'function') { + throw new TypegenCommandError('Required runtime FluoFactory is unavailable.'); + } + const application = await Reflect.apply(requireFunction(factory, 'create'), factory, [ + rootModule, + { logger: SILENT_APPLICATION_LOGGER }, + ]); + if (typeof application !== 'object' || application === null) { + throw new TypegenCommandError('Runtime application bootstrap returned an invalid value.'); + } + + const close = requireFunction(application, 'close'); + try { + const dispatcher = Reflect.get(application, 'dispatcher'); + if (typeof dispatcher !== 'object' || dispatcher === null) { + throw new TypegenCommandError('Runtime application dispatcher is unavailable.'); + } + const descriptors = Reflect.apply(requireFunction(dispatcher, 'describeRoutes'), dispatcher, []); + if (!Array.isArray(descriptors)) { + throw new TypegenCommandError('Runtime route descriptors are unavailable.'); + } + const catalog = Reflect.apply(requireFunction(options.modules.react, 'createReactPageCatalog'), undefined, [descriptors]); + const source = Reflect.apply(requireFunction(options.modules.typegen, 'generateReactPageTypes'), undefined, [catalog]); + if (typeof source !== 'string') { + throw new TypegenCommandError('React page typegen returned an invalid artifact.'); + } + return source; + } finally { + await Reflect.apply(close, application, []); + } +} + +/** + * Inspects an existing generated source value through the loaded React typegen package. + * + * @param modules Loaded React typegen module namespace. + * @param source Existing artifact source. + * @returns Parsed current, malformed, or unsupported-version status. + */ +export function inspectReactTypegenArtifact( + modules: ReactTypegenModules, + source: string, +): ReactTypegenArtifactInspection { + const inspection = Reflect.apply(requireFunction(modules.typegen, 'inspectReactPageTypeArtifact'), undefined, [source]); + if (typeof inspection !== 'object' || inspection === null || !('status' in inspection)) { + throw new TypegenCommandError('React page typegen artifact inspection returned an invalid result.'); + } + if (inspection.status === 'malformed') { + return { status: 'malformed' }; + } + if ( + inspection.status === 'unsupported-version' + && 'version' in inspection + && typeof inspection.version === 'number' + ) { + return { status: 'unsupported-version', version: inspection.version }; + } + if (inspection.status === 'valid' && 'version' in inspection && typeof inspection.version === 'number') { + return { status: 'valid', version: inspection.version }; + } + throw new TypegenCommandError('React page typegen artifact inspection returned an invalid result.'); +} diff --git a/packages/cli/src/commands/typegen-watch-startup.test.ts b/packages/cli/src/commands/typegen-watch-startup.test.ts new file mode 100644 index 000000000..4c11aaa84 --- /dev/null +++ b/packages/cli/src/commands/typegen-watch-startup.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + runTypegenWatch, + type TypegenWatcher, + type TypegenWatchSignalTarget, +} from './typegen-watch.js'; + +function createSignalTarget(): TypegenWatchSignalTarget & { emit(signal: 'SIGINT' | 'SIGTERM'): void } { + const listeners = new Map<'SIGINT' | 'SIGTERM', () => void>(); + return { + emit(signal) { + listeners.get(signal)?.(); + }, + off(signal, listener) { + if (listeners.get(signal) === listener) { + listeners.delete(signal); + } + }, + once(signal, listener) { + listeners.set(signal, listener); + }, + }; +} + +describe('fluo typegen watch startup barrier', () => { + it('publishes a change observed during initial generation before reporting ready', async () => { + // Given: initial generation has captured stale source while the watcher can observe a newer save. + const signalTarget = createSignalTarget(); + const publishedSources: string[] = []; + const readySources: string[] = []; + let currentSource = 'stale source'; + let listener: ((event: string, filename: string | Buffer | null) => void) | undefined; + let releaseInitialGeneration: (() => void) | undefined; + const watcher: TypegenWatcher = { close: vi.fn(), on: vi.fn(() => watcher) }; + const watchTarget = vi.fn((_target, _options, nextListener) => { + listener = nextListener; + return watcher; + }); + const generate = vi.fn(async () => { + const capturedSource = currentSource; + if (generate.mock.calls.length === 1) { + await new Promise((resolve) => { + releaseInitialGeneration = resolve; + }); + } + publishedSources.push(capturedSource); + }); + const runPromise = runTypegenWatch({ + generate, + modulePath: '/project/src/app.ts', + onReady() { + readySources.push(publishedSources.at(-1) ?? 'missing'); + }, + outputPath: '/project/src/generated/react-pages.ts', + signalTarget, + watchTarget, + }); + + try { + await vi.waitFor(() => expect(generate).toHaveBeenCalledOnce()); + + // When: source changes before the initial generation can publish its captured bytes. + currentSource = 'current source'; + listener?.('change', 'page.tsx'); + releaseInitialGeneration?.(); + + // Then: startup performs a buffered rerun and reports ready only after current bytes publish. + await vi.waitFor(() => expect(publishedSources).toEqual(['stale source', 'current source'])); + expect(readySources).toEqual(['current source']); + } finally { + releaseInitialGeneration?.(); + await vi.waitFor(() => expect(watchTarget).toHaveBeenCalledOnce()); + signalTarget.emit('SIGTERM'); + await runPromise; + } + }); + + it('releases the preinstalled watcher and signal handlers when startup generation fails', async () => { + // Given: the watcher is active before an application bootstrap that cannot produce an artifact. + const startupError = new Error('startup bootstrap failed'); + const close = vi.fn(); + const watcher: TypegenWatcher = { close, on: vi.fn(() => watcher) }; + const signalTarget: TypegenWatchSignalTarget = { + off: vi.fn(), + once: vi.fn(), + }; + const watchTarget = vi.fn(() => watcher); + + // When: watch mode performs its deterministic startup generation. + const action = runTypegenWatch({ + generate: async () => { + throw startupError; + }, + modulePath: '/project/src/app.ts', + outputPath: '/project/src/generated/react-pages.ts', + signalTarget, + watchTarget, + }); + + // Then: startup rejects with the original failure after releasing every acquired resource. + await expect(action).rejects.toBe(startupError); + expect(watchTarget).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); + + it('releases the watcher and signal handlers when ready notification fails', async () => { + // Given: startup generation and watcher acquisition succeed but the ready output boundary fails. + const readyError = new Error('ready output failed'); + const close = vi.fn(); + const watcher: TypegenWatcher = { close, on: vi.fn(() => watcher) }; + const signalTarget: TypegenWatchSignalTarget = { + off: vi.fn(), + once: vi.fn(), + }; + + // When: watch mode announces that its long-running lifecycle is ready. + const action = runTypegenWatch({ + generate: async () => undefined, + modulePath: '/project/src/app.ts', + onReady() { + throw readyError; + }, + outputPath: '/project/src/generated/react-pages.ts', + signalTarget, + watchTarget: () => watcher, + }); + + // Then: setup rejects with the original failure after releasing every acquired resource. + await expect(action).rejects.toBe(readyError); + expect(close).toHaveBeenCalledOnce(); + expect(signalTarget.off).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/cli/src/commands/typegen-watch.test.ts b/packages/cli/src/commands/typegen-watch.test.ts new file mode 100644 index 000000000..5a7b064a5 --- /dev/null +++ b/packages/cli/src/commands/typegen-watch.test.ts @@ -0,0 +1,214 @@ +import { mkdtemp, readFile, rm, stat, utimes } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { writeTypegenArtifact } from './typegen-artifact.js'; +import { + runTypegenWatch, + type TypegenWatcher, + type TypegenWatchScheduler, + type TypegenWatchSignalTarget, +} from './typegen-watch.js'; + +const tempDirectories: string[] = []; + +function createScheduler(): TypegenWatchScheduler & { flush(): void } { + let sequence = 0; + const callbacks = new Map void>(); + return { + clear(handle) { + callbacks.delete(handle); + }, + flush() { + const pending = [...callbacks.values()]; + callbacks.clear(); + for (const callback of pending) { + callback(); + } + }, + set(callback) { + sequence += 1; + callbacks.set(sequence, callback); + return sequence; + }, + }; +} + +function createSignalTarget(): TypegenWatchSignalTarget & { emit(signal: 'SIGINT' | 'SIGTERM'): void } { + const listeners = new Map<'SIGINT' | 'SIGTERM', () => void>(); + return { + emit(signal) { + listeners.get(signal)?.(); + }, + off(signal, listener) { + if (listeners.get(signal) === listener) { + listeners.delete(signal); + } + }, + once(signal, listener) { + listeners.set(signal, listener); + }, + }; +} + +afterEach(async () => { + for (const directory of tempDirectories.splice(0)) { + await rm(directory, { force: true, recursive: true }); + } +}); + +describe('fluo typegen watch lifecycle', () => { + it('coalesces rapid changes, serializes regeneration, and shuts down cleanly', async () => { + // Given: a successful startup generation and an injectable watcher lifecycle. + const scheduler = createScheduler(); + const signalTarget = createSignalTarget(); + const close = vi.fn(); + let listener: ((event: string, filename: string | Buffer | null) => void) | undefined; + const watcher: TypegenWatcher = { close, on: vi.fn(() => watcher) }; + const watchTarget = vi.fn((_target, _options, nextListener) => { + listener = nextListener; + return watcher; + }); + const onReady = vi.fn(); + let active = 0; + let maximumActive = 0; + let releaseSecondGeneration: (() => void) | undefined; + let generationCount = 0; + const generate = vi.fn(async () => { + generationCount += 1; + active += 1; + maximumActive = Math.max(maximumActive, active); + if (generationCount === 2) { + await new Promise((resolve) => { + releaseSecondGeneration = resolve; + }); + } + active -= 1; + }); + const runPromise = runTypegenWatch({ + generate, + modulePath: '/project/src/app.ts', + onReady, + outputPath: '/project/src/generated/react-pages.ts', + scheduler, + signalTarget, + watchTarget, + }); + await vi.waitFor(() => expect(onReady).toHaveBeenCalledOnce()); + listener?.('change', 'generated/react-pages.ts'); + scheduler.flush(); + expect(generate).toHaveBeenCalledOnce(); + + // When: save bursts arrive before and during one pending regeneration. + listener?.('change', 'page.tsx'); + listener?.('change', 'router.ts'); + scheduler.flush(); + await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(2)); + listener?.('change', 'layout.tsx'); + listener?.('change', 'metadata.ts'); + scheduler.flush(); + releaseSecondGeneration?.(); + await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(3)); + signalTarget.emit('SIGTERM'); + + // Then: bursts become one serialized rerun and shutdown releases every owned listener. + await expect(runPromise).resolves.toBe(0); + expect(maximumActive).toBe(1); + expect(close).toHaveBeenCalledOnce(); + }); + + it('preserves the last valid artifact after a watch regeneration failure and recovers later', async () => { + // Given: startup publishes one valid artifact and the next authoritative bootstrap fails. + const cwd = await mkdtemp(join(tmpdir(), 'fluo-typegen-watch-')); + tempDirectories.push(cwd); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const scheduler = createScheduler(); + const signalTarget = createSignalTarget(); + const onError = vi.fn(); + let listener: ((event: string, filename: string | Buffer | null) => void) | undefined; + const watcher: TypegenWatcher = { close: vi.fn(), on: vi.fn(() => watcher) }; + const watchTarget = vi.fn((_target, _options, nextListener) => { + listener = nextListener; + return watcher; + }); + const onReady = vi.fn(); + let generationCount = 0; + const generate = vi.fn(async () => { + generationCount += 1; + if (generationCount === 2) { + throw new Error('application bootstrap failed'); + } + await writeTypegenArtifact(outputPath, generationCount === 1 ? 'valid one\n' : 'valid two\n'); + }); + const runPromise = runTypegenWatch({ + generate, + modulePath: join(cwd, 'src', 'app.ts'), + onError, + onReady, + outputPath, + scheduler, + signalTarget, + watchTarget, + }); + await vi.waitFor(() => expect(onReady).toHaveBeenCalledOnce()); + + // When: one failed generation is followed by another source change. + listener?.('change', 'page.tsx'); + scheduler.flush(); + await vi.waitFor(() => expect(onError).toHaveBeenCalledOnce()); + const artifactAfterFailure = await readFile(outputPath, 'utf8'); + listener?.('change', 'page.tsx'); + scheduler.flush(); + await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(3)); + await vi.waitFor(async () => expect(await readFile(outputPath, 'utf8')).toBe('valid two\n')); + signalTarget.emit('SIGINT'); + + // Then: failure never replaces the prior file and a later valid run commits normally. + expect(artifactAfterFailure).toBe('valid one\n'); + await expect(runPromise).resolves.toBe(0); + }); + + it('does not rewrite an unchanged artifact after a watched source event', async () => { + // Given: startup generated one artifact and its timestamp records the last meaningful catalog change. + const cwd = await mkdtemp(join(tmpdir(), 'fluo-typegen-watch-unchanged-')); + tempDirectories.push(cwd); + const outputPath = join(cwd, 'generated', 'react-pages.ts'); + const scheduler = createScheduler(); + const signalTarget = createSignalTarget(); + let listener: ((event: string, filename: string | Buffer | null) => void) | undefined; + const watcher: TypegenWatcher = { close: vi.fn(), on: vi.fn(() => watcher) }; + const watchTarget = vi.fn((_target, _options, nextListener) => { + listener = nextListener; + return watcher; + }); + const onReady = vi.fn(); + const generate = vi.fn(async () => { + await writeTypegenArtifact(outputPath, 'stable artifact\n'); + }); + const runPromise = runTypegenWatch({ + generate, + modulePath: join(cwd, 'src', 'app.ts'), + onReady, + outputPath, + scheduler, + signalTarget, + watchTarget, + }); + await vi.waitFor(() => expect(onReady).toHaveBeenCalledOnce()); + const oldTimestamp = new Date('2020-01-01T00:00:00.000Z'); + await utimes(outputPath, oldTimestamp, oldTimestamp); + + // When: a watched save produces the same authoritative catalog bytes. + listener?.('change', 'page.tsx'); + scheduler.flush(); + await vi.waitFor(() => expect(generate).toHaveBeenCalledTimes(2)); + signalTarget.emit('SIGTERM'); + + // Then: regeneration reports no write through the preserved filesystem timestamp. + await expect(runPromise).resolves.toBe(0); + expect((await stat(outputPath)).mtimeMs).toBe(oldTimestamp.getTime()); + }); + +}); diff --git a/packages/cli/src/commands/typegen-watch.ts b/packages/cli/src/commands/typegen-watch.ts new file mode 100644 index 000000000..f59f4c071 --- /dev/null +++ b/packages/cli/src/commands/typegen-watch.ts @@ -0,0 +1,224 @@ +import { watch } from 'node:fs'; +import { basename, dirname, resolve } from 'node:path'; + +/** Scheduler used to debounce typegen filesystem bursts deterministically. */ +export type TypegenWatchScheduler = { + readonly clear: (handle: number) => void; + readonly set: (callback: () => void, delayMs: number) => number; +}; + +/** Signal registration boundary owned by one typegen watch invocation. */ +export type TypegenWatchSignalTarget = { + readonly off: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => unknown; + readonly once: (signal: 'SIGINT' | 'SIGTERM', listener: () => void) => unknown; +}; + +/** Minimal watcher resource acquired by typegen watch mode. */ +export type TypegenWatcher = { + close(): void; + on(event: 'error', listener: (error: Error) => void): TypegenWatcher; +}; + +type TypegenWatchTarget = ( + target: string, + options: { readonly persistent: boolean; readonly recursive: boolean }, + listener: (event: string, filename: string | Buffer | null) => void, +) => TypegenWatcher; + +/** Dependencies and callbacks for one bounded typegen watch lifecycle. */ +export type TypegenWatchOptions = { + readonly debounceMs?: number; + readonly generate: () => Promise; + readonly modulePath: string; + readonly onError?: (error: unknown) => void; + readonly onReady?: (watchRoot: string) => void; + readonly outputPath: string; + readonly scheduler?: TypegenWatchScheduler; + readonly signalTarget?: TypegenWatchSignalTarget; + readonly watchTarget?: TypegenWatchTarget; +}; + +const DEFAULT_DEBOUNCE_MS = 100; + +function createDefaultScheduler(): TypegenWatchScheduler { + let sequence = 0; + const timers = new Map>(); + return { + clear(handle) { + const timer = timers.get(handle); + if (timer !== undefined) { + clearTimeout(timer); + timers.delete(handle); + } + }, + set(callback, delayMs) { + sequence += 1; + const handle = sequence; + const timer = setTimeout(() => { + timers.delete(handle); + callback(); + }, delayMs); + timers.set(handle, timer); + return handle; + }, + }; +} + +const defaultWatchTarget: TypegenWatchTarget = (target, options, listener) => watch(target, options, listener); + +function isOwnArtifactEvent(changedPath: string, outputPath: string): boolean { + if (changedPath === outputPath) { + return true; + } + + const changedName = basename(changedPath); + const outputName = basename(outputPath); + return changedName.startsWith(`.${outputName}.`) && changedName.endsWith('.tmp'); +} + +/** + * Watches the application module directory while generating an initial current artifact. + * + * @param options Generation, watcher, scheduler, and signal lifecycle dependencies. + * @returns Exit code `0` after signal shutdown or `1` after an asynchronous watcher failure. + */ +export async function runTypegenWatch(options: TypegenWatchOptions): Promise { + const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS; + const onError = options.onError ?? (() => undefined); + const scheduler = options.scheduler ?? createDefaultScheduler(); + const signalTarget = options.signalTarget ?? process; + const watchTarget = options.watchTarget ?? defaultWatchTarget; + const watchRoot = dirname(options.modulePath); + let activeGeneration: Promise | undefined; + let cleanedUp = false; + let rerunRequested = false; + let resolved = false; + let restartTimer: number | undefined; + let stopping = false; + let stopCode = 0; + let watcher: TypegenWatcher | undefined; + let startupComplete = false; + let resolveResult: (code: number) => void = () => undefined; + + const result = new Promise((resolvePromise) => { + resolveResult = resolvePromise; + }); + + const cleanup = () => { + if (cleanedUp) { + return; + } + cleanedUp = true; + if (restartTimer !== undefined) { + scheduler.clear(restartTimer); + restartTimer = undefined; + } + watcher?.close(); + signalTarget.off('SIGINT', stopForSignal); + signalTarget.off('SIGTERM', stopForSignal); + }; + + const settleIfIdle = () => { + if (!stopping || activeGeneration !== undefined || resolved) { + return; + } + resolved = true; + resolveResult(stopCode); + }; + + const stop = (code: number) => { + stopCode = Math.max(stopCode, code); + if (!stopping) { + stopping = true; + cleanup(); + } + settleIfIdle(); + }; + + function stopForSignal(): void { + stop(0); + } + + const regenerate = () => { + if (stopping) { + return; + } + if (activeGeneration !== undefined) { + rerunRequested = true; + return; + } + + activeGeneration = (async () => { + do { + rerunRequested = false; + try { + await options.generate(); + } catch (error: unknown) { + onError(error); + } + } while (rerunRequested && !stopping); + })().finally(() => { + activeGeneration = undefined; + settleIfIdle(); + }); + }; + + const scheduleRegeneration = (changedPath: string) => { + if (stopping || isOwnArtifactEvent(changedPath, options.outputPath)) { + return; + } + if (!startupComplete) { + rerunRequested = true; + return; + } + if (restartTimer !== undefined) { + scheduler.clear(restartTimer); + } + restartTimer = scheduler.set(() => { + restartTimer = undefined; + regenerate(); + }, debounceMs); + }; + + try { + watcher = watchTarget( + watchRoot, + { persistent: true, recursive: true }, + (_event, filename) => { + const changedPath = filename === null ? watchRoot : resolve(watchRoot, String(filename)); + scheduleRegeneration(changedPath); + }, + ); + watcher.on('error', (error) => { + onError(error); + stop(1); + }); + } catch (error: unknown) { + watcher?.close(); + throw error; + } + + try { + signalTarget.once('SIGINT', stopForSignal); + signalTarget.once('SIGTERM', stopForSignal); + activeGeneration = (async () => { + do { + rerunRequested = false; + await options.generate(); + } while (rerunRequested && !stopping); + })().finally(() => { + activeGeneration = undefined; + settleIfIdle(); + }); + await activeGeneration; + startupComplete = true; + if (stopping) { + return result; + } + options.onReady?.(watchRoot); + } catch (error: unknown) { + cleanup(); + throw error; + } + return result; +} diff --git a/packages/cli/src/commands/typegen.test.ts b/packages/cli/src/commands/typegen.test.ts index 36a4f841b..af355a72e 100644 --- a/packages/cli/src/commands/typegen.test.ts +++ b/packages/cli/src/commands/typegen.test.ts @@ -1,10 +1,9 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, stat, utimes, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; - -import ts from 'typescript'; import { tsImport } from 'tsx/esm/api'; +import ts from 'typescript'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { runTypegenCommand, type TypegenCommandRuntimeOptions } from './typegen.js'; @@ -177,6 +176,137 @@ describe('fluo typegen', () => { expect(fixture.stdout.join('')).toContain('UNCHANGED'); }); + it('checks an unchanged artifact without rewriting it', async () => { + // Given: the target already contains the authoritative generated artifact with an old timestamp. + const fixture = await createFixture(); + await runTypegenCommand([fixtureModulePath, '--output', fixture.outputPath], fixture.runtime); + const oldTimestamp = new Date('2020-01-01T00:00:00.000Z'); + await utimes(fixture.outputPath, oldTimestamp, oldTimestamp); + fixture.stdout.splice(0); + + // When: CI runs non-mutating check mode. + const exitCode = await runTypegenCommand([ + fixtureModulePath, + '--output', + fixture.outputPath, + '--check', + ], fixture.runtime); + + // Then: the check succeeds, reports UNCHANGED, and preserves the file timestamp. + expect(exitCode).toBe(0); + expect(fixture.stderr).toEqual([]); + expect(fixture.stdout).toEqual([`UNCHANGED ${fixture.outputPath}\n`]); + expect((await stat(fixture.outputPath)).mtimeMs).toBe(oldTimestamp.getTime()); + }); + + it('reports a missing check target without creating it', async () => { + // Given: no generated artifact exists at the requested output path. + const fixture = await createFixture(); + + // When: CI checks the missing target. + const exitCode = await runTypegenCommand([ + fixtureModulePath, + '--output', + fixture.outputPath, + '--check', + ], fixture.runtime); + + // Then: the missing status has its stable exit code and no target is written. + expect(exitCode).toBe(2); + expect(fixture.stdout).toEqual([]); + expect(fixture.stderr.join('')).toContain(`MISSING ${fixture.outputPath}`); + await expect(readFile(fixture.outputPath, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('reports a stale current-version artifact without updating it', async () => { + // Given: the target is structurally valid but differs from the authoritative catalog output. + const fixture = await createFixture(); + await runTypegenCommand([fixtureModulePath, '--output', fixture.outputPath], fixture.runtime); + const stale = (await readFile(fixture.outputPath, 'utf8')).replaceAll('/products', '/stale-products'); + await writeFile(fixture.outputPath, stale, 'utf8'); + fixture.stdout.splice(0); + + // When: CI checks the stale artifact. + const exitCode = await runTypegenCommand([ + fixtureModulePath, + '--output', + fixture.outputPath, + '--check', + ], fixture.runtime); + + // Then: stale output is actionable and remains untouched. + expect(exitCode).toBe(3); + expect(fixture.stdout).toEqual([]); + expect(fixture.stderr.join('')).toContain(`STALE ${fixture.outputPath}`); + expect(await readFile(fixture.outputPath, 'utf8')).toBe(stale); + }); + + it('reports malformed output separately from stale output', async () => { + // Given: the target does not contain a complete generated artifact header and body. + const fixture = await createFixture(); + await runTypegenCommand([fixtureModulePath, '--output', fixture.outputPath], fixture.runtime); + await writeFile(fixture.outputPath, 'not a generated artifact\n', 'utf8'); + fixture.stdout.splice(0); + + // When: CI checks the malformed target. + const exitCode = await runTypegenCommand([ + fixtureModulePath, + '--output', + fixture.outputPath, + '--check', + ], fixture.runtime); + + // Then: malformed output has a dedicated diagnostic and exit code. + expect(exitCode).toBe(4); + expect(fixture.stdout).toEqual([]); + expect(fixture.stderr.join('')).toContain(`MALFORMED ${fixture.outputPath}`); + expect(await readFile(fixture.outputPath, 'utf8')).toBe('not a generated artifact\n'); + }); + + it('reports an unsupported artifact version without replacing it', async () => { + // Given: the target was generated by a newer artifact schema. + const fixture = await createFixture(); + await runTypegenCommand([fixtureModulePath, '--output', fixture.outputPath], fixture.runtime); + const unsupported = (await readFile(fixture.outputPath, 'utf8')).replace('Artifact version: 1.', 'Artifact version: 99.'); + await writeFile(fixture.outputPath, unsupported, 'utf8'); + fixture.stdout.splice(0); + + // When: CI checks the newer artifact. + const exitCode = await runTypegenCommand([ + fixtureModulePath, + '--output', + fixture.outputPath, + '--check', + ], fixture.runtime); + + // Then: the unsupported version remains distinguishable and untouched. + expect(exitCode).toBe(5); + expect(fixture.stdout).toEqual([]); + expect(fixture.stderr.join('')).toContain(`UNSUPPORTED_VERSION ${fixture.outputPath}`); + expect(fixture.stderr.join('')).toContain('version 99'); + expect(await readFile(fixture.outputPath, 'utf8')).toBe(unsupported); + }); + + it('rejects combining non-mutating check mode with long-running watch mode', async () => { + // Given: one invocation requests two mutually exclusive lifecycle modes. + const fixture = await createFixture(); + const loadReactTypegenModules = vi.fn(fixture.runtime.loadReactTypegenModules); + + // When: argument parsing resolves the conflicting flags. + const exitCode = await runTypegenCommand([ + fixtureModulePath, + '--output', + fixture.outputPath, + '--check', + '--watch', + ], { ...fixture.runtime, loadReactTypegenModules }); + + // Then: the command fails before loading or bootstrapping application code. + expect(exitCode).toBe(1); + expect(loadReactTypegenModules).not.toHaveBeenCalled(); + expect(fixture.stderr).toEqual(['fluo typegen accepts only one of --check or --watch.\n']); + }); + it('closes the bootstrapped application when React page catalog projection fails', async () => { // Given const fixture = await createFixture(); diff --git a/packages/cli/src/commands/typegen.ts b/packages/cli/src/commands/typegen.ts index 241449b37..ac4d19025 100644 --- a/packages/cli/src/commands/typegen.ts +++ b/packages/cli/src/commands/typegen.ts @@ -1,41 +1,35 @@ -import { existsSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { dirname, extname, resolve } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -import { tsImport } from 'tsx/esm/api'; +import { resolve } from 'node:path'; +import { TYPEGEN_EXIT_CODES } from '../typegen-contract.js'; import { typegenUsage } from '../usage.js'; +import { + checkTypegenArtifact, + type TypegenArtifactCheck, + writeTypegenArtifact, +} from './typegen-artifact.js'; +import { runTypegenGenerationProcess } from './typegen-generation-process.js'; +import { parseTypegenArgs, TypegenCommandError } from './typegen-options.js'; +import { + createTypegenSource, + inspectReactTypegenArtifact, + loadReactTypegenModules, + type ReactTypegenModules, +} from './typegen-source.js'; +import { runTypegenWatch } from './typegen-watch.js'; type CliStream = { write(message: string): unknown; }; -type ReactTypegenModules = { - readonly react: object; - readonly runtime: object; - readonly typegen: object; -}; - -type ParsedTypegenArgs = { - readonly exportName: string; - readonly modulePath: string; - readonly outputPath: string; -}; - -const TYPESCRIPT_MODULE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']); -const TYPEGEN_MODULE_IDS = ['@fluojs/react', '@fluojs/react/typegen', '@fluojs/runtime'] as const; - -class TypegenCommandError extends Error { - readonly name = 'TypegenCommandError'; -} - /** Runtime options for the React page typegen command. */ export interface TypegenCommandRuntimeOptions { /** Current working directory for module and output path resolution. */ readonly cwd?: string; - /** Optional build-tooling module loader override for tests and editor integrations. */ + /** + * Optional build-tooling module loader override for tests and editor integrations. + * Supplying it keeps application evaluation in the caller process and uses the returned + * namespaces for TypeScript and native modules; omitting it uses isolated generation children. + */ readonly loadReactTypegenModules?: (cwd: string) => Promise; /** Custom stream for error output. */ readonly stderr?: CliStream; @@ -43,157 +37,37 @@ export interface TypegenCommandRuntimeOptions { readonly stdout?: CliStream; } -function parseTypegenArgs(argv: readonly string[]): ParsedTypegenArgs { - let exportName = 'AppModule'; - let modulePath: string | undefined; - let outputPath: string | undefined; - - for (let index = 0; index < argv.length; index += 1) { - const option = argv[index]; - if (option === '--output') { - const next = argv[index + 1]; - if (next === undefined || next.startsWith('-')) { - throw new TypegenCommandError('Expected --output to have a file path value.'); - } - outputPath = next; - index += 1; - continue; - } - - if (option === '--export') { - const next = argv[index + 1]; - if (next === undefined || next.startsWith('-')) { - throw new TypegenCommandError('Expected --export to have a symbol name value.'); - } - exportName = next; - index += 1; - continue; - } - - if (option?.startsWith('-')) { - throw new TypegenCommandError(`Unknown option for typegen command: ${option}`); - } - - if (option !== undefined) { - if (modulePath !== undefined) { - throw new TypegenCommandError(`Unexpected extra positional argument: ${option}`); - } - modulePath = option; - } - } - - if (modulePath === undefined || outputPath === undefined) { - throw new TypegenCommandError('Usage: fluo typegen --output [--export ]'); - } - - return { exportName, modulePath, outputPath }; -} - -function isModuleNotFoundError(error: unknown): boolean { - if (typeof error !== 'object' || error === null || !('code' in error)) { - return false; - } - - return error.code === 'MODULE_NOT_FOUND' || error.code === 'ERR_MODULE_NOT_FOUND'; -} - -async function importProjectModule(moduleId: string, cwd: string): Promise { - const resolvers = [createRequire(resolve(cwd, 'package.json')), createRequire(import.meta.url)]; - for (const resolver of resolvers) { - let modulePath: string; - try { - modulePath = resolver.resolve(moduleId); - } catch (error: unknown) { - if (!isModuleNotFoundError(error)) { - throw error; - } - continue; - } - return import(pathToFileURL(modulePath).href); - } - - throw new TypegenCommandError(`Unable to resolve ${moduleId} from the inspected project.`); -} - -async function loadReactTypegenModules(cwd: string): Promise { - const [react, typegen, runtime] = await Promise.all(TYPEGEN_MODULE_IDS.map((moduleId) => importProjectModule(moduleId, cwd))); - return { react, runtime, typegen }; -} - -async function importApplicationModule(modulePath: string): Promise { - const moduleUrl = pathToFileURL(modulePath).href; - if (!TYPESCRIPT_MODULE_EXTENSIONS.has(extname(modulePath))) { - return import(moduleUrl); - } - - const tsconfigPath = resolve(dirname(modulePath), 'tsconfig.json'); - return existsSync(tsconfigPath) - ? tsImport(moduleUrl, { parentURL: import.meta.url, tsconfig: tsconfigPath }) - : tsImport(moduleUrl, import.meta.url); -} - -function requireFunction(owner: object, name: string): (...args: readonly unknown[]) => unknown { - const value = Reflect.get(owner, name); - if (typeof value !== 'function') { - throw new TypegenCommandError(`Required typegen function ${name} is unavailable.`); - } - return value; -} - -async function createTypegenSource(parsed: ParsedTypegenArgs, cwd: string, modules: ReactTypegenModules): Promise { - const importedApplication = await importApplicationModule(resolve(cwd, parsed.modulePath)); - const rootModule = Reflect.get(importedApplication, parsed.exportName); - if (typeof rootModule !== 'function') { - throw new TypegenCommandError(`Export "${parsed.exportName}" is not a module class constructor.`); - } - - const factory = Reflect.get(modules.runtime, 'FluoFactory'); - if (typeof factory !== 'function') { - throw new TypegenCommandError('Required runtime FluoFactory is unavailable.'); - } - const create = requireFunction(factory, 'create'); - const application = await Reflect.apply(create, factory, [rootModule]); - if (typeof application !== 'object' || application === null) { - throw new TypegenCommandError('Runtime application bootstrap returned an invalid value.'); - } - - const close = requireFunction(application, 'close'); - try { - const dispatcher = Reflect.get(application, 'dispatcher'); - if (typeof dispatcher !== 'object' || dispatcher === null) { - throw new TypegenCommandError('Runtime application dispatcher is unavailable.'); - } - const descriptors = Reflect.apply(requireFunction(dispatcher, 'describeRoutes'), dispatcher, []); - if (!Array.isArray(descriptors)) { - throw new TypegenCommandError('Runtime route descriptors are unavailable.'); - } - const catalog = Reflect.apply(requireFunction(modules.react, 'createReactPageCatalog'), undefined, [descriptors]); - const source = Reflect.apply(requireFunction(modules.typegen, 'generateReactPageTypes'), undefined, [catalog]); - if (typeof source !== 'string') { - throw new TypegenCommandError('React page typegen returned an invalid artifact.'); - } - return source; - } finally { - await Reflect.apply(close, application, []); - } -} - -async function writeTypegenArtifact(outputPath: string, source: string): Promise<'CREATE' | 'UNCHANGED' | 'UPDATE'> { - if (existsSync(outputPath)) { - if (await readFile(outputPath, 'utf8') === source) { - return 'UNCHANGED'; +function reportCheckResult( + check: TypegenArtifactCheck, + outputPath: string, + stdout: CliStream, + stderr: CliStream, +): number { + switch (check.status) { + case 'UNCHANGED': + stdout.write(`UNCHANGED ${outputPath}\n`); + return TYPEGEN_EXIT_CODES.SUCCESS; + case 'MISSING': + stderr.write(`MISSING ${outputPath}: run fluo typegen without --check to create the artifact.\n`); + return TYPEGEN_EXIT_CODES.MISSING; + case 'STALE': + stderr.write(`STALE ${outputPath}: generated React page types differ from the authoritative compiled catalog.\n`); + return TYPEGEN_EXIT_CODES.STALE; + case 'MALFORMED': + stderr.write(`MALFORMED ${outputPath}: the target is not a complete React page typegen artifact.\n`); + return TYPEGEN_EXIT_CODES.MALFORMED; + case 'UNSUPPORTED_VERSION': + stderr.write(`UNSUPPORTED_VERSION ${outputPath}: artifact version ${String(check.version)} is not supported by this CLI.\n`); + return TYPEGEN_EXIT_CODES.UNSUPPORTED_VERSION; + default: { + const unreachable: never = check; + throw new TypegenCommandError(`Unexpected typegen check result: ${String(unreachable)}`); } - await writeFile(outputPath, source, 'utf8'); - return 'UPDATE'; } - - await mkdir(dirname(outputPath), { recursive: true }); - await writeFile(outputPath, source, 'utf8'); - return 'CREATE'; } /** - * Generates one application-owned React page type artifact. + * Generates, checks, or watches one application-owned React page type artifact. * * @param argv Command arguments after `typegen`. * @param runtime Runtime overrides for module loading and output streams. @@ -214,14 +88,47 @@ export async function runTypegenCommand( } const parsed = parseTypegenArgs(argv); - const modules = await (runtime.loadReactTypegenModules ?? loadReactTypegenModules)(cwd); - const source = await createTypegenSource(parsed, cwd, modules); + const customModules = runtime.loadReactTypegenModules?.(cwd); const outputPath = resolve(cwd, parsed.outputPath); + const generateSource = async () => customModules === undefined + ? runTypegenGenerationProcess({ cwd, exportName: parsed.exportName, modulePath: parsed.modulePath }) + : createTypegenSource({ cwd, modules: await customModules, parsed }); + const generateAndWrite = async () => { + const source = await generateSource(); + const action = await writeTypegenArtifact(outputPath, source); + stdout.write(`${action} ${outputPath}\n`); + }; + if (parsed.watch) { + return await runTypegenWatch({ + generate: generateAndWrite, + modulePath: resolve(cwd, parsed.modulePath), + onError(error) { + stderr.write(`ERROR ${outputPath}: ${error instanceof Error ? error.message : String(error)}\n`); + }, + onReady(watchRoot) { + stdout.write(`WATCHING ${watchRoot}\n`); + }, + outputPath, + }); + } + + const source = await generateSource(); + if (parsed.check) { + const modules = customModules === undefined + ? await loadReactTypegenModules(cwd) + : await customModules; + const check = await checkTypegenArtifact( + outputPath, + source, + (existingSource) => inspectReactTypegenArtifact(modules, existingSource), + ); + return reportCheckResult(check, outputPath, stdout, stderr); + } const action = await writeTypegenArtifact(outputPath, source); stdout.write(`${action} ${outputPath}\n`); - return 0; + return TYPEGEN_EXIT_CODES.SUCCESS; } catch (error: unknown) { stderr.write(`${error instanceof Error ? error.message : String(error)}\n`); - return 1; + return TYPEGEN_EXIT_CODES.ERROR; } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index f7014aff8..ae33a511d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,7 +1,7 @@ -export { runCli, type CliRuntimeOptions } from './run-cli.js'; -export { runGenerateCommand, type GeneratePlanAction, type GeneratePlanEntry, type GenerateResult } from './public-generate.js'; -export { inspectUsage, runInspectCommand, type InspectCommandRuntimeOptions } from './public-inspect.js'; -export { newUsage, runNewCommand, type NewCommandRuntimeOptions } from './public-new.js'; -export { runTypegenCommand, type TypegenCommandRuntimeOptions, typegenUsage } from './public-typegen.js'; export { CliPromptCancelledError } from './prompt-cancel.js'; -export type { GenerateOptions, GeneratedFile, GeneratorKind, ModuleRegistration } from './types.js'; +export { type GeneratePlanAction, type GeneratePlanEntry, type GenerateResult, runGenerateCommand } from './public-generate.js'; +export { type InspectCommandRuntimeOptions, inspectUsage, runInspectCommand } from './public-inspect.js'; +export { type NewCommandRuntimeOptions, newUsage, runNewCommand } from './public-new.js'; +export { runTypegenCommand, TYPEGEN_EXIT_CODES, type TypegenCommandRuntimeOptions, typegenUsage } from './public-typegen.js'; +export { type CliRuntimeOptions, runCli } from './run-cli.js'; +export type { GeneratedFile, GenerateOptions, GeneratorKind, ModuleRegistration } from './types.js'; diff --git a/packages/cli/src/public-api.test.ts b/packages/cli/src/public-api.test.ts index 00a6f63a3..5f6e5027b 100644 --- a/packages/cli/src/public-api.test.ts +++ b/packages/cli/src/public-api.test.ts @@ -20,6 +20,7 @@ import { runInspectCommand, runNewCommand, runTypegenCommand, + TYPEGEN_EXIT_CODES, type TypegenCommandRuntimeOptions, typegenUsage, } from './index.js'; @@ -124,6 +125,14 @@ describe('public CLI package API', () => { expect(typeof runGenerateCommand).toBe('function'); expect(typeof runInspectCommand).toBe('function'); expect(typeof runTypegenCommand).toBe('function'); + expect(TYPEGEN_EXIT_CODES).toEqual({ + ERROR: 1, + MALFORMED: 4, + MISSING: 2, + STALE: 3, + SUCCESS: 0, + UNSUPPORTED_VERSION: 5, + }); expect(inspectUsage()).toContain('Usage: fluo inspect'); expect(typegenUsage()).toContain('Usage: fluo typegen'); expect(result.wiringBehavior).toBe('auto-registered'); diff --git a/packages/cli/src/public-typegen.ts b/packages/cli/src/public-typegen.ts index 9fc07e1ea..3d7311379 100644 --- a/packages/cli/src/public-typegen.ts +++ b/packages/cli/src/public-typegen.ts @@ -1,11 +1,12 @@ import type { TypegenCommandRuntimeOptions } from './commands/typegen.js'; +import { TYPEGEN_EXIT_CODES } from './typegen-contract.js'; import { typegenUsage } from './usage.js'; export type { TypegenCommandRuntimeOptions } from './commands/typegen.js'; -export { typegenUsage }; +export { TYPEGEN_EXIT_CODES, typegenUsage }; /** - * Runs React page type generation through a lazy implementation import. + * Runs React page generation, checking, or watch mode through a lazy implementation import. * * @param argv Command arguments after `typegen`. * @param runtime Runtime overrides for programmatic callers. diff --git a/packages/cli/src/typegen-contract.ts b/packages/cli/src/typegen-contract.ts new file mode 100644 index 000000000..855771baa --- /dev/null +++ b/packages/cli/src/typegen-contract.ts @@ -0,0 +1,9 @@ +/** Stable process exit codes returned by `fluo typegen`. */ +export const TYPEGEN_EXIT_CODES = { + ERROR: 1, + MALFORMED: 4, + MISSING: 2, + STALE: 3, + SUCCESS: 0, + UNSUPPORTED_VERSION: 5, +} as const; diff --git a/packages/cli/src/usage.ts b/packages/cli/src/usage.ts index e4eda38c9..b6f099cd8 100644 --- a/packages/cli/src/usage.ts +++ b/packages/cli/src/usage.ts @@ -133,6 +133,11 @@ const INSPECT_OPTION_HELP: CommandOptionHelpEntry[] = [ ]; const TYPEGEN_OPTION_HELP: CommandOptionHelpEntry[] = [ + { + aliases: [], + description: 'Check that the target exactly matches the compiled React page catalog without writing it.', + option: '--check', + }, { aliases: [], description: 'Write the generated React page route types to this file.', @@ -143,6 +148,11 @@ const TYPEGEN_OPTION_HELP: CommandOptionHelpEntry[] = [ description: 'Select the exported module symbol name (default: AppModule).', option: '--export ', }, + { + aliases: [], + description: 'Regenerate after coalesced changes under the application module directory.', + option: '--watch', + }, { aliases: ['-h'], description: 'Show help for the typegen command.', diff --git a/packages/react/README.ko.md b/packages/react/README.ko.md index a6f49781e..8d292dbef 100644 --- a/packages/react/README.ko.md +++ b/packages/react/README.ko.md @@ -21,6 +21,7 @@ fluo 애플리케이션을 위한 런타임 중립 React 통합입니다. - [Router 및 Path Decorators](#router-및-path-decorators) - [Bootstrap-Resolved Page Catalog](#bootstrap-resolved-page-catalog) - [Path-Only Page Type Generation](#path-only-page-type-generation) +- [Consumer Testing Loop](#consumer-testing-loop) - [Web Streams SSR](#web-streams-ssr) - [Hydration Asset Contract](#hydration-asset-contract) - [Vite Asset Manifest Integration](#vite-asset-manifest-integration) @@ -519,11 +520,19 @@ application-owned TypeScript route helper를 작성합니다. 출력은 전용 ` ```bash fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts fluo typegen ./src/admin.ts --export AdminModule --output ./src/generated/admin-pages.ts +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --check +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --watch ``` 명령은 필요하면 output directory를 만들고 stale output을 deterministic하게 덮어쓰며 `CREATE`, -`UPDATE`, `UNCHANGED` 중 하나를 보고합니다. `app.dispatcher.describeRoutes()`를 읽은 뒤 bootstrap한 -application을 닫습니다. 생성 artifact는 application code에서 import합니다. +`UPDATE`, `UNCHANGED` 중 하나를 보고합니다. Atomic replacement는 partial publication을 막고 byte-identical +run은 파일을 다시 쓰지 않습니다. `--check`는 write 없이 exact generated byte를 비교하며 structure가 +손상된 현재-version body를 stale이 아닌 malformed로 분류합니다. `--watch`는 startup generation 전에 +application-module watcher를 설치하고, 해당 generation 중 관찰된 change를 readiness 전에 한 번 rerun한 +뒤 coalesced change를 serialize하며 generation failure 뒤에도 마지막 valid artifact를 보존합니다. 각 +generation은 native `.js`와 `.mjs` dependency를 현재 module graph에서 평가합니다. 모든 run은 +`app.dispatcher.describeRoutes()`를 읽은 뒤 bootstrap한 application을 닫습니다. 생성 artifact는 +application code에서 import합니다. ```ts import { @@ -584,6 +593,34 @@ Compiled catalog만으로는 version selection이 URI, header, media type, custo 왔는지 구분할 수 없으므로 하나의 absolute href를 생성하면 실제와 다른 URL contract를 약속할 수 있기 때문입니다. +## Consumer Testing Loop + +기존 React, HTTP, testing, TypeScript, browser seam을 하나의 layered consumer loop로 사용하세요. 반복 setup은 +일반 fixture로 이미 격리할 수 있고 request test의 대상은 real dispatcher여야 하므로 React-specific testing +helper를 추가할 필요가 없습니다. + +1. **Render-policy unit**: Pure policy 및 metadata helper를 직접 호출합니다. Browser bootstrap 없이 + broad-to-specific layout order, nearest fallback 또는 metadata precedence, configuration diagnostic을 검증합니다. +2. **Request dispatch**: 작성한 module을 `createTestApp({ rootModule })`로 만들고 explicit `@Path(...)` route를 + `app.request(...).send()`로 요청한 뒤 `finally`에서 app을 닫습니다. Direct `ReactElement` return과 + missing-renderer diagnostic을 포함해 real HTTP dispatcher, request scope, response ownership, application + renderer를 함께 검증합니다. +3. **Generated-route compilation**: Bootstrapped `HandlerDescriptor` catalog에서 generate하고 positive/negative + consumer fixture에 TypeScript를 실행하며 CI에서 `fluo typegen ... --check`를 실행합니다. Positive fixture는 + known route id와 complete param을 사용하고 negative fixture는 unknown id, missing/extra param 및 stale, + malformed, missing, unsupported-version artifact를 검증합니다. +4. **Hydration**: 같은 application component를 server에서 render하고 `hydrateRoot(...)`로 hydrate합니다. + Positive fixture는 diagnostic 없이 interaction이 동작해야 합니다. 의도적으로 mismatch한 fixture는 + `onRecoverableError`를 capture해 mismatch reporting 자체의 regression을 막습니다. +5. **Production browser 및 progressive enhancement**: Build asset을 대상으로 Playwright를 실행하고 + console/page error에서 실패하게 합니다. 별도 `javaScriptEnabled: false` context에서 native form을 일반 + `POST` → `303` → `GET` flow로 submit합니다. + +Executable reference는 `src/render-policy.test.ts`, `src/direct-page-return.test.ts`, +`../cli/src/commands/typegen-navigation.test.ts`, `../../examples/react-vite-ssr/src/app.test.ts`, +`../../examples/react-vite-ssr/src/hydration.test.ts`, +`../../examples/react-vite-ssr/tests/production-hydration.spec.ts`입니다. + ## Web Streams SSR Configured application page renderer를 통해 `ReactElement` 하나를 반환하거나 @@ -1140,10 +1177,12 @@ stable subpath를 추가하지 않고 deprecation window도 시작하지 않습 descriptor에서 freeze된 read-only React page catalog를 만듭니다. - `ReactPageCatalogEntry` — effective HTTP method/path/version/params와 originating router/handler를 담는 type-only bootstrap-resolved page descriptor입니다. -- `@fluojs/react/typegen` subpath — package root를 넓히지 않고 deterministic path-only declaration과 - absolute href builder, route-bound `Link` props, typed `push`/`replace` method를 생성하는 - `generateReactPageTypes(...)`, `ReactPageTypegenError`, - `REACT_PAGE_TYPEGEN_ERROR_CODES`, `ReactPageTypegenErrorCode`를 제공합니다. +- `@fluojs/react/typegen` subpath — package root를 넓히거나 runtime route table을 추가하지 않고 + deterministic path-only declaration, versioned artifact check, absolute href builder, route-bound `Link` prop, + typed `push`/`replace` method를 제공하는 `generateReactPageTypes(...)`, + `inspectReactPageTypeArtifact(...)`, `REACT_PAGE_TYPEGEN_ARTIFACT_VERSION`, + `ReactPageTypeArtifactInspection`, `ReactPageTypegenError`, `REACT_PAGE_TYPEGEN_ERROR_CODES`, + `ReactPageTypegenErrorCode`를 제공합니다. - `ReactModule` — `forRoot(...)`가 기존 fluo module/controller metadata path를 통해 React router를 등록하는 런타임 중립 module facade입니다. - `REACT_PAGE_RENDERER` — `ReactModule.forRoot({ renderPage })`가 등록하는 application page renderer의 diff --git a/packages/react/README.md b/packages/react/README.md index 2d753d0d9..a61b65393 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -21,6 +21,7 @@ Runtime-neutral React integration for fluo applications. - [Router and Path Decorators](#router-and-path-decorators) - [Bootstrap-Resolved Page Catalog](#bootstrap-resolved-page-catalog) - [Path-Only Page Type Generation](#path-only-page-type-generation) +- [Consumer Testing Loop](#consumer-testing-loop) - [Web Streams SSR](#web-streams-ssr) - [Hydration Asset Contract](#hydration-asset-contract) - [Vite Asset Manifest Integration](#vite-asset-manifest-integration) @@ -526,11 +527,20 @@ write application-owned TypeScript route helpers. The output comes from the dedi ```bash fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts fluo typegen ./src/admin.ts --export AdminModule --output ./src/generated/admin-pages.ts +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --check +fluo typegen ./src/app.ts --output ./src/generated/react-pages.ts --watch ``` The command creates the output directory when needed, overwrites stale output deterministically, -and reports `CREATE`, `UPDATE`, or `UNCHANGED`. It closes the bootstrapped application after reading -`app.dispatcher.describeRoutes()`. Import the generated artifact from application code: +and reports `CREATE`, `UPDATE`, or `UNCHANGED`. Atomic replacement prevents partial publication and +byte-identical runs do not rewrite the file. `--check` compares exact generated bytes without writes +and classifies a structurally corrupted current-version body as malformed rather than stale. +`--watch` installs its application-module watcher before startup generation, reruns once for changes +observed during that generation before reporting ready, and then serializes coalesced changes while +preserving the last valid artifact after generation failures. Native `.js` and `.mjs` dependencies +are evaluated from a current module graph for each generation. Every run closes its bootstrapped +application after reading `app.dispatcher.describeRoutes()`. Import the generated artifact from +application code: ```ts import { @@ -591,6 +601,35 @@ routes, optional parameters, or a client route tree. Typegen rejects every catal URI, a header, media type, or a custom strategy; emitting one absolute href would otherwise claim a URL contract that may be false. +## Consumer Testing Loop + +Use existing React, HTTP, testing, TypeScript, and browser seams as one layered consumer loop. No +React-specific testing helper is needed because the repeated setup is already isolated by ordinary +fixtures and the real dispatcher remains the subject of request tests. + +1. **Render-policy units**: call pure policy and metadata helpers directly. Cover broad-to-specific + layout order, nearest fallback or metadata precedence, and configuration diagnostics without + bootstrapping a browser. +2. **Request dispatch**: build the authored module with `createTestApp({ rootModule })`, request the + explicit `@Path(...)` route through `app.request(...).send()`, and close the app in `finally`. + Include a direct `ReactElement` return and the missing-renderer diagnostic so the real HTTP + dispatcher, request scope, response ownership, and application renderer stay covered. +3. **Generated-route compilation**: generate from the bootstrapped `HandlerDescriptor` catalog, run + TypeScript over positive and negative consumer fixtures, and run `fluo typegen ... --check` in CI. + Positive fixtures use known route ids and complete params; negative fixtures cover unknown ids, + missing or extra params, and stale, malformed, missing, or unsupported-version artifacts. +4. **Hydration**: render the same application component on the server and hydrate it with + `hydrateRoot(...)`. The positive fixture expects interaction with no diagnostics. A deliberately + mismatched fixture captures `onRecoverableError` so mismatch reporting itself cannot regress. +5. **Production browser and progressive enhancement**: run Playwright against built assets, fail on + console/page errors, and use a separate `javaScriptEnabled: false` context to submit the native + form through the ordinary `POST` → `303` → `GET` flow. + +Executable references are `src/render-policy.test.ts`, `src/direct-page-return.test.ts`, +`../cli/src/commands/typegen-navigation.test.ts`, `../../examples/react-vite-ssr/src/app.test.ts`, +`../../examples/react-vite-ssr/src/hydration.test.ts`, and +`../../examples/react-vite-ssr/tests/production-hydration.spec.ts`. + ## Web Streams SSR Return one `ReactElement` through a configured application page renderer, or return @@ -1157,10 +1196,12 @@ This package currently does **not** provide: compiled HTTP descriptors without participating in matching or dispatch. - `ReactPageCatalogEntry` — type-only bootstrap-resolved page descriptor with effective HTTP method/path/version/params plus the originating router and handler. -- `@fluojs/react/typegen` subpath — `generateReactPageTypes(...)`, `ReactPageTypegenError`, - `REACT_PAGE_TYPEGEN_ERROR_CODES`, and `ReactPageTypegenErrorCode` for deterministic path-only - declarations, absolute href builders, route-bound `Link` props, and typed `push`/`replace` methods - without widening the package root or adding a runtime route table. +- `@fluojs/react/typegen` subpath — `generateReactPageTypes(...)`, + `inspectReactPageTypeArtifact(...)`, `REACT_PAGE_TYPEGEN_ARTIFACT_VERSION`, + `ReactPageTypeArtifactInspection`, `ReactPageTypegenError`, `REACT_PAGE_TYPEGEN_ERROR_CODES`, and + `ReactPageTypegenErrorCode` for deterministic path-only declarations, versioned artifact checks, + absolute href builders, route-bound `Link` props, and typed `push`/`replace` methods without + widening the package root or adding a runtime route table. - `ReactModule` — runtime-neutral module facade whose `forRoot(...)` registers React routers through the existing fluo module/controller metadata path. - `REACT_PAGE_RENDERER` — dependency-injection token for the application page renderer registered by diff --git a/packages/react/src/typegen-artifact.ts b/packages/react/src/typegen-artifact.ts new file mode 100644 index 000000000..1c9f658a2 --- /dev/null +++ b/packages/react/src/typegen-artifact.ts @@ -0,0 +1,116 @@ +import type { ReactPageCatalogEntry } from './page-catalog.js'; + +const JSON_STRING_SOURCE = '"(?:\\\\.|[^"\\\\])*"'; +const PATH_LINE_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): (${JSON_STRING_SOURCE});$`, 'u'); +const PARAM_OPEN_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): \\{$`, 'u'); +const PARAM_LINE_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): string;$`, 'u'); +const PARAM_UNDEFINED_PATTERN = new RegExp(`^ readonly (${JSON_STRING_SOURCE}): undefined;$`, 'u'); + +function parseJsonString(value: string): string | undefined { + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === 'string' ? parsed : undefined; + } catch { + return undefined; + } +} + +function readSection(lines: readonly string[], opening: string, closing: string): readonly string[] | undefined { + const start = lines.indexOf(opening); + if (start < 0) { + return undefined; + } + const end = lines.indexOf(closing, start + 1); + return end < 0 ? undefined : lines.slice(start + 1, end); +} + +function parsePaths(lines: readonly string[]): ReadonlyMap | undefined { + if (lines.includes('export type ReactPagePathById = Readonly>;')) { + return new Map(); + } + const section = readSection(lines, 'export interface ReactPagePathById {', '}'); + if (section === undefined || section.length === 0) { + return undefined; + } + const paths = new Map(); + for (const line of section) { + const match = PATH_LINE_PATTERN.exec(line); + const id = match?.[1] === undefined ? undefined : parseJsonString(match[1]); + const path = match?.[2] === undefined ? undefined : parseJsonString(match[2]); + if (id === undefined || path === undefined || paths.has(id)) { + return undefined; + } + paths.set(id, path); + } + return paths; +} + +function parseParams(lines: readonly string[]): ReadonlyMap | undefined { + if (lines.includes('export type ReactPageParamsById = Readonly>;')) { + return new Map(); + } + const section = readSection(lines, 'export interface ReactPageParamsById {', '}'); + if (section === undefined || section.length === 0) { + return undefined; + } + const paramsById = new Map(); + for (let index = 0; index < section.length; index += 1) { + const line = section[index] ?? ''; + const undefinedMatch = PARAM_UNDEFINED_PATTERN.exec(line); + const undefinedId = undefinedMatch?.[1] === undefined ? undefined : parseJsonString(undefinedMatch[1]); + if (undefinedId !== undefined) { + if (paramsById.has(undefinedId)) { + return undefined; + } + paramsById.set(undefinedId, []); + continue; + } + + const openMatch = PARAM_OPEN_PATTERN.exec(line); + const id = openMatch?.[1] === undefined ? undefined : parseJsonString(openMatch[1]); + if (id === undefined || paramsById.has(id)) { + return undefined; + } + const params: string[] = []; + index += 1; + while (index < section.length && section[index] !== ' };') { + const paramMatch = PARAM_LINE_PATTERN.exec(section[index] ?? ''); + const param = paramMatch?.[1] === undefined ? undefined : parseJsonString(paramMatch[1]); + if (param === undefined) { + return undefined; + } + params.push(param); + index += 1; + } + if (section[index] !== ' };' || params.length === 0) { + return undefined; + } + paramsById.set(id, params); + } + return paramsById; +} + +/** + * Parses the route catalog encoded in one canonical generated React page artifact. + * + * @param source Generated artifact source to inspect. + * @returns Parsed catalog entries, or `undefined` when the artifact body is malformed. + */ +export function parseGeneratedReactPageCatalog(source: string): readonly ReactPageCatalogEntry[] | undefined { + const lines = source.replaceAll('\r\n', '\n').split('\n'); + const paths = parsePaths(lines); + const paramsById = parseParams(lines); + if (paths === undefined || paramsById === undefined || paths.size !== paramsById.size) { + return undefined; + } + + const catalog: ReactPageCatalogEntry[] = []; + for (const [id, path] of paths) { + const params = paramsById.get(id); + if (params === undefined) { + return undefined; + } + catalog.push({ handler: '', id, kind: 'react-page', method: 'GET', params, path, router: '' }); + } + return catalog; +} diff --git a/packages/react/src/typegen.test.ts b/packages/react/src/typegen.test.ts index 7eddba505..ab92520cb 100644 --- a/packages/react/src/typegen.test.ts +++ b/packages/react/src/typegen.test.ts @@ -3,7 +3,11 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import type { ReactPageCatalogEntry } from './page-catalog.js'; -import { generateReactPageTypes } from './typegen.js'; +import { + generateReactPageTypes, + inspectReactPageTypeArtifact, + REACT_PAGE_TYPEGEN_ARTIFACT_VERSION, +} from './typegen.js'; const catalog = [ { @@ -27,6 +31,56 @@ const catalog = [ ] satisfies readonly ReactPageCatalogEntry[]; describe('@fluojs/react/typegen', () => { + it('emits and recognizes the current deterministic artifact version', () => { + // Given: one authoritative React page catalog. + const pages = catalog; + + // When: typegen renders and inspects its own artifact. + const output = generateReactPageTypes(pages); + const inspection = inspectReactPageTypeArtifact(output); + + // Then: the first line identifies the stable artifact schema and inspection accepts it. + expect(output.split('\n', 1)).toEqual([ + `/* Generated by @fluojs/react/typegen. Artifact version: ${REACT_PAGE_TYPEGEN_ARTIFACT_VERSION}. Do not edit manually. */`, + ]); + expect(inspection).toEqual({ + status: 'valid', + version: REACT_PAGE_TYPEGEN_ARTIFACT_VERSION, + }); + }); + + it('distinguishes malformed artifacts from unsupported artifact versions', () => { + // Given: one current artifact truncated after its required declarations and one complete artifact from a future schema. + const complete = generateReactPageTypes(catalog); + const malformed = complete.slice(0, complete.indexOf(` ${JSON.stringify(catalog[0].id)}: {`)); + const unsupported = complete.replace( + `Artifact version: ${REACT_PAGE_TYPEGEN_ARTIFACT_VERSION}.`, + 'Artifact version: 99.', + ); + + // When: tooling inspects both artifacts before deciding whether they are stale. + const malformedInspection = inspectReactPageTypeArtifact(malformed); + const unsupportedInspection = inspectReactPageTypeArtifact(unsupported); + + // Then: corruption and a newer schema remain separate actionable states. + expect(malformedInspection).toEqual({ status: 'malformed' }); + expect(unsupportedInspection).toEqual({ status: 'unsupported-version', version: 99 }); + }); + + it('classifies a syntactically malformed current-version body as malformed', () => { + // Given: a current-version artifact keeps its banner, sentinels, and footer but has invalid TypeScript syntax. + const malformed = generateReactPageTypes(catalog).replace( + 'export const reactPageRoutes = {', + 'export const reactPageRoutes = {]', + ); + + // When: tooling validates the complete generated body. + const inspection = inspectReactPageTypeArtifact(malformed); + + // Then: syntax corruption is malformed rather than a valid stale artifact. + expect(inspection).toEqual({ status: 'malformed' }); + }); + it('generates absolute paths and href builders with required params', () => { // Given const pages = catalog; diff --git a/packages/react/src/typegen.ts b/packages/react/src/typegen.ts index aab19e06f..4c57acd3e 100644 --- a/packages/react/src/typegen.ts +++ b/packages/react/src/typegen.ts @@ -1,6 +1,18 @@ import type { ReactPageCatalogEntry } from './page-catalog.js'; +import { parseGeneratedReactPageCatalog } from './typegen-artifact.js'; -const GENERATED_BANNER = '/* Generated by @fluojs/react/typegen. Do not edit manually. */'; +/** Current schema version embedded in generated React page type artifacts. */ +export const REACT_PAGE_TYPEGEN_ARTIFACT_VERSION = 1; + +const GENERATED_BANNER = `/* Generated by @fluojs/react/typegen. Artifact version: ${REACT_PAGE_TYPEGEN_ARTIFACT_VERSION}. Do not edit manually. */`; +const GENERATED_FOOTER = '/* End generated @fluojs/react/typegen artifact. */'; +const GENERATED_BANNER_PATTERN = /^\/\* Generated by @fluojs\/react\/typegen\. Artifact version: (0|[1-9][0-9]{0,8})\. Do not edit manually\. \*\/\r?\n/u; + +/** Structural classification of an existing React page type artifact. */ +export type ReactPageTypeArtifactInspection = + | { readonly status: 'malformed' } + | { readonly status: 'unsupported-version'; readonly version: number } + | { readonly status: 'valid'; readonly version: number }; /** Stable diagnostic codes emitted by React page type generation. */ export const REACT_PAGE_TYPEGEN_ERROR_CODES = { @@ -20,6 +32,37 @@ export class ReactPageTypegenError extends Error { } } +/** + * Classifies generated React page type source before tooling compares its bytes. + * + * @param source Existing artifact source read from disk. + * @returns Its current, malformed, or unsupported-version structural status. + */ +export function inspectReactPageTypeArtifact(source: string): ReactPageTypeArtifactInspection { + const match = GENERATED_BANNER_PATTERN.exec(source); + const rawVersion = match?.[1]; + if (rawVersion === undefined) { + return { status: 'malformed' }; + } + + const version = Number(rawVersion); + if (version !== REACT_PAGE_TYPEGEN_ARTIFACT_VERSION) { + return { status: 'unsupported-version', version }; + } + + const normalizedSource = source.replaceAll('\r\n', '\n'); + const parsedCatalog = parseGeneratedReactPageCatalog(normalizedSource); + if ( + parsedCatalog === undefined + || !normalizedSource.endsWith(`${GENERATED_FOOTER}\n`) + || generateReactPageTypes(parsedCatalog) !== normalizedSource + ) { + return { status: 'malformed' }; + } + + return { status: 'valid', version }; +} + function stringLiteral(value: string): string { return JSON.stringify(value); } @@ -159,6 +202,7 @@ export function generateReactPageTypes(catalog: readonly ReactPageCatalogEntry[] '}', ...renderRoutes(sortedCatalog), 'export type ReactPageRoute = (typeof reactPageRoutes)[ReactPageRouteId];', + GENERATED_FOOTER, '', ].join('\n'); } diff --git a/packages/testing/README.ko.md b/packages/testing/README.ko.md index 85e2b35b9..bdb2b9458 100644 --- a/packages/testing/README.ko.md +++ b/packages/testing/README.ko.md @@ -13,6 +13,7 @@ Node.js 20+ fluo 애플리케이션을 위한 기본 request-level 테스트 헬 - [빠른 시작](#빠른-시작) - [주요 패턴](#주요-패턴) - [canonical TDD ladder](#canonical-tdd-ladder) +- [React Consumer Testing Recipe](#react-consumer-testing-recipe) - [공개 API](#공개-api) - [관련 패키지](#관련-패키지) - [예제 소스](#예제-소스) @@ -177,6 +178,27 @@ test/ fluo는 테스트가 명시적인 `rootModule`을 이름으로 지정해야 한다는 점에서 NestJS와 다릅니다. 테스트 유틸리티는 legacy TypeScript design metadata나 reflection flag에서 dependency를 추론하지 않고, 작성자가 만든 module graph를 컴파일합니다. +## React Consumer Testing Recipe + +React 애플리케이션은 같은 testing ladder를 유지하고 React-specific testing helper 대신 기존 boundary에 +build/browser evidence를 추가합니다. + +1. Render-policy 및 metadata composition을 pure value로 unit test합니다. +2. Direct page return, missing-renderer diagnostic, DTO validation, request-scope identity, response ownership, + guard, interceptor, native mutation route는 `createTestApp({ rootModule })`로 검증하고 `finally`에서 app을 + 닫습니다. +3. CI에서 `fluo typegen ... --check`를 실행하고 generated-route fixture를 TypeScript로 compile합니다. + Positive route-id/params case와 negative unknown-id, missing-param, extra-param, stale-output case를 유지합니다. +4. React DOM으로 server markup을 hydrate합니다. Aligned tree는 diagnostic 없이 interactive해야 하고, + 의도적으로 mismatch한 tree 하나는 `onRecoverableError`를 capture해야 합니다. +5. Production asset을 대상으로 Playwright를 실행한 뒤 별도 `javaScriptEnabled: false` context에서 native + form scenario를 반복해 일반 `POST` → `303` → `GET` fallback을 executable 상태로 유지합니다. + +Runnable map은 [`@fluojs/react`](../react/README.ko.md#consumer-testing-loop)와 +[`examples/react-vite-ssr`](../../examples/react-vite-ssr/README.ko.md#canonical-consumer-test-map)에 +문서화되어 있습니다. 이 layer들은 이미 real HTTP dispatcher와 application page renderer를 compose하므로 +synthetic React test runtime은 필요한 setup을 줄이기보다 coverage를 약화시킵니다. + ## 공개 API - **루트 패키지**: `createTestingModule(...)`, `Test.createTestingModule(...)`, `createTestApp(...)`, 모듈 introspection 헬퍼, `DeepMocked`를 포함한 공용 app/module 테스트 타입 diff --git a/packages/testing/README.md b/packages/testing/README.md index 2601ada47..bbedba9dc 100644 --- a/packages/testing/README.md +++ b/packages/testing/README.md @@ -11,6 +11,7 @@ Node.js 20+ request-level testing helpers, testing module construction, and prov - [Quick Start](#quick-start) - [Common Patterns](#common-patterns) - [Canonical TDD Ladder](#canonical-tdd-ladder) +- [React Consumer Testing Recipe](#react-consumer-testing-recipe) - [Public API](#public-api) - [Related Packages](#related-packages) - [Example Sources](#example-sources) @@ -175,6 +176,29 @@ test/ fluo differs from NestJS by requiring tests to name an explicit `rootModule`. The testing utilities compile the module graph you authored instead of inferring dependencies from legacy TypeScript design metadata or reflection flags. +## React Consumer Testing Recipe + +React applications keep the same testing ladder and add build/browser evidence at the existing +boundaries instead of introducing a React-specific testing helper: + +1. Unit-test render-policy and metadata composition as pure values. +2. Use `createTestApp({ rootModule })` for direct page returns, missing-renderer diagnostics, DTO + validation, request-scope identity, response ownership, guards, interceptors, and native mutation + routes. Close the app in `finally`. +3. Run `fluo typegen ... --check` in CI and compile generated-route fixtures with TypeScript. Keep + positive route-id/params cases and negative unknown-id, missing-param, extra-param, and stale-output + cases. +4. Hydrate server markup with React DOM. Assert an aligned tree is interactive without diagnostics, + and capture `onRecoverableError` for one deliberately mismatched tree. +5. Run Playwright against production assets, then repeat the native form scenario in a separate + `javaScriptEnabled: false` context so the ordinary `POST` → `303` → `GET` fallback stays executable. + +The runnable map is documented in +[`@fluojs/react`](../react/README.md#consumer-testing-loop) and +[`examples/react-vite-ssr`](../../examples/react-vite-ssr/README.md#canonical-consumer-test-map). +These layers already compose the real HTTP dispatcher and application page renderer, so a synthetic +React test runtime would reduce coverage rather than remove necessary setup. + ## Public API - **Root package**: `createTestingModule(...)`, `Test.createTestingModule(...)`, `createTestApp(...)`, module introspection helpers, and shared app/module testing types including `DeepMocked`