Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
always-auth: true
registry-url: https://registry.npmjs.org

- uses: pnpm/action-setup@v4
with:
version: 8.15.6
version: 9.14.0

- name: Setup Git
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
node-version: 20
always-auth: true
registry-url: https://registry.npmjs.org

- uses: pnpm/action-setup@v4
with:
version: 8.15.6
version: 9.14.0

- name: Install dependencies
run: pnpm install
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,5 @@
"engines": {
"node": ">=18"
},
"packageManager": "pnpm@8.15.6"
}
"packageManager": "pnpm@9.14.2"
}
294 changes: 155 additions & 139 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

42 changes: 22 additions & 20 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,39 @@
import crypto from 'node:crypto'
import { Readable } from 'node:stream'
import { handleRequest } from 'msw'
import { Emitter } from 'strict-event-emitter'

import type { ReadableStream as NodeReadableStream } from 'node:stream/web'
import type { RequestHandler as ExpressMiddleware } from 'express'
import type { LifeCycleEventsMap, RequestHandler } from 'msw'
import {
handleRequest,
type LifeCycleEventsMap,
type RequestHandler,
} from 'msw'
import { Emitter } from 'strict-event-emitter'

const emitter = new Emitter<LifeCycleEventsMap>()

export function createMiddleware(
...handlers: Array<RequestHandler>
): ExpressMiddleware {
return async (req, res, next) => {
const method = req.method || 'GET'
const serverOrigin = `${req.protocol}://${req.get('host')}`
return async (request, response, next) => {
const method = request.method || 'GET'
const origin = `${request.protocol}://${request.get('host')}`
const canRequestHaveBody = method !== 'HEAD' && method !== 'GET'

const fetchRequest = new Request(
// Treat all relative URLs as the ones coming from the server.
new URL(req.url, serverOrigin),
new URL(request.url, origin),
{
method,
headers: new Headers(req.headers as HeadersInit),
headers: new Headers(request.headers as HeadersInit),
credentials: 'omit',
// @ts-ignore Internal Undici property.
duplex: canRequestHaveBody ? 'half' : undefined,
body: canRequestHaveBody
? req.readable
? (Readable.toWeb(req) as ReadableStream)
: req.header('content-type')?.includes('json')
? JSON.stringify(req.body)
: req.body
? request.readable
? (Readable.toWeb(request) as ReadableStream)
: request.header('content-type')?.includes('json')
? JSON.stringify(request.body)
: request.body
: undefined,
},
)
Expand All @@ -50,29 +52,29 @@ export function createMiddleware(
* @note Resolve relative request handler URLs against
* the server's origin (no relative URLs in Node.js).
*/
baseUrl: serverOrigin,
baseUrl: origin,
},
async onMockedResponse(mockedResponse) {
const { status, statusText, headers } = mockedResponse

res.statusCode = status
res.statusMessage = statusText
response.statusCode = status
response.statusMessage = statusText

headers.forEach((value, name) => {
/**
* @note Use `.appendHeader()` to support multi-value
* response headers, like "Set-Cookie".
*/
res.appendHeader(name, value)
response.appendHeader(name, value)
})

if (mockedResponse.body) {
const stream = Readable.fromWeb(
mockedResponse.body as NodeReadableStream,
)
stream.pipe(res)
stream.pipe(response)
} else {
res.end()
response.end()
}
},
onPassthroughResponse() {
Expand Down
54 changes: 45 additions & 9 deletions test/middleware.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ const httpServer = new HttpServer((app) => {
http.post('/users', () => {
return new HttpResponse(null, { status: 204 })
}),

http.post('/proxy', async ({ request }) => {
return HttpResponse.json(await request.json())
}),
http.get('/error', () => {
throw new Error('Something went wrong.')
}),

http.get('/user', () => {
return HttpResponse.json(
{ firstName: 'John' },
Expand All @@ -31,6 +32,14 @@ const httpServer = new HttpServer((app) => {
app.get('/book', (req, res) => {
return res.status(200).send('book')
})

app.use((req, res) => {
res.status(404).json({
readable: req.readable,
readableDidRead: req.readableDidRead,
readableEnded: req.readableEnded,
})
})
})

beforeAll(async () => {
Expand All @@ -48,18 +57,29 @@ afterEach(() => {
it('returns the mocked response when requesting the middleware', async () => {
const response = await fetch(httpServer.http.url('/user'))

expect(response.headers.get('x-my-header')).toEqual('value')
await expect(response.json()).resolves.toEqual({ firstName: 'John' })
expect.soft(response.headers.get('x-my-header')).toEqual('value')
await expect.soft(response.json()).resolves.toEqual({ firstName: 'John' })
})

it('returns the mocked 204 with empty body', async () => {
const response = await fetch(httpServer.http.url('/users'), {
method: 'POST',
})

expect(response.status).toEqual(204)
expect(response.ok).toBeTruthy()
expect(response.bodyUsed).toBeFalsy()
expect.soft(response.status).toEqual(204)
expect.soft(response.ok).toBeTruthy()
expect.soft(response.bodyUsed).toBeFalsy()
})

it('allows reading request body in the resolver', async () => {
const response = await fetch(httpServer.http.url('/proxy'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ hello: 'world' }),
})

expect.soft(response.status).toEqual(200)
await expect.soft(response.json()).resolves.toEqual({ hello: 'world' })
})

it('returns the original response given no matching request handler', async () => {
Expand All @@ -70,6 +90,22 @@ it('returns the original response given no matching request handler', async () =
it('forwards promise rejections to error middleware', async () => {
const response = await fetch(httpServer.http.url('/error'))

expect(response.status).toEqual(500)
expect(response.ok).toBeFalsy()
expect.soft(response.status).toEqual(500)
expect.soft(response.ok).toBeFalsy()
})

it('does not lock the request stream for other middleware', async () => {
const response = await fetch(httpServer.http.url('/intentionally-unknown'), {
method: 'POST',
headers: { 'content-type': 'text/plain' },
body: 'hello world',
})

console.log(await response.text())

await expect(response.json()).resolves.toEqual({
readable: true,
readableDidRead: false,
readableEnded: false,
})
})
18 changes: 12 additions & 6 deletions test/with-express-multipart-form.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@ it('supports "multipart/form-data" requests (no body parser)', async () => {
body: form,
})

expect(res.status).toBe(200)
expect(res.headers.get('x-my-header')).toBe('value')
expect(await res.json()).toEqual({ field1: 'value1', field2: 'value2' })
expect.soft(res.status).toBe(200)
expect.soft(res.headers.get('x-my-header')).toBe('value')
await expect.soft(res.json()).resolves.toEqual({
field1: 'value1',
field2: 'value2',
})
})

it('supports "multipart/form-data" requests (raw body parser)', async () => {
Expand Down Expand Up @@ -87,7 +90,10 @@ it('supports "multipart/form-data" requests (raw body parser)', async () => {
body: form,
})

expect(res.status).toBe(200)
expect(res.headers.get('x-my-header')).toBe('value')
expect(await res.json()).toEqual({ field1: 'value1', field2: 'value2' })
expect.soft(res.status).toBe(200)
expect.soft(res.headers.get('x-my-header')).toBe('value')
await expect.soft(res.json()).resolves.toEqual({
field1: 'value1',
field2: 'value2',
})
})
1 change: 1 addition & 0 deletions tsconfig.base.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"compilerOptions": {
"strict": true,
"skipLibCheck": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
Expand Down
Loading