@fluojs/platform-express 1.0.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -21,9 +21,15 @@ fluo 런타임을 위한 Express 기반 HTTP 어댑터 패키지입니다.
21
21
  npm install @fluojs/platform-express express
22
22
  ```
23
23
 
24
+ `@fluojs/platform-express`는 Node.js `>=24.0.0 <27`이 필요합니다. Listener-level RFC `QUERY` 요청이 Express와 fluo dispatch에 도달하도록 package manifest는 `engines.node >=24.0.0 <27`을 선언합니다. Node 24 미만과 Node 27 이상은 제외됩니다. Deployment host가 Bun, Deno, Cloudflare Workers라면 fetch-style adapter를 선택하세요.
25
+
24
26
  ## 사용 시점
25
27
 
26
- fluo 애플리케이션의 기본 HTTP 엔진으로 Express를 사용하려는 경우에 이 패키지를 사용합니다. 이는 fluo의 데코레이터 기반 아키텍처 내에서 Express의 강력한 생태계, 성숙한 Node.js 서버 처리 친숙한 요청/응답 생명주기를 활용하는 유용합니다.
28
+ fluo 애플리케이션의 기본 HTTP 엔진으로 Express를 사용하려는 경우에 이 패키지를 사용합니다. 기존 Express 운영 자산, 호스팅 관례, 서버 통합은 platform boundary 근처에 두고 controller, provider, guard, interceptor, middleware는 fluo 런타임 계약을 유지해야 할 때 유용합니다.
29
+
30
+ Express를 host로 유지해도 NestJS legacy decorator 또는 reflection metadata semantics는 보존되지 않습니다. HTTP host를 변경하기 전에 controller와 provider를 TC39 표준 데코레이터로 마이그레이션하고, class-level `@Inject(...)`로 constructor token을 선언하며, 명시적 module/provider registration을 사용하세요. `experimentalDecorators`와 `emitDecoratorMetadata`는 비활성화한 상태로 유지해야 하며, Express adapter 교체는 NestJS dependency discovery compatibility layer가 아닙니다.
31
+
32
+ Express 호환성은 native Express/Connect `(req, res, next)` middleware를 fluo의 애플리케이션 레벨 `middleware` 옵션에 직접 전달할 수 있다는 의미가 아닙니다. 이 옵션은 fluo middleware(`handle(context, next)`) 또는 route-scoped fluo middleware provider를 받습니다. Migration 전용 native handler는 adapter의 명시적 `nativeMiddleware` 옵션에 등록하거나, Fastify, raw Node.js, Bun, Deno, Workers adapter로도 이동할 수 있도록 fluo `Middleware` 계약 뒤에 감싸세요.
27
33
 
28
34
  ## 빠른 시작
29
35
 
@@ -43,16 +49,22 @@ await app.listen();
43
49
 
44
50
  ## 주요 패턴
45
51
 
52
+ ### Early Hints
53
+
54
+ Express response는 underlying Node `ServerResponse`를 사용하는 optional `context.response.earlyHints` capability를 노출합니다. `103` 하나마다 `write(...)`를 await하면 final response 전에 여러 write를 관찰할 수 있습니다. Early field는 Express final header, status, body, commit state와 분리됩니다. Capability가 없으면 unsupported이며 late write와 disconnect는 no-op이 아니라 결정적으로 reject됩니다.
55
+
46
56
  ### 스트리밍 응답 처리 (SSE)
47
57
  Express 어댑터는 공유 `SseResponse` 유틸리티를 통해 Server-Sent Events(SSE)를 지원하며, Express 전용 스트림 처리를 추상화합니다.
48
58
 
49
59
  Express 기반 응답 스트림은 공유 fluo 백프레셔 계약도 따릅니다. `response.stream.waitForDrain()`은 `drain`, `close`, `error` 중 어느 쪽이 먼저 와도 완료되므로, 백프레셔가 풀리기 전에 클라이언트가 연결을 끊어도 스트리밍 작성기가 멈추지 않습니다.
50
60
 
51
61
  ```typescript
52
- @Get('events')
53
- async streamEvents(@Res() res: FrameworkResponse) {
54
- const events = new SseResponse();
55
- events.send({ data: 'hello' });
62
+ import { Sse, SseResponse, type RequestContext } from '@fluojs/http';
63
+
64
+ @Sse('events')
65
+ async streamEvents(_input: undefined, ctx: RequestContext) {
66
+ const events = new SseResponse(ctx);
67
+ events.send({ data: 'hello' }, { event: 'ready' });
56
68
  return events;
57
69
  }
58
70
  ```
@@ -72,6 +84,47 @@ const adapter = createExpressAdapter(
72
84
  );
73
85
  ```
74
86
 
87
+ ### Express/Connect Middleware 경계
88
+ Express adapter는 Express를 host HTTP engine으로 보존하지만 request pipeline middleware는 dispatcher가 소유합니다. Portable middleware는 fluo `Middleware` 계약으로 등록하세요.
89
+
90
+ Adapter는 Express application을 직접 생성하고 소유하므로 기존 Express application을 채택하거나 재사용하는 방식은 지원하지 않습니다. Native Express handler는 construction-time `nativeMiddleware`로 제공해야 하며, bootstrap 이후 `use(...)`로 native stack에 middleware를 추가하는 방식은 지원하지 않습니다. 이식 가능한 동작은 fluo `Middleware`로 재작성하는 방식을 우선하세요.
91
+
92
+ ```typescript
93
+ import type { Middleware } from '@fluojs/http';
94
+
95
+ const compressionHeaders: Middleware = {
96
+ async handle(context, next) {
97
+ context.response.setHeader('vary', 'Accept-Encoding');
98
+ await next();
99
+ },
100
+ };
101
+
102
+ const app = await fluoFactory.create(AppModule, {
103
+ adapter: createExpressAdapter({ port: 3000 }),
104
+ middleware: [compressionHeaders],
105
+ });
106
+ ```
107
+
108
+ `compression()` 같은 Express/Connect function을 fluo middleware로 직접 전달하지 마세요. Migration 중 native handler를 유지해야 한다면 adapter construction이 완료되기 전에 명시적으로 등록하세요.
109
+
110
+ ```typescript
111
+ import type { RequestHandler } from 'express';
112
+
113
+ const legacyRequestTag: RequestHandler = (_request, response, next) => {
114
+ response.setHeader('x-migration-host', 'express');
115
+ next();
116
+ };
117
+
118
+ const adapter = createExpressAdapter({
119
+ nativeMiddleware: [legacyRequestTag],
120
+ port: 3000,
121
+ });
122
+ ```
123
+
124
+ `nativeMiddleware`는 배열 순서대로 adapter의 Express Router와 catch-all fluo dispatch보다 먼저 mount됩니다. `next()`를 호출하면 fluo middleware, guard, interceptor, handler로 계속 진행합니다. Native response를 끝내면 fluo dispatch에 들어가지 않고 그 자리에서 종료됩니다. Throw/rejected error와 `next(error)`는 Express error chain에 남으므로, native Express error handler는 같은 배열에서 자신이 처리할 middleware 뒤에 두세요. Dispatch 전에 발생한 실패는 fluo error filter나 envelope가 변환하지 않습니다.
125
+
126
+ Native stack은 adapter 생성 시 고정됩니다. Adapter는 Node HTTP/S listener와 connection을 소유하지만 native middleware가 캡처한 timer, client, 기타 resource를 발견하거나 dispose하지 않습니다. 이 resource는 application bootstrap code가 정리해야 합니다. Route behavior, request-context mutation, cross-platform concern은 fluo middleware, guard, interceptor에 두세요.
127
+
75
128
  ### 안전한 fallback을 포함한 Native Route Registration
76
129
  어댑터는 의미 보존이 가능한 명시적 `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD` route를 Express Router에 사전 등록하면서도, 실제 요청 처리는 계속 공유 fluo dispatcher를 통해 수행합니다.
77
130
 
@@ -81,16 +134,24 @@ const adapter = createExpressAdapter(
81
134
 
82
135
  문서화된 fluo semantics를 바꾸지 않기 위해 `/:id` 와 `/:slug`처럼 shape가 겹치는 파라미터 라우트, `@All(...)` 핸들러, `OPTIONS` 소유권, non-URI versioning, 그리고 duplicate slash/trailing slash 정규화에 의존하는 요청은 catch-all fallback 경로에 남겨둡니다.
83
136
 
137
+ ### Startup Retry와 Shutdown
138
+ `listen()`은 adapter가 열린 상태인 동안에만 `retryDelayMs` 및 `retryLimit`에 따라 `EADDRINUSE`를 재시도합니다. 동시에 호출된 `listen()`은 서로 겹치는 retry loop를 시작하지 않고 첫 호출자의 in-flight startup lifecycle과 dispatcher를 공유합니다. Startup이 retry loop에서 대기 중일 때 `close()`가 호출되면, underlying Node server가 아직 listening 상태에 도달하지 않았더라도 adapter는 공유 listen attempt를 abort하고 그 작업이 settle될 때까지 기다린 뒤 `close()`를 완료합니다. `close()` 진행 중 호출된 `listen()`은 reject되며 `close()`가 resolve된 뒤 다시 시도할 수 있습니다. `close()`가 resolve된 뒤 막혀 있던 port를 해제해도 adapter가 나중에 bind되지 않으며, 다시 시작하려면 호출자가 명시적으로 `listen()`을 다시 호출해야 합니다.
139
+
140
+ 같은 adapter instance를 close 후 다시 listen하면 traffic을 받기 전에 native route descriptor registry를 새 dispatcher에서 갱신합니다. 유지된 Express Router layer는 현재 descriptor만 resolve하고 그 밖의 경우 full dispatcher matching으로 fallback하므로 native handoff metadata가 이전 application graph를 가리키지 않습니다.
141
+
84
142
  ## 어댑터 계약
85
143
 
86
144
  - **공유 dispatcher 소유권 유지**: Native Express Router 매치 이후에도 실제 요청은 공유 fluo dispatcher가 처리하므로 middleware, guards, interceptors, observers, params, error envelope 계약은 그대로 유지됩니다.
145
+ - **Express application 소유권**: Adapter가 Express application을 생성하고 소유합니다. 기존 Express application을 채택하지 않고 post-bootstrap `use(...)` mutation도 노출하지 않습니다. 불가피한 native handler는 construction-time `nativeMiddleware`로 제공하고, 이식 가능한 동작은 fluo `Middleware`로 재작성하세요.
146
+ - **Host engine 경계**: Express는 host/platform HTTP engine이지만 fluo는 native Express/Connect middleware를 fluo middleware로 재해석하지 않습니다. Application-level middleware는 공유 `Middleware` 계약을 구현하고, platform-specific `nativeMiddleware` 옵션은 routing 전에 native handler를 mount합니다.
147
+ - **Native middleware 소유권**: Native handler는 선언 순서대로 실행되며 Express continuation, response termination, error-chain semantics를 유지합니다. Adapter shutdown은 listener와 connection을 닫지만 handler가 소유한 resource는 dispose하지 않습니다.
87
148
  - **안전한 fallback 범위**: `@All(...)` 핸들러와 shape가 겹치는 파라미터 라우트는 Express Router에 강제 등록하지 않고 의도적으로 catch-all fallback 경로에 둡니다.
88
149
  - **OPTIONS 소유권 parity**: 어댑터는 native route에 대해 Express Router가 `OPTIONS`를 자동 응답하지 못하게 막아, 미지원 메서드도 계속 fluo dispatcher semantics로 흘러가고 `@All(...)` 핸들러가 정의된 경우 `OPTIONS`도 그대로 소유할 수 있게 합니다.
89
150
  - **경로 정규화 parity**: duplicate slash 변형처럼 Express Router와 fluo의 정규화 방식이 다를 수 있는 요청도 fallback dispatch를 통해 fluo의 normalized route contract를 유지합니다.
90
151
  - **버저닝 parity**: Express Router가 최초 path match를 하더라도 header/media-type/custom version 선택은 계속 dispatcher가 최종 결정합니다.
91
152
  - **Middleware rewrite parity**: App middleware가 method/path를 rewrite하면 native handoff는 무효화되고 rewrite된 요청을 기준으로 다시 매칭합니다.
92
153
  - **응답 serialization parity**: String response는 기본적으로 `text/plain`, object/array는 JSON, binary payload는 `application/octet-stream`으로 serialize되며 `set-cookie` 값은 병합됩니다.
93
- - **Startup과 shutdown**: 어댑터는 HTTP/HTTPS startup, retry option에 따른 `EADDRINUSE` 재시도, close 시 socket drain, 동시에 들어온 `close()` 호출의 단일 in-flight close lifecycle 재사용, shutdown timeout 이후 force-close를 지원하며, `shutdownTimeoutMs`가 `0`이면 즉시 force-close합니다.
154
+ - **Startup과 shutdown**: 어댑터는 HTTP/HTTPS startup, adapter가 열린 상태에서 `retryLimit` 소진 전까지 retry option에 따른 `EADDRINUSE` 재시도, 동시 startup 호출자의 단일 in-flight listen lifecycle 및 dispatcher 재사용, `close()` 중 해당 공유 retry loop를 abort 및 join한 뒤 shutdown 완료 보고, close 진행 중 `listen()` reject, 이미 시작된 adapter에 대한 중복 `listen()` 호출의 idempotent 처리와 live dispatcher 보존, close 후 adapter를 다시 listen할 때 native route descriptor 갱신, 정상 close idle keep-alive socket drain, 동시에 들어온 `close()` 호출의 단일 in-flight close lifecycle 재사용, shutdown timeout 이후 force-close를 지원하며, `shutdownTimeoutMs`가 `0`이면 즉시 force-close합니다.
94
155
 
95
156
  ## 공개 API 개요
96
157
 
@@ -98,10 +159,22 @@ const adapter = createExpressAdapter(
98
159
  - `bootstrapExpressApplication(module, options)`: 수동 제어를 위한 고급 부트스트랩 헬퍼입니다.
99
160
  - `runExpressApplication(module, options)`: 시그널 연결을 포함한 빠른 시작을 위한 호환 헬퍼입니다. timeout/실패 시에는 해당 상태를 로그와 `process.exitCode`로 보고하고, 최종 프로세스 종료는 주변 호스트에 맡깁니다.
100
161
  - `isExpressMultipartTooLargeError(error)`: adapter error shape 전반에서 multipart limit 감지를 정규화합니다.
101
- - `ExpressHttpApplicationAdapter`: 핵심 어댑터 구현 클래스입니다.
102
- - Option type: `ExpressAdapterOptions`, `BootstrapExpressApplicationOptions`, `RunExpressApplicationOptions`, `CorsInput`, `ExpressApplicationSignal`.
162
+ - `ExpressServer`: `ExpressHttpApplicationAdapter.getServer()`가 infrastructure boundary에서 반환하는 adapter-owned `node:http` `Server` 또는 `node:https` `Server` union의 공개 type alias입니다.
163
+ - `ExpressHttpApplicationAdapter`: 핵심 어댑터 구현 클래스입니다. `getServer()`는 좁은 platform integration을 위해 underlying Node HTTP/HTTPS server를 노출하며 `ExpressServer`를 반환하고, `getListenTarget()`은 startup 이후 resolved bind target과 public URL을 보고하며, `getRealtimeCapability()`는 realtime package가 사용하는 server-backed capability를 반환합니다. 이러한 helper는 모두 일반 애플리케이션 코드에 native server object를 퍼뜨리기보다 infrastructure boundary에만 두세요.
164
+ - Option type: `ExpressAdapterOptions`, `BootstrapExpressApplicationOptions`, `RunExpressApplicationOptions`, `ExpressNativeMiddleware`, `CorsInput`, `ExpressApplicationSignal`.
165
+
166
+ `createExpressAdapter(options, multipartOptions?)`는 `host`, `https`, `maxBodySize`, `nativeMiddleware`, `port`, `rawBody`, `retryDelayMs`, `retryLimit`, `shutdownTimeoutMs`를 지원합니다. `ExpressHttpApplicationAdapter`를 직접 생성하는 경우에도 factory와 같은 numeric validation이 적용됩니다.
167
+
168
+ - `BootstrapExpressApplicationOptions`와 `RunExpressApplicationOptions`는 위의 adapter option과 함께 `cors`, `globalPrefix`, `globalPrefixExclude`, `middleware`, `multipart`, `nativeMiddleware`, `securityHeaders`, `logger`를 공통으로 받습니다.
169
+ - `RunExpressApplicationOptions`만 signal-driven shutdown을 위한 `forceExitTimeoutMs`와 `shutdownSignals`를 추가로 받습니다.
170
+
171
+ 두 helper는 startup/shutdown diagnostics에 framework console logger를 기본으로 사용하며, `logger`가 제공되면 주입된 `ApplicationLogger`를 따릅니다.
172
+
173
+ ## Multipart 스트리밍
174
+
175
+ Express를 bootstrap할 때 `multipart: { strategy: 'stream' }`을 설정하면 multipart part가 `RequestContext.request.body`의 `AsyncIterable`로 노출됩니다. Express는 iterator를 미리 읽거나 버퍼링하지 않으며, file part를 소비할 때만 바이트를 가져옵니다. 버퍼링 multipart parsing은 기본값이며 fields와 `request.files`를 노출하고, 하나의 request body에서 stream 소비와 함께 사용할 수 없습니다.
103
176
 
104
- `createExpressAdapter(options, multipartOptions?)`는 `host`, `https`, `maxBodySize`, `port`, `rawBody`, `retryDelayMs`, `retryLimit`, `shutdownTimeoutMs`를 지원합니다. `ExpressHttpApplicationAdapter`를 직접 생성하는 경우에도 factory와 같은 numeric validation이 적용됩니다. `bootstrapExpressApplication(...)`과 `runExpressApplication(...)`은 `cors`, `globalPrefix`, `globalPrefixExclude`, `middleware`, `multipart`, `securityHeaders`, `forceExitTimeoutMs`, `shutdownSignals`, `logger`도 받습니다. startup/shutdown diagnostics에는 framework console logger기본으로 사용하며, `logger`가 제공되면 주입된 `ApplicationLogger`를 따릅니다.
177
+ Runtime route dispatch는 route를 위해 만든 iterator를 소유하며 handler가 끝난 자동으로 `return()`을 호출해 active source를 cancel하고 release합니다. Standalone `parseMultipartStream(...)` consumer는 책임을 직접 집니다. iterator끝까지 소비하거나 일찍 끝낼 `return()`을 호출하세요.
105
178
 
106
179
  ## 관련 패키지
107
180
 
@@ -112,4 +185,4 @@ const adapter = createExpressAdapter(
112
185
  ## 예제 소스
113
186
 
114
187
  - `packages/platform-express/src/adapter.test.ts`
115
- - `examples/minimal/src/main.ts` (Fastify 기반이지만 공유 `fluoFactory` 패턴을 보여줌)
188
+ - 이 패키지는 아직 전용 `examples/platform-express` 앱을 제공하지 않습니다. Express bootstrap 형태는 이 README의 빠른 시작 및 native middleware scenario를 사용하고, native middleware ordering/termination/error propagation, SSE framing, native-route fallback parity, close/relisten 후 native descriptor 갱신, duplicate listen idempotency, retry exhaustion, shutdown 중 startup retry cancellation, idle keep-alive drain, forced shutdown을 포함한 실행 가능한 Express adapter coverage는 `packages/platform-express/src/adapter.test.ts`를 사용하세요. `examples/minimal/src/main.ts`는 Fastify 기반이므로 Express 예제 소스로 취급하지 않아야 합니다.
package/README.md CHANGED
@@ -21,9 +21,15 @@ Express-backed HTTP adapter for the fluo runtime.
21
21
  npm install @fluojs/platform-express express
22
22
  ```
23
23
 
24
+ `@fluojs/platform-express` requires Node.js `>=24.0.0 <27`. Its package manifest declares `engines.node >=24.0.0 <27` so listener-level RFC `QUERY` requests reach Express and fluo dispatch; Node versions below 24 and Node 27+ are excluded. Choose a fetch-style adapter instead when the deployment host is Bun, Deno, or Cloudflare Workers.
25
+
24
26
  ## When to Use
25
27
 
26
- Use this package when you want to run a fluo application using Express as the underlying HTTP engine. This is useful for leveraging Express's robust ecosystem, mature Node.js server handling, and familiar request/response lifecycle within the fluo decorator-based architecture.
28
+ Use this package when you want to run a fluo application using Express as the underlying HTTP engine. This is useful when existing Express operational assets, hosting conventions, or server integrations need to stay near the platform boundary while controllers, providers, guards, interceptors, and middleware keep using fluo's runtime contracts.
29
+
30
+ Keeping Express as the host does not preserve NestJS legacy decorator or reflection-metadata semantics. Before changing the HTTP host, migrate controllers and providers to TC39 standard decorators, declare constructor tokens with class-level `@Inject(...)`, and use explicit module/provider registration. Keep `experimentalDecorators` and `emitDecoratorMetadata` disabled; an Express adapter replacement is not a compatibility layer for NestJS dependency discovery.
31
+
32
+ Express compatibility does not mean that native Express/Connect `(req, res, next)` middleware can be passed directly to fluo's application-level `middleware` option. That option accepts fluo middleware (`handle(context, next)`) or route-scoped fluo middleware providers. Register migration-only native handlers through the adapter's explicit `nativeMiddleware` option, or wrap the behavior behind the fluo `Middleware` contract so it remains portable to Fastify, raw Node.js, Bun, Deno, and Workers adapters.
27
33
 
28
34
  ## Quick Start
29
35
 
@@ -43,16 +49,22 @@ await app.listen();
43
49
 
44
50
  ## Common Patterns
45
51
 
52
+ ### Early Hints
53
+
54
+ Express responses expose the optional `context.response.earlyHints` capability backed by the underlying Node `ServerResponse`. Await one `write(...)` per `103`; multiple writes are observable before the final response. Early fields stay separate from Express final headers, status, body, and commit state. Missing capability means unsupported, while late writes and disconnects reject deterministically instead of becoming no-ops.
55
+
46
56
  ### Handling Streaming Responses (SSE)
47
57
  The Express adapter supports Server-Sent Events (SSE) via the shared `SseResponse` utility, abstracting away the Express-specific stream handling.
48
58
 
49
59
  Express-backed response streams also honor the shared fluo backpressure contract: `response.stream.waitForDrain()` settles on `drain`, `close`, or `error`, so streaming writers do not hang when clients disconnect before backpressure clears.
50
60
 
51
61
  ```typescript
52
- @Get('events')
53
- async streamEvents(@Res() res: FrameworkResponse) {
54
- const events = new SseResponse();
55
- events.send({ data: 'hello' });
62
+ import { Sse, SseResponse, type RequestContext } from '@fluojs/http';
63
+
64
+ @Sse('events')
65
+ async streamEvents(_input: undefined, ctx: RequestContext) {
66
+ const events = new SseResponse(ctx);
67
+ events.send({ data: 'hello' }, { event: 'ready' });
56
68
  return events;
57
69
  }
58
70
  ```
@@ -72,6 +84,47 @@ const adapter = createExpressAdapter(
72
84
  );
73
85
  ```
74
86
 
87
+ ### Express/Connect Middleware Boundary
88
+ The Express adapter preserves Express as the host HTTP engine, but request pipeline middleware remains dispatcher-owned. Register portable middleware through the fluo `Middleware` contract:
89
+
90
+ The adapter constructs and owns its Express application. Adopting or reusing an existing Express application is unsupported. Native Express handlers must be supplied through construction-time `nativeMiddleware`; after bootstrap, calling `use(...)` to append to the native stack is not a supported surface. Prefer rewriting portable behavior as fluo `Middleware`.
91
+
92
+ ```typescript
93
+ import type { Middleware } from '@fluojs/http';
94
+
95
+ const compressionHeaders: Middleware = {
96
+ async handle(context, next) {
97
+ context.response.setHeader('vary', 'Accept-Encoding');
98
+ await next();
99
+ },
100
+ };
101
+
102
+ const app = await fluoFactory.create(AppModule, {
103
+ adapter: createExpressAdapter({ port: 3000 }),
104
+ middleware: [compressionHeaders],
105
+ });
106
+ ```
107
+
108
+ Do not pass an Express/Connect function such as `compression()` directly as fluo middleware. If a migration must retain a native handler, register it explicitly before adapter construction completes:
109
+
110
+ ```typescript
111
+ import type { RequestHandler } from 'express';
112
+
113
+ const legacyRequestTag: RequestHandler = (_request, response, next) => {
114
+ response.setHeader('x-migration-host', 'express');
115
+ next();
116
+ };
117
+
118
+ const adapter = createExpressAdapter({
119
+ nativeMiddleware: [legacyRequestTag],
120
+ port: 3000,
121
+ });
122
+ ```
123
+
124
+ `nativeMiddleware` is mounted in array order before the adapter's Express Router and catch-all fluo dispatch. Calling `next()` continues into fluo middleware, guards, interceptors, and handlers. Ending the native response stops there without entering fluo dispatch. Thrown/rejected errors and `next(error)` remain in the Express error chain, so place any native Express error handler after the middleware it handles in the same array; fluo error filters and envelopes do not translate failures that occur before dispatch.
125
+
126
+ The native stack is fixed when the adapter is created. The adapter owns its Node HTTP/S listener and connections, but it does not discover or dispose timers, clients, or other resources captured by native middleware; application bootstrap code must release those resources. Keep route behavior, request-context mutation, and cross-platform concerns in fluo middleware, guards, or interceptors.
127
+
75
128
  ### Native Route Registration with Safe Fallback
76
129
  The adapter pre-registers semantically safe Express Router handlers for explicit `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, and `HEAD` routes and still dispatches those requests through the shared fluo dispatcher.
77
130
 
@@ -81,16 +134,24 @@ If app middleware rewrites the framework request method or path after the adapte
81
134
 
82
135
  To avoid changing documented fluo semantics, overlapping same-shape param routes such as `/:id` and `/:slug`, `@All(...)` handlers, `OPTIONS` ownership, non-URI versioning, and requests that rely on fluo's duplicate-slash/trailing-slash normalization stay on the catch-all fallback path.
83
136
 
137
+ ### Startup Retry and Shutdown
138
+ `listen()` retries `EADDRINUSE` according to `retryDelayMs` and `retryLimit` only while the adapter remains open. Concurrent `listen()` calls share the first caller's in-flight startup lifecycle and dispatcher instead of starting overlapping retry loops. If `close()` is called while startup is waiting in that retry loop, the adapter aborts the shared listen attempt and waits for it to settle before `close()` resolves, even when the underlying Node server has not reached the listening state yet. A `listen()` call made while `close()` is in progress rejects and can be retried after `close()` resolves. Releasing the blocked port after `close()` resolves cannot make the adapter bind later; callers must invoke `listen()` again explicitly to start it.
139
+
140
+ If the same adapter instance is listened again after close, its native route descriptor registry refreshes from the new dispatcher before traffic is served. Retained Express Router layers resolve only current descriptors and otherwise fall back to full dispatcher matching, so native handoff metadata never points at a previous application graph.
141
+
84
142
  ## Adapter Contract
85
143
 
86
144
  - **Shared dispatcher ownership**: Native Express Router matches still hand off to the shared fluo dispatcher, so middleware, guards, interceptors, observers, params, and error envelopes remain framework-defined.
145
+ - **Express application ownership**: The adapter constructs and owns its Express application. It does not adopt an existing Express application, and it does not expose post-bootstrap `use(...)` mutation. Supply unavoidable native handlers through construction-time `nativeMiddleware`; prefer rewriting portable behavior as fluo `Middleware`.
146
+ - **Host engine boundary**: Express is the host/platform HTTP engine, but fluo does not reinterpret native Express/Connect middleware as fluo middleware; application-level middleware must implement the shared `Middleware` contract, while the platform-specific `nativeMiddleware` option mounts native handlers before routing.
147
+ - **Native middleware ownership**: Native handlers run in declared order and retain Express continuation, response termination, and error-chain semantics. Adapter shutdown closes the listener and connections but does not dispose resources owned by those handlers.
87
148
  - **Safe fallback scope**: `@All(...)` handlers and overlapping same-shape param routes intentionally stay on the catch-all fallback path instead of being force-registered through Express Router.
88
149
  - **OPTIONS ownership parity**: The adapter prevents Express Router from auto-answering `OPTIONS` for native routes, so unsupported methods still fall through to fluo dispatcher semantics and `@All(...)` handlers can continue to own `OPTIONS` when defined.
89
150
  - **Path normalization parity**: Requests that Express Router does not normalize the same way as fluo, such as duplicate-slash variants, still resolve through fallback dispatch so fluo's normalized route contract is preserved.
90
151
  - **Versioning parity**: Header/media-type/custom version selection remains dispatcher-owned even when Express Router handles the initial path match.
91
152
  - **Middleware rewrite parity**: App middleware that rewrites method or path invalidates native handoff and rematches the rewritten request.
92
153
  - **Response serialization parity**: String responses default to `text/plain`, objects/arrays serialize as JSON, binary payloads default to `application/octet-stream`, and `set-cookie` values are merged.
93
- - **Startup and shutdown**: The adapter supports HTTP/HTTPS startup, retries `EADDRINUSE` according to retry options, drains sockets on close, reuses one in-flight close lifecycle for concurrent `close()` calls, and can force-close connections after shutdown timeout, including immediate force-close when `shutdownTimeoutMs` is `0`.
154
+ - **Startup and shutdown**: The adapter supports HTTP/HTTPS startup, retries `EADDRINUSE` according to retry options until `retryLimit` is exhausted while the adapter is open, reuses one in-flight listen lifecycle and its dispatcher for concurrent startup callers, aborts and joins that shared retry loop during `close()` before shutdown completion is reported, rejects `listen()` while close is in progress, treats duplicate `listen()` calls on an already-started adapter as idempotent without replacing the live dispatcher, refreshes native route descriptors when the adapter is listened again after close, drains idle keep-alive sockets on normal close, reuses one in-flight close lifecycle for concurrent `close()` calls, and can force-close connections after shutdown timeout, including immediate force-close when `shutdownTimeoutMs` is `0`.
94
155
 
95
156
  ## Public API Overview
96
157
 
@@ -98,10 +159,22 @@ To avoid changing documented fluo semantics, overlapping same-shape param routes
98
159
  - `bootstrapExpressApplication(module, options)`: Advanced bootstrap helper for manual control.
99
160
  - `runExpressApplication(module, options)`: Compatibility helper for quick startup with signal wiring. On timeout/failure it reports the condition through logging and `process.exitCode`, while leaving final process termination to the surrounding host.
100
161
  - `isExpressMultipartTooLargeError(error)`: Normalizes multipart limit detection across adapter error shapes.
101
- - `ExpressHttpApplicationAdapter`: The core adapter implementation class.
102
- - Option types: `ExpressAdapterOptions`, `BootstrapExpressApplicationOptions`, `RunExpressApplicationOptions`, `CorsInput`, `ExpressApplicationSignal`.
162
+ - `ExpressServer`: Public type alias for the adapter-owned `node:http` `Server` or `node:https` `Server` union returned by `ExpressHttpApplicationAdapter.getServer()` at infrastructure boundaries.
163
+ - `ExpressHttpApplicationAdapter`: The core adapter implementation class. `getServer()` exposes the underlying Node HTTP/HTTPS server for narrow platform integrations and returns `ExpressServer`, `getListenTarget()` reports the resolved bind target and public URL after startup, and `getRealtimeCapability()` returns the server-backed capability used by realtime packages. Keep these helpers at infrastructure boundaries instead of threading native server objects through ordinary application code.
164
+ - Option types: `ExpressAdapterOptions`, `BootstrapExpressApplicationOptions`, `RunExpressApplicationOptions`, `ExpressNativeMiddleware`, `CorsInput`, `ExpressApplicationSignal`.
165
+
166
+ `createExpressAdapter(options, multipartOptions?)` supports `host`, `https`, `maxBodySize`, `nativeMiddleware`, `port`, `rawBody`, `retryDelayMs`, `retryLimit`, and `shutdownTimeoutMs`. Direct `ExpressHttpApplicationAdapter` construction applies the same numeric validation as the factory.
167
+
168
+ - `BootstrapExpressApplicationOptions` and `RunExpressApplicationOptions` share `cors`, `globalPrefix`, `globalPrefixExclude`, `middleware`, `multipart`, `nativeMiddleware`, `securityHeaders`, and `logger` in addition to the adapter options above.
169
+ - `RunExpressApplicationOptions` alone adds `forceExitTimeoutMs` and `shutdownSignals` for signal-driven shutdown.
170
+
171
+ Both helpers use the framework console logger by default for startup and shutdown diagnostics and honor an injected `ApplicationLogger` when `logger` is provided.
172
+
173
+ ## Multipart streaming
174
+
175
+ Set `multipart: { strategy: 'stream' }` when bootstrapping Express to expose multipart parts through `RequestContext.request.body` as an `AsyncIterable`. Express creates the iterator without pre-reading or buffering it; consuming a file part pulls its bytes on demand. Buffered multipart parsing remains the default, exposes fields and `request.files`, and cannot be combined with stream consumption for the same request body.
103
176
 
104
- `createExpressAdapter(options, multipartOptions?)` supports `host`, `https`, `maxBodySize`, `port`, `rawBody`, `retryDelayMs`, `retryLimit`, and `shutdownTimeoutMs`. Direct `ExpressHttpApplicationAdapter` construction applies the same numeric validation as the factory. `bootstrapExpressApplication(...)` and `runExpressApplication(...)` also accept `cors`, `globalPrefix`, `globalPrefixExclude`, `middleware`, `multipart`, `securityHeaders`, `forceExitTimeoutMs`, `shutdownSignals`, and `logger`; they use the framework console logger by default for startup and shutdown diagnostics and honor an injected `ApplicationLogger` when provided.
177
+ Runtime route dispatch owns an iterator created for a route and automatically calls `return()` after the handler finishes, cancelling and releasing an active source. Standalone `parseMultipartStream(...)` consumers own that responsibility: consume the iterator to completion or call `return()` when ending early.
105
178
 
106
179
  ## Related Packages
107
180
 
@@ -112,4 +185,4 @@ To avoid changing documented fluo semantics, overlapping same-shape param routes
112
185
  ## Example Sources
113
186
 
114
187
  - `packages/platform-express/src/adapter.test.ts`
115
- - `examples/minimal/src/main.ts` (Fastify-based, but demonstrates the shared `fluoFactory` pattern)
188
+ - This package does not currently ship a dedicated `examples/platform-express` app. Use the Quick Start and native middleware scenario in this README for Express bootstrap shape and `packages/platform-express/src/adapter.test.ts` for executable Express adapter coverage, including native middleware ordering/termination/error propagation, SSE framing, native-route fallback parity, native descriptor refresh after close/relisten, duplicate listen idempotency, retry exhaustion, startup retry cancellation during shutdown, idle keep-alive drain, and forced shutdown. `examples/minimal/src/main.ts` is Fastify-based and should not be treated as an Express example source.
package/dist/adapter.d.ts CHANGED
@@ -1,12 +1,14 @@
1
- import { type ServerOptions as HttpsServerOptions } from 'node:https';
1
+ import type { Server as HttpServer } from 'node:http';
2
+ import { type Server as HttpsServer, type ServerOptions as HttpsServerOptions } from 'node:https';
2
3
  import { type CorsOptions, type Dispatcher, type HttpApplicationAdapter, type MiddlewareLike, type SecurityHeadersOptions } from '@fluojs/http';
3
- import type { Application, ApplicationLogger, CreateApplicationOptions, ModuleType, MultipartOptions, UploadedFile } from '@fluojs/runtime';
4
- declare module '@fluojs/http' {
5
- interface FrameworkRequest {
6
- files?: UploadedFile[];
7
- rawBody?: Uint8Array;
8
- }
9
- }
4
+ import type { Application, ApplicationLogger, CreateApplicationOptions, ModuleType, MultipartOptions } from '@fluojs/runtime';
5
+ import { type ErrorRequestHandler, type RequestHandler } from 'express';
6
+ /**
7
+ * Defines a native Express/Connect middleware handler registered before fluo routing.
8
+ *
9
+ * @remarks Native middleware remains platform-specific and follows Express response and error-chain semantics.
10
+ */
11
+ export type ExpressNativeMiddleware = RequestHandler | ErrorRequestHandler;
10
12
  /**
11
13
  * Describes the express adapter options contract.
12
14
  */
@@ -14,6 +16,7 @@ export interface ExpressAdapterOptions {
14
16
  host?: string;
15
17
  https?: HttpsServerOptions;
16
18
  maxBodySize?: number;
19
+ nativeMiddleware?: readonly ExpressNativeMiddleware[];
17
20
  port?: number;
18
21
  rawBody?: boolean;
19
22
  retryDelayMs?: number;
@@ -41,6 +44,7 @@ export interface BootstrapExpressApplicationOptions extends Omit<CreateApplicati
41
44
  maxBodySize?: number;
42
45
  middleware?: MiddlewareLike[];
43
46
  multipart?: MultipartOptions;
47
+ nativeMiddleware?: readonly ExpressNativeMiddleware[];
44
48
  port?: number;
45
49
  rawBody?: boolean;
46
50
  retryDelayMs?: number;
@@ -59,6 +63,10 @@ interface ExpressListenTarget {
59
63
  bindTarget: string;
60
64
  url: string;
61
65
  }
66
+ /**
67
+ * The Node.js HTTP or HTTPS server owned by the Express adapter.
68
+ */
69
+ export type ExpressServer = HttpServer | HttpsServer;
62
70
  /**
63
71
  * Represents the express http application adapter.
64
72
  */
@@ -72,16 +80,25 @@ export declare class ExpressHttpApplicationAdapter implements HttpApplicationAda
72
80
  private readonly maxBodySize;
73
81
  private readonly preserveRawBody;
74
82
  private readonly shutdownTimeoutMs;
83
+ private closing;
75
84
  private closeInFlight?;
76
85
  private dispatcher?;
86
+ private listenAbortController?;
87
+ private listenInFlight?;
77
88
  private readonly app;
89
+ private readonly nativeRouteDescriptors;
78
90
  private nativeRoutesReady;
79
91
  private readonly requestResponseFactory;
80
92
  private readonly router;
81
93
  private readonly server;
82
94
  private readonly sockets;
83
- constructor(port: number, host: string | undefined, retryDelayMs: number | undefined, retryLimit: number | undefined, httpsOptions: HttpsServerOptions | undefined, multipartOptions?: MultipartOptions | undefined, maxBodySize?: number, preserveRawBody?: boolean, shutdownTimeoutMs?: number);
84
- getServer(): unknown;
95
+ constructor(port: number, host: string | undefined, retryDelayMs: number | undefined, retryLimit: number | undefined, httpsOptions: HttpsServerOptions | undefined, multipartOptions?: MultipartOptions | undefined, maxBodySize?: number, preserveRawBody?: boolean, shutdownTimeoutMs?: number, nativeMiddleware?: readonly ExpressNativeMiddleware[]);
96
+ /**
97
+ * Returns the Node.js HTTP or HTTPS server owned by this adapter.
98
+ *
99
+ * @returns The platform-owned Node.js HTTP or HTTPS server.
100
+ */
101
+ getServer(): ExpressServer;
85
102
  getRealtimeCapability(): import("@fluojs/http").ServerBackedHttpAdapterRealtimeCapability;
86
103
  getListenTarget(): ExpressListenTarget;
87
104
  listen(dispatcher: Dispatcher): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../src/adapter.ts"],"names":[],"mappings":"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"}
package/dist/adapter.js CHANGED
@@ -1,15 +1,20 @@
1
1
  import { createServer as createHttpServer } from 'node:http';
2
2
  import { createServer as createHttpsServer } from 'node:https';
3
- import { Readable } from 'node:stream';
4
3
  import { BadRequestException, createErrorResponse, createServerBackedHttpAdapterRealtimeCapability, HttpException, InternalServerErrorException, PayloadTooLargeException } from '@fluojs/http';
5
4
  import { attachFrameworkRequestNativeRouteHandoff, bindRawRequestNativeRouteHandoff, consumeRawRequestNativeRouteHandoff, isRoutePathNormalizationSensitive } from '@fluojs/http/internal';
6
5
  import { bootstrapHttpAdapterApplication, runHttpAdapterApplication } from '@fluojs/runtime/internal/http-adapter';
7
- import { dispatchWithRequestResponseFactory } from '@fluojs/runtime/internal/request-response-factory';
8
- import { cloneRequestHeaders, createDeferredFrameworkRequestShell, createMemoizedAsyncValue, createRequestSignal, normalizePrimaryContentType, parseQueryParamsFromSearch, resolveAbsoluteRequestUrl, resolveRequestIdFromHeaders, snapshotSimpleQueryRecord, splitRawRequestUrl } from '@fluojs/runtime/internal-node';
9
- import { createConsoleApplicationLogger, createNodeShutdownSignalRegistration, defaultNodeShutdownSignals } from '@fluojs/runtime/node';
10
- import { parseMultipart } from '@fluojs/runtime/web';
6
+ import { dispatchWithRequestResponseFactory, finalizeRouteOwnedMultipartBody } from '@fluojs/runtime/internal/request-response-factory';
7
+ import { cloneRequestHeaders, createNodeEarlyHintsCapability, createDeferredFrameworkRequestShell, createMemoizedAsyncValue, createRequestSignal, normalizePrimaryContentType, parseQueryParamsFromSearch, resolveRequestIdFromHeaders, snapshotSimpleQueryRecord, splitRawRequestUrl } from '@fluojs/platform-nodejs/internal';
8
+ import { createConsoleApplicationLogger, createNodeShutdownSignalRegistration, defaultNodeShutdownSignals } from '@fluojs/platform-nodejs';
9
+ import { parseMultipart, parseMultipartStream } from '@fluojs/runtime/web';
11
10
  import express from 'express';
12
11
 
12
+ /**
13
+ * Defines a native Express/Connect middleware handler registered before fluo routing.
14
+ *
15
+ * @remarks Native middleware remains platform-specific and follows Express response and error-chain semantics.
16
+ */
17
+
13
18
  /**
14
19
  * Describes the express adapter options contract.
15
20
  */
@@ -34,19 +39,31 @@ const EXPRESS_NATIVE_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', '
34
39
  * Describes the run express application options contract.
35
40
  */
36
41
 
42
+ /**
43
+ * The Node.js HTTP or HTTPS server owned by the Express adapter.
44
+ */
45
+
46
+ function isExpressResponseTerminated(response) {
47
+ return response.writableEnded || response.destroyed;
48
+ }
49
+
37
50
  /**
38
51
  * Represents the express http application adapter.
39
52
  */
40
53
  export class ExpressHttpApplicationAdapter {
54
+ closing = false;
41
55
  closeInFlight;
42
56
  dispatcher;
57
+ listenAbortController;
58
+ listenInFlight;
43
59
  app;
60
+ nativeRouteDescriptors = new Map();
44
61
  nativeRoutesReady = false;
45
62
  requestResponseFactory;
46
63
  router = express.Router();
47
64
  server;
48
65
  sockets = new Set();
49
- constructor(port, host, retryDelayMs = 150, retryLimit = 20, httpsOptions, multipartOptions, maxBodySize = DEFAULT_MAX_BODY_SIZE, preserveRawBody = false, shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS) {
66
+ constructor(port, host, retryDelayMs = 150, retryLimit = 20, httpsOptions, multipartOptions, maxBodySize = DEFAULT_MAX_BODY_SIZE, preserveRawBody = false, shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS, nativeMiddleware = []) {
50
67
  this.port = port;
51
68
  this.host = host;
52
69
  this.retryDelayMs = retryDelayMs;
@@ -62,10 +79,16 @@ export class ExpressHttpApplicationAdapter {
62
79
  resolveNonNegativeIntegerOption('maxBodySize', this.maxBodySize, DEFAULT_MAX_BODY_SIZE);
63
80
  resolveNonNegativeIntegerOption('shutdownTimeoutMs', this.shutdownTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS);
64
81
  this.app = express();
82
+ for (const middleware of nativeMiddleware) {
83
+ this.app.use(middleware);
84
+ }
65
85
  this.requestResponseFactory = createExpressRequestResponseFactory(this.multipartOptions, this.maxBodySize, this.preserveRawBody);
66
86
  this.server = createExpressServer(this.httpsOptions, this.app);
67
87
  this.app.use(this.router);
68
88
  this.app.use((request, response) => {
89
+ if (isExpressResponseTerminated(response)) {
90
+ return;
91
+ }
69
92
  void this.handleRequest(request, response);
70
93
  });
71
94
  this.server.on('connection', socket => {
@@ -75,6 +98,12 @@ export class ExpressHttpApplicationAdapter {
75
98
  });
76
99
  });
77
100
  }
101
+
102
+ /**
103
+ * Returns the Node.js HTTP or HTTPS server owned by this adapter.
104
+ *
105
+ * @returns The platform-owned Node.js HTTP or HTTPS server.
106
+ */
78
107
  getServer() {
79
108
  return this.server;
80
109
  }
@@ -85,57 +114,107 @@ export class ExpressHttpApplicationAdapter {
85
114
  return resolveListenTarget(this.server.address() ?? null, this.port, this.host, this.httpsOptions !== undefined);
86
115
  }
87
116
  async listen(dispatcher) {
117
+ if (this.closing) {
118
+ throw new Error('Express adapter is closing. Wait for close() to complete before listen().');
119
+ }
120
+ if (this.server.listening) {
121
+ return;
122
+ }
123
+ if (this.listenInFlight) {
124
+ await this.listenInFlight;
125
+ return;
126
+ }
88
127
  this.dispatcher = dispatcher;
89
128
  this.registerNativeRoutes(dispatcher);
90
- await this.listenWithRetry();
129
+ const abortController = new AbortController();
130
+ this.listenAbortController = abortController;
131
+ const listenInFlight = this.listenWithRetry(abortController.signal).finally(() => {
132
+ if (this.listenInFlight === listenInFlight) {
133
+ this.listenInFlight = undefined;
134
+ }
135
+ if (this.listenAbortController === abortController) {
136
+ this.listenAbortController = undefined;
137
+ }
138
+ });
139
+ this.listenInFlight = listenInFlight;
140
+ await listenInFlight;
91
141
  }
92
142
  async close() {
93
143
  if (this.closeInFlight) {
94
144
  await waitForCloseWithTimeout(this.closeInFlight, this.shutdownTimeoutMs);
95
145
  return;
96
146
  }
97
- if (!this.server.listening) {
98
- this.dispatcher = undefined;
99
- return;
100
- }
101
- const closePromise = closeServerWithDrain(this.server, this.sockets, this.shutdownTimeoutMs);
147
+ this.closing = true;
148
+ const closePromise = (async () => {
149
+ if (this.listenInFlight) {
150
+ this.listenAbortController?.abort();
151
+ await waitForCloseWithTimeout(ignoreCancelledListen(this.listenInFlight), this.shutdownTimeoutMs);
152
+ }
153
+ if (this.server.listening) {
154
+ await closeServerWithDrain(this.server, this.sockets, this.shutdownTimeoutMs);
155
+ }
156
+ })();
102
157
  const closeInFlight = closePromise.finally(() => {
158
+ this.closing = false;
103
159
  this.closeInFlight = undefined;
104
160
  this.dispatcher = undefined;
161
+ this.nativeRouteDescriptors.clear();
105
162
  });
106
163
  this.closeInFlight = closeInFlight;
107
164
  void closeInFlight.catch(() => {});
108
165
  await waitForCloseWithTimeout(closeInFlight, this.shutdownTimeoutMs);
109
166
  }
110
- async listenWithRetry() {
167
+ async listenWithRetry(signal) {
111
168
  for (let attempt = 0;; attempt++) {
169
+ throwIfListenCancelled(signal);
112
170
  try {
113
- await listenServer(this.server, this.port, this.host);
171
+ await listenServer({
172
+ host: this.host,
173
+ port: this.port,
174
+ server: this.server,
175
+ signal
176
+ });
177
+ if (signal.aborted) {
178
+ await closeServerBestEffort(this.server);
179
+ throw new ExpressListenCancelledError();
180
+ }
114
181
  return;
115
182
  } catch (error) {
116
183
  if (!isAddressInUseError(error) || attempt >= this.retryLimit) {
117
184
  throw error;
118
185
  }
119
186
  await closeServerSilently(this.server);
120
- await delay(this.retryDelayMs);
187
+ await delay(this.retryDelayMs, signal);
121
188
  }
122
189
  }
123
190
  }
124
191
  registerNativeRoutes(dispatcher) {
192
+ const nativeRoutes = createExpressNativeRoutes(resolveDispatcherRouteDescriptors(dispatcher));
193
+ this.nativeRouteDescriptors.clear();
194
+ for (const route of nativeRoutes) {
195
+ for (const method of route.methods) {
196
+ const descriptor = route.descriptorsByMethod[method];
197
+ if (descriptor) {
198
+ this.nativeRouteDescriptors.set(`${method}:${route.path}`, descriptor);
199
+ }
200
+ }
201
+ }
125
202
  if (this.nativeRoutesReady) {
126
203
  return;
127
204
  }
128
- const nativeRoutes = createExpressNativeRoutes(resolveDispatcherRouteDescriptors(dispatcher));
129
205
  Reflect.set(this.router, '__fluoNativeRoutes', nativeRoutes);
130
206
  for (const route of nativeRoutes) {
131
207
  this.router.all(route.path, (request, response, next) => {
208
+ if (isExpressResponseTerminated(response)) {
209
+ return;
210
+ }
132
211
  if (!route.methods.includes(request.method.toUpperCase())) {
133
212
  next();
134
213
  return;
135
214
  }
136
215
  const nativeMethod = request.method.toUpperCase();
137
216
  const requestPath = splitRawRequestUrl(request.originalUrl || request.url || '/').path;
138
- const descriptor = route.descriptorsByMethod[nativeMethod];
217
+ const descriptor = this.nativeRouteDescriptors.get(`${nativeMethod}:${route.path}`);
139
218
  const params = normalizeNativeRouteParams(request.params);
140
219
  if (descriptor && !isRoutePathNormalizationSensitive(requestPath) && !hasNativeRouteParamSeparators(params)) {
141
220
  void this.handleNativeRouteRequest(descriptor, params, request, response);
@@ -168,8 +247,9 @@ export class ExpressHttpApplicationAdapter {
168
247
  const factory = this.requestResponseFactory;
169
248
  const frameworkResponse = factory.createResponse(response, request);
170
249
  const signal = factory.createRequestSignal(response);
250
+ let frameworkRequest;
171
251
  try {
172
- const frameworkRequest = attachFrameworkRequestNativeRouteHandoff(await factory.createRequest(request, signal), {
252
+ frameworkRequest = attachFrameworkRequestNativeRouteHandoff(await factory.createRequest(request, signal), {
173
253
  descriptor,
174
254
  params
175
255
  });
@@ -189,6 +269,8 @@ export class ExpressHttpApplicationAdapter {
189
269
  return;
190
270
  }
191
271
  await factory.writeErrorResponse(error, frameworkResponse, factory.resolveRequestId(request));
272
+ } finally {
273
+ await finalizeRouteOwnedMultipartBody(frameworkRequest);
192
274
  }
193
275
  }
194
276
  }
@@ -287,7 +369,7 @@ function createExpressRequestResponseFactory(multipartOptions, maxBodySize = DEF
287
369
  * @returns The create express adapter result.
288
370
  */
289
371
  export function createExpressAdapter(options = {}, multipartOptions) {
290
- return new ExpressHttpApplicationAdapter(resolvePort(options.port), options.host, resolveNonNegativeIntegerOption('retryDelayMs', options.retryDelayMs, 150), resolveNonNegativeIntegerOption('retryLimit', options.retryLimit, 20), options.https, multipartOptions, resolveNonNegativeIntegerOption('maxBodySize', options.maxBodySize, DEFAULT_MAX_BODY_SIZE), options.rawBody, resolveNonNegativeIntegerOption('shutdownTimeoutMs', options.shutdownTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS));
372
+ return new ExpressHttpApplicationAdapter(resolvePort(options.port), options.host, resolveNonNegativeIntegerOption('retryDelayMs', options.retryDelayMs, 150), resolveNonNegativeIntegerOption('retryLimit', options.retryLimit, 20), options.https, multipartOptions, resolveNonNegativeIntegerOption('maxBodySize', options.maxBodySize, DEFAULT_MAX_BODY_SIZE), options.rawBody, resolveNonNegativeIntegerOption('shutdownTimeoutMs', options.shutdownTimeoutMs, DEFAULT_SHUTDOWN_TIMEOUT_MS), options.nativeMiddleware);
291
373
  }
292
374
 
293
375
  /**
@@ -318,9 +400,12 @@ export async function runExpressApplication(rootModule, options) {
318
400
  }, adapter, logger);
319
401
  }
320
402
  function createFrameworkResponse(response) {
321
- return {
403
+ const headers = Object.fromEntries(Object.entries(response.getHeaders()).filter(entry => entry[1] !== undefined).map(([name, value]) => [name, typeof value === 'number' ? String(value) : value]));
404
+ let frameworkResponse;
405
+ frameworkResponse = {
322
406
  committed: response.headersSent || response.writableEnded,
323
- headers: {},
407
+ earlyHints: createNodeEarlyHintsCapability(response, () => frameworkResponse.committed),
408
+ headers,
324
409
  raw: response,
325
410
  stream: createFrameworkResponseStream(response),
326
411
  redirect(status, location) {
@@ -329,11 +414,19 @@ function createFrameworkResponse(response) {
329
414
  this.committed = true;
330
415
  response.redirect(status, location);
331
416
  },
332
- async send(body) {
417
+ async send(body, options) {
333
418
  if (response.writableEnded) {
334
419
  this.committed = true;
335
420
  return;
336
421
  }
422
+ if (options?.compression === false) {
423
+ disableNativeCompression(response);
424
+ }
425
+ if (body === undefined && response.req.method.toUpperCase() === 'HEAD') {
426
+ this.committed = true;
427
+ response.end();
428
+ return;
429
+ }
337
430
  const existingContentType = response.getHeader('content-type');
338
431
  const serialized = serializeResponseBody(body, typeof existingContentType === 'string' ? existingContentType : undefined);
339
432
  if (!response.hasHeader('content-type') && serialized.defaultContentType) {
@@ -373,6 +466,14 @@ function createFrameworkResponse(response) {
373
466
  statusCode: undefined,
374
467
  statusSet: false
375
468
  };
469
+ return frameworkResponse;
470
+ }
471
+ function disableNativeCompression(response) {
472
+ const cacheControl = response.getHeader('cache-control');
473
+ const value = Array.isArray(cacheControl) ? cacheControl.join(', ') : String(cacheControl ?? '');
474
+ if (!/\bno-transform\b/i.test(value)) {
475
+ response.setHeader('Cache-Control', value ? `${value}, no-transform` : 'no-transform');
476
+ }
376
477
  }
377
478
  function createFrameworkResponseStream(response) {
378
479
  return {
@@ -384,6 +485,9 @@ function createFrameworkResponseStream(response) {
384
485
  get closed() {
385
486
  return response.writableEnded;
386
487
  },
488
+ disableCompression() {
489
+ disableNativeCompression(response);
490
+ },
387
491
  flush() {
388
492
  response.flushHeaders?.();
389
493
  },
@@ -393,20 +497,33 @@ function createFrameworkResponseStream(response) {
393
497
  response.removeListener('close', listener);
394
498
  };
395
499
  },
500
+ onError(listener) {
501
+ response.on('error', listener);
502
+ return () => {
503
+ response.removeListener('error', listener);
504
+ };
505
+ },
396
506
  waitForDrain() {
397
507
  if (response.writableEnded || response.destroyed) {
398
508
  return Promise.resolve();
399
509
  }
400
- return new Promise(resolve => {
401
- const settle = () => {
402
- response.removeListener('drain', settle);
403
- response.removeListener('close', settle);
404
- response.removeListener('error', settle);
510
+ return new Promise((resolve, reject) => {
511
+ const cleanup = () => {
512
+ response.removeListener('drain', resolveDrain);
513
+ response.removeListener('close', resolveDrain);
514
+ response.removeListener('error', rejectError);
515
+ };
516
+ const rejectError = error => {
517
+ cleanup();
518
+ reject(error);
519
+ };
520
+ const resolveDrain = () => {
521
+ cleanup();
405
522
  resolve();
406
523
  };
407
- response.once('drain', settle);
408
- response.once('close', settle);
409
- response.once('error', settle);
524
+ response.once('drain', resolveDrain);
525
+ response.once('close', resolveDrain);
526
+ response.once('error', rejectError);
410
527
  });
411
528
  },
412
529
  write(chunk) {
@@ -424,10 +541,21 @@ async function createFrameworkRequest(request, signal, multipartOptions, maxBody
424
541
  let frameworkRequest;
425
542
  const materializeBody = createMemoizedAsyncValue(async () => {
426
543
  if (isMultipart) {
427
- const parsed = await parseMultipartRequest(request, {
544
+ const resolvedMultipartOptions = {
428
545
  ...multipartOptions,
429
546
  maxTotalSize: multipartOptions?.maxTotalSize ?? maxBodySize
430
- });
547
+ };
548
+ if (multipartOptions?.strategy === 'stream') {
549
+ frameworkRequest.body = parseMultipartStream({
550
+ body: request,
551
+ headers,
552
+ method: request.method,
553
+ signal,
554
+ url: rawUrl
555
+ }, resolvedMultipartOptions);
556
+ return;
557
+ }
558
+ const parsed = await parseMultipartRequest(request, resolvedMultipartOptions);
431
559
  frameworkRequest.body = parsed.fields;
432
560
  frameworkRequest.files = parsed.files;
433
561
  return;
@@ -481,12 +609,14 @@ function collectVersionSensitiveRouteKeys(descriptors) {
481
609
  }
482
610
  async function parseMultipartRequest(request, options = {}) {
483
611
  try {
484
- return await parseMultipart({
485
- body: Readable.toWeb(request),
486
- headers: normalizeHeaders(request.headers),
487
- method: request.method,
488
- url: resolveAbsoluteRequestUrl(request.url)
489
- }, options);
612
+ const result = await parseMultipart(request, options);
613
+ return {
614
+ fields: result.fields,
615
+ files: result.files.map(file => ({
616
+ ...file,
617
+ buffer: Buffer.from(file.buffer)
618
+ }))
619
+ };
490
620
  } catch (error) {
491
621
  if (isExpressMultipartTooLargeError(error)) {
492
622
  if (error instanceof PayloadTooLargeException) {
@@ -580,33 +710,74 @@ function resolveNonNegativeIntegerOption(name, value, defaultValue) {
580
710
  }
581
711
  return resolved;
582
712
  }
583
- async function listenServer(server, port, host) {
713
+ async function listenServer(input) {
714
+ const {
715
+ host,
716
+ port,
717
+ server,
718
+ signal
719
+ } = input;
584
720
  await new Promise((resolve, reject) => {
585
- const onError = error => {
586
- cleanup();
587
- reject(error);
588
- };
589
- const onListening = () => {
721
+ if (signal.aborted) {
722
+ reject(new ExpressListenCancelledError());
723
+ return;
724
+ }
725
+ let settled = false;
726
+ function onError(error) {
727
+ finishReject(error);
728
+ }
729
+ function onListening() {
730
+ finishResolve();
731
+ }
732
+ function onAbort() {
590
733
  cleanup();
591
- resolve();
592
- };
593
- const cleanup = () => {
734
+ void closeServerBestEffort(server).then(() => {
735
+ finishReject(new ExpressListenCancelledError());
736
+ });
737
+ }
738
+ function cleanup() {
594
739
  server.off('error', onError);
595
740
  server.off('listening', onListening);
596
- };
741
+ signal.removeEventListener('abort', onAbort);
742
+ }
743
+ function finishResolve() {
744
+ if (settled) {
745
+ return;
746
+ }
747
+ settled = true;
748
+ cleanup();
749
+ resolve();
750
+ }
751
+ function finishReject(error) {
752
+ if (settled) {
753
+ return;
754
+ }
755
+ settled = true;
756
+ cleanup();
757
+ reject(error);
758
+ }
597
759
  server.once('error', onError);
598
760
  server.once('listening', onListening);
761
+ signal.addEventListener('abort', onAbort, {
762
+ once: true
763
+ });
599
764
  try {
600
765
  server.listen({
601
766
  host,
602
767
  port
603
768
  });
604
769
  } catch (error) {
605
- cleanup();
606
- reject(error);
770
+ finishReject(error);
607
771
  }
608
772
  });
609
773
  }
774
+ function closeServerBestEffort(server) {
775
+ return new Promise(resolve => {
776
+ server.close(() => {
777
+ resolve();
778
+ });
779
+ });
780
+ }
610
781
  function closeServerSilently(server) {
611
782
  if (!server.listening) {
612
783
  return Promise.resolve();
@@ -667,9 +838,47 @@ function isAddressInUseError(error) {
667
838
  }
668
839
  return error.code === 'EADDRINUSE';
669
840
  }
670
- function delay(ms) {
671
- return new Promise(resolve => {
672
- setTimeout(resolve, ms);
841
+ class ExpressListenCancelledError extends Error {
842
+ name = 'ExpressListenCancelledError';
843
+ constructor() {
844
+ super('Express adapter startup was cancelled during shutdown.');
845
+ }
846
+ }
847
+ function throwIfListenCancelled(signal) {
848
+ if (signal.aborted) {
849
+ throw new ExpressListenCancelledError();
850
+ }
851
+ }
852
+ function isExpressListenCancelledError(error) {
853
+ return error instanceof ExpressListenCancelledError;
854
+ }
855
+ async function ignoreCancelledListen(listenPromise) {
856
+ try {
857
+ await listenPromise;
858
+ } catch (error) {
859
+ if (isExpressListenCancelledError(error)) {
860
+ return;
861
+ }
862
+ throw error;
863
+ }
864
+ }
865
+ function delay(ms, signal) {
866
+ return new Promise((resolve, reject) => {
867
+ if (signal?.aborted) {
868
+ reject(new ExpressListenCancelledError());
869
+ return;
870
+ }
871
+ const timeout = setTimeout(() => {
872
+ signal?.removeEventListener('abort', onAbort);
873
+ resolve();
874
+ }, ms);
875
+ const onAbort = () => {
876
+ clearTimeout(timeout);
877
+ reject(new ExpressListenCancelledError());
878
+ };
879
+ signal?.addEventListener('abort', onAbort, {
880
+ once: true
881
+ });
673
882
  });
674
883
  }
675
884
  function waitForCloseWithTimeout(closePromise, timeoutMs) {
package/package.json CHANGED
@@ -8,7 +8,7 @@
8
8
  "platform",
9
9
  "server"
10
10
  ],
11
- "version": "1.0.6",
11
+ "version": "2.0.0",
12
12
  "private": false,
13
13
  "license": "MIT",
14
14
  "repository": {
@@ -17,7 +17,7 @@
17
17
  "directory": "packages/platform-express"
18
18
  },
19
19
  "engines": {
20
- "node": ">=20.0.0"
20
+ "node": ">=24.0.0 <27"
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
@@ -36,13 +36,14 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "express": "^5.1.0",
39
- "@fluojs/http": "^1.1.1",
40
- "@fluojs/runtime": "^1.1.7"
39
+ "@fluojs/http": "^3.0.0",
40
+ "@fluojs/platform-nodejs": "^2.0.0",
41
+ "@fluojs/runtime": "^3.0.0"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@types/express": "^5.0.3",
44
- "vitest": "^3.2.4",
45
- "@fluojs/di": "^1.1.0"
45
+ "vitest": "^4.1.11",
46
+ "@fluojs/di": "^3.0.0"
46
47
  },
47
48
  "scripts": {
48
49
  "prebuild": "node ../../tooling/scripts/clean-dist.mjs",
File without changes