修改后台权限

This commit is contained in:
yoyuzh
2026-03-24 14:30:59 +08:00
parent 00f902f475
commit b2d9db7be9
9310 changed files with 1246063 additions and 48 deletions

View File

@@ -0,0 +1,255 @@
import { vi, it, expect, afterEach } from 'vitest'
import { Interceptor } from './Interceptor'
import { BatchInterceptor } from './BatchInterceptor'
afterEach(() => {
vi.resetAllMocks()
})
it('applies child interceptors', () => {
class PrimaryInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('primary'))
}
}
class SecondaryInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('secondary'))
}
}
const instances = {
primary: new PrimaryInterceptor(),
secondary: new SecondaryInterceptor(),
}
const interceptor = new BatchInterceptor({
name: 'batch-apply',
interceptors: [instances.primary, instances.secondary],
})
const primaryApplySpy = vi.spyOn(instances.primary, 'apply')
const secondaryApplySpy = vi.spyOn(instances.secondary, 'apply')
interceptor.apply()
expect(primaryApplySpy).toHaveBeenCalledTimes(1)
expect(secondaryApplySpy).toHaveBeenCalledTimes(1)
})
it('proxies event listeners to the interceptors', () => {
class PrimaryInterceptor extends Interceptor<{ hello: [string] }> {
constructor() {
super(Symbol('primary'))
}
}
class SecondaryInterceptor extends Interceptor<{
goodbye: [string]
}> {
constructor() {
super(Symbol('secondary'))
}
}
const instances = {
primary: new PrimaryInterceptor(),
secondary: new SecondaryInterceptor(),
}
const interceptor = new BatchInterceptor({
name: 'batch-proxy',
interceptors: [instances.primary, instances.secondary],
})
const helloListener = vi.fn()
interceptor.on('hello', helloListener)
const goodbyeListener = vi.fn()
interceptor.on('goodbye', goodbyeListener)
// Emulate the child interceptor emitting events.
instances.primary['emitter'].emit('hello', 'John')
instances.secondary['emitter'].emit('goodbye', 'Kate')
// Must call the batch interceptor listener.
expect(helloListener).toHaveBeenCalledTimes(1)
expect(helloListener).toHaveBeenCalledWith('John')
expect(goodbyeListener).toHaveBeenCalledTimes(1)
expect(goodbyeListener).toHaveBeenCalledWith('Kate')
})
it('disposes of child interceptors', async () => {
class PrimaryInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('primary'))
}
}
class SecondaryInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('secondary'))
}
}
const instances = {
primary: new PrimaryInterceptor(),
secondary: new SecondaryInterceptor(),
}
const interceptor = new BatchInterceptor({
name: 'batch-dispose',
interceptors: [instances.primary, instances.secondary],
})
const primaryDisposeSpy = vi.spyOn(instances.primary, 'dispose')
const secondaryDisposeSpy = vi.spyOn(instances.secondary, 'dispose')
interceptor.apply()
interceptor.dispose()
expect(primaryDisposeSpy).toHaveBeenCalledTimes(1)
expect(secondaryDisposeSpy).toHaveBeenCalledTimes(1)
})
it('forwards listeners added via "on()"', () => {
class FirstInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('first'))
}
}
class SecondaryInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('second'))
}
}
const firstInterceptor = new FirstInterceptor()
const secondInterceptor = new SecondaryInterceptor()
const interceptor = new BatchInterceptor({
name: 'batch',
interceptors: [firstInterceptor, secondInterceptor],
})
const listener = vi.fn()
interceptor.on('foo', listener)
expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(1)
expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(1)
expect(interceptor['emitter'].listenerCount('foo')).toBe(0)
})
it('forwards listeners removal via "off()"', () => {
type Events = {
foo: []
}
class FirstInterceptor extends Interceptor<Events> {
constructor() {
super(Symbol('first'))
}
}
class SecondaryInterceptor extends Interceptor<Events> {
constructor() {
super(Symbol('second'))
}
}
const firstInterceptor = new FirstInterceptor()
const secondInterceptor = new SecondaryInterceptor()
const interceptor = new BatchInterceptor({
name: 'batch',
interceptors: [firstInterceptor, secondInterceptor],
})
const listener = vi.fn()
interceptor.on('foo', listener)
interceptor.off('foo', listener)
expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0)
expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0)
})
it('forwards removal of all listeners by name via ".removeAllListeners()"', () => {
type Events = {
foo: []
bar: []
}
class FirstInterceptor extends Interceptor<Events> {
constructor() {
super(Symbol('first'))
}
}
class SecondaryInterceptor extends Interceptor<Events> {
constructor() {
super(Symbol('second'))
}
}
const firstInterceptor = new FirstInterceptor()
const secondInterceptor = new SecondaryInterceptor()
const interceptor = new BatchInterceptor({
name: 'batch',
interceptors: [firstInterceptor, secondInterceptor],
})
const listener = vi.fn()
interceptor.on('foo', listener)
interceptor.on('foo', listener)
interceptor.on('bar', listener)
expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2)
expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2)
expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1)
expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1)
interceptor.removeAllListeners('foo')
expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0)
expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0)
expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1)
expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1)
})
it('forwards removal of all listeners via ".removeAllListeners()"', () => {
class FirstInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('first'))
}
}
class SecondaryInterceptor extends Interceptor<any> {
constructor() {
super(Symbol('second'))
}
}
const firstInterceptor = new FirstInterceptor()
const secondInterceptor = new SecondaryInterceptor()
const interceptor = new BatchInterceptor({
name: 'batch',
interceptors: [firstInterceptor, secondInterceptor],
})
const listener = vi.fn()
interceptor.on('foo', listener)
interceptor.on('foo', listener)
interceptor.on('bar', listener)
expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(2)
expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(2)
expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(1)
expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(1)
interceptor.removeAllListeners()
expect(firstInterceptor['emitter'].listenerCount('foo')).toBe(0)
expect(secondInterceptor['emitter'].listenerCount('foo')).toBe(0)
expect(firstInterceptor['emitter'].listenerCount('bar')).toBe(0)
expect(secondInterceptor['emitter'].listenerCount('bar')).toBe(0)
})

View File

@@ -0,0 +1,95 @@
import { EventMap, Listener } from 'strict-event-emitter'
import { Interceptor, ExtractEventNames } from './Interceptor'
export interface BatchInterceptorOptions<
InterceptorList extends ReadonlyArray<Interceptor<any>>
> {
name: string
interceptors: InterceptorList
}
export type ExtractEventMapType<
InterceptorList extends ReadonlyArray<Interceptor<any>>
> = InterceptorList extends ReadonlyArray<infer InterceptorType>
? InterceptorType extends Interceptor<infer EventMap>
? EventMap
: never
: never
/**
* A batch interceptor that exposes a single interface
* to apply and operate with multiple interceptors at once.
*/
export class BatchInterceptor<
InterceptorList extends ReadonlyArray<Interceptor<any>>,
Events extends EventMap = ExtractEventMapType<InterceptorList>
> extends Interceptor<Events> {
static symbol: symbol
private interceptors: InterceptorList
constructor(options: BatchInterceptorOptions<InterceptorList>) {
BatchInterceptor.symbol = Symbol(options.name)
super(BatchInterceptor.symbol)
this.interceptors = options.interceptors
}
protected setup() {
const logger = this.logger.extend('setup')
logger.info('applying all %d interceptors...', this.interceptors.length)
for (const interceptor of this.interceptors) {
logger.info('applying "%s" interceptor...', interceptor.constructor.name)
interceptor.apply()
logger.info('adding interceptor dispose subscription')
this.subscriptions.push(() => interceptor.dispose())
}
}
public on<EventName extends ExtractEventNames<Events>>(
event: EventName,
listener: Listener<Events[EventName]>
): this {
// Instead of adding a listener to the batch interceptor,
// propagate the listener to each of the individual interceptors.
for (const interceptor of this.interceptors) {
interceptor.on(event, listener)
}
return this
}
public once<EventName extends ExtractEventNames<Events>>(
event: EventName,
listener: Listener<Events[EventName]>
): this {
for (const interceptor of this.interceptors) {
interceptor.once(event, listener)
}
return this
}
public off<EventName extends ExtractEventNames<Events>>(
event: EventName,
listener: Listener<Events[EventName]>
): this {
for (const interceptor of this.interceptors) {
interceptor.off(event, listener)
}
return this
}
public removeAllListeners<EventName extends ExtractEventNames<Events>>(
event?: EventName | undefined
): this {
for (const interceptors of this.interceptors) {
interceptors.removeAllListeners(event)
}
return this
}
}

View File

@@ -0,0 +1,205 @@
import { describe, vi, it, expect, afterEach } from 'vitest'
import {
Interceptor,
getGlobalSymbol,
deleteGlobalSymbol,
InterceptorReadyState,
} from './Interceptor'
import { nextTickAsync } from './utils/nextTick'
const symbol = Symbol('test')
afterEach(() => {
deleteGlobalSymbol(symbol)
})
it('does not set a maximum listeners limit', () => {
const interceptor = new Interceptor(symbol)
expect(interceptor['emitter'].getMaxListeners()).toBe(0)
})
describe('on()', () => {
it('adds a new listener using "on()"', () => {
const interceptor = new Interceptor(symbol)
expect(interceptor['emitter'].listenerCount('event')).toBe(0)
const listener = vi.fn()
interceptor.on('event', listener)
expect(interceptor['emitter'].listenerCount('event')).toBe(1)
})
})
describe('once()', () => {
it('calls the listener only once', () => {
const interceptor = new Interceptor(symbol)
const listener = vi.fn()
interceptor.once('foo', listener)
expect(listener).not.toHaveBeenCalled()
interceptor['emitter'].emit('foo', 'bar')
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenCalledWith('bar')
listener.mockReset()
interceptor['emitter'].emit('foo', 'baz')
interceptor['emitter'].emit('foo', 'xyz')
expect(listener).toHaveBeenCalledTimes(0)
})
})
describe('off()', () => {
it('removes a listener using "off()"', () => {
const interceptor = new Interceptor(symbol)
expect(interceptor['emitter'].listenerCount('event')).toBe(0)
const listener = vi.fn()
interceptor.on('event', listener)
expect(interceptor['emitter'].listenerCount('event')).toBe(1)
interceptor.off('event', listener)
expect(interceptor['emitter'].listenerCount('event')).toBe(0)
})
})
describe('persistence', () => {
it('stores global reference to the applied interceptor', () => {
const interceptor = new Interceptor(symbol)
interceptor.apply()
expect(getGlobalSymbol(symbol)).toEqual(interceptor)
})
it('deletes global reference when the interceptor is disposed', () => {
const interceptor = new Interceptor(symbol)
interceptor.apply()
interceptor.dispose()
expect(getGlobalSymbol(symbol)).toBeUndefined()
})
})
describe('readyState', () => {
it('sets the state to "INACTIVE" when the interceptor is created', () => {
const interceptor = new Interceptor(symbol)
expect(interceptor.readyState).toBe(InterceptorReadyState.INACTIVE)
})
it('leaves state as "INACTIVE" if the interceptor failed the environment check', async () => {
class MyInterceptor extends Interceptor<any> {
protected checkEnvironment(): boolean {
return false
}
}
const interceptor = new MyInterceptor(symbol)
interceptor.apply()
expect(interceptor.readyState).toBe(InterceptorReadyState.INACTIVE)
})
it('performs state transition when the interceptor is applying', async () => {
const interceptor = new Interceptor(symbol)
interceptor.apply()
// The interceptor's state transitions to APPLIED immediately.
// The only exception is if something throws during the setup.
expect(interceptor.readyState).toBe(InterceptorReadyState.APPLIED)
})
it('performs state transition when disposing of the interceptor', async () => {
const interceptor = new Interceptor(symbol)
interceptor.apply()
interceptor.dispose()
// The interceptor's state transitions to DISPOSED immediately.
// The only exception is if something throws during the teardown.
expect(interceptor.readyState).toBe(InterceptorReadyState.DISPOSED)
})
})
describe('apply', () => {
it('does not apply the same interceptor multiple times', () => {
const interceptor = new Interceptor(symbol)
const setupSpy = vi.spyOn(
interceptor,
// @ts-expect-error Protected property spy.
'setup'
)
// Intentionally apply the same interceptor multiple times.
interceptor.apply()
interceptor.apply()
interceptor.apply()
// The "setup" must not be called repeatedly.
expect(setupSpy).toHaveBeenCalledTimes(1)
expect(getGlobalSymbol(symbol)).toEqual(interceptor)
})
it('does not call "apply" if the interceptor fails environment check', () => {
class MyInterceptor extends Interceptor<{}> {
checkEnvironment() {
return false
}
}
const interceptor = new MyInterceptor(Symbol('test'))
const setupSpy = vi.spyOn(
interceptor,
// @ts-expect-error Protected property spy.
'setup'
)
interceptor.apply()
expect(setupSpy).not.toHaveBeenCalled()
})
it('proxies listeners from new interceptor to already running interceptor', () => {
const firstInterceptor = new Interceptor(symbol)
const secondInterceptor = new Interceptor(symbol)
firstInterceptor.apply()
const firstListener = vi.fn()
firstInterceptor.on('test', firstListener)
secondInterceptor.apply()
const secondListener = vi.fn()
secondInterceptor.on('test', secondListener)
// Emitting event in the first interceptor will bubble to the second one.
firstInterceptor['emitter'].emit('test', 'hello world')
expect(firstListener).toHaveBeenCalledTimes(1)
expect(firstListener).toHaveBeenCalledWith('hello world')
expect(secondListener).toHaveBeenCalledTimes(1)
expect(secondListener).toHaveBeenCalledWith('hello world')
expect(secondInterceptor['emitter'].listenerCount('test')).toBe(0)
})
})
describe('dispose', () => {
it('removes all listeners when the interceptor is disposed', async () => {
const interceptor = new Interceptor(symbol)
interceptor.apply()
const listener = vi.fn()
interceptor.on('test', listener)
interceptor.dispose()
// Even after emitting an event, the listener must not get called.
interceptor['emitter'].emit('test')
expect(listener).not.toHaveBeenCalled()
// The listener must not be called on the next tick either.
await nextTickAsync(() => {
interceptor['emitter'].emit('test')
expect(listener).not.toHaveBeenCalled()
})
})
})

249
node_modules/@mswjs/interceptors/src/Interceptor.ts generated vendored Normal file
View File

@@ -0,0 +1,249 @@
import { Logger } from '@open-draft/logger'
import { Emitter, Listener } from 'strict-event-emitter'
export type InterceptorEventMap = Record<string, any>
export type InterceptorSubscription = () => void
/**
* Request header name to detect when a single request
* is being handled by nested interceptors (XHR -> ClientRequest).
* Obscure by design to prevent collisions with user-defined headers.
* Ideally, come up with the Interceptor-level mechanism for this.
* @see https://github.com/mswjs/interceptors/issues/378
*/
export const INTERNAL_REQUEST_ID_HEADER_NAME =
'x-interceptors-internal-request-id'
export function getGlobalSymbol<V>(symbol: Symbol): V | undefined {
return (
// @ts-ignore https://github.com/Microsoft/TypeScript/issues/24587
globalThis[symbol] || undefined
)
}
function setGlobalSymbol(symbol: Symbol, value: any): void {
// @ts-ignore
globalThis[symbol] = value
}
export function deleteGlobalSymbol(symbol: Symbol): void {
// @ts-ignore
delete globalThis[symbol]
}
export enum InterceptorReadyState {
INACTIVE = 'INACTIVE',
APPLYING = 'APPLYING',
APPLIED = 'APPLIED',
DISPOSING = 'DISPOSING',
DISPOSED = 'DISPOSED',
}
export type ExtractEventNames<Events extends Record<string, any>> =
Events extends Record<infer EventName, any> ? EventName : never
export class Interceptor<Events extends InterceptorEventMap> {
protected emitter: Emitter<Events>
protected subscriptions: Array<InterceptorSubscription>
protected logger: Logger
public readyState: InterceptorReadyState
constructor(private readonly symbol: symbol) {
this.readyState = InterceptorReadyState.INACTIVE
this.emitter = new Emitter()
this.subscriptions = []
this.logger = new Logger(symbol.description!)
// Do not limit the maximum number of listeners
// so not to limit the maximum amount of parallel events emitted.
this.emitter.setMaxListeners(0)
this.logger.info('constructing the interceptor...')
}
/**
* Determine if this interceptor can be applied
* in the current environment.
*/
protected checkEnvironment(): boolean {
return true
}
/**
* Apply this interceptor to the current process.
* Returns an already running interceptor instance if it's present.
*/
public apply(): void {
const logger = this.logger.extend('apply')
logger.info('applying the interceptor...')
if (this.readyState === InterceptorReadyState.APPLIED) {
logger.info('intercepted already applied!')
return
}
const shouldApply = this.checkEnvironment()
if (!shouldApply) {
logger.info('the interceptor cannot be applied in this environment!')
return
}
this.readyState = InterceptorReadyState.APPLYING
// Whenever applying a new interceptor, check if it hasn't been applied already.
// This enables to apply the same interceptor multiple times, for example from a different
// interceptor, only proxying events but keeping the stubs in a single place.
const runningInstance = this.getInstance()
if (runningInstance) {
logger.info('found a running instance, reusing...')
// Proxy any listeners you set on this instance to the running instance.
this.on = (event, listener) => {
logger.info('proxying the "%s" listener', event)
// Add listeners to the running instance so they appear
// at the top of the event listeners list and are executed first.
runningInstance.emitter.addListener(event, listener)
// Ensure that once this interceptor instance is disposed,
// it removes all listeners it has appended to the running interceptor instance.
this.subscriptions.push(() => {
runningInstance.emitter.removeListener(event, listener)
logger.info('removed proxied "%s" listener!', event)
})
return this
}
this.readyState = InterceptorReadyState.APPLIED
return
}
logger.info('no running instance found, setting up a new instance...')
// Setup the interceptor.
this.setup()
// Store the newly applied interceptor instance globally.
this.setInstance()
this.readyState = InterceptorReadyState.APPLIED
}
/**
* Setup the module augments and stubs necessary for this interceptor.
* This method is not run if there's a running interceptor instance
* to prevent instantiating an interceptor multiple times.
*/
protected setup(): void {}
/**
* Listen to the interceptor's public events.
*/
public on<EventName extends ExtractEventNames<Events>>(
event: EventName,
listener: Listener<Events[EventName]>
): this {
const logger = this.logger.extend('on')
if (
this.readyState === InterceptorReadyState.DISPOSING ||
this.readyState === InterceptorReadyState.DISPOSED
) {
logger.info('cannot listen to events, already disposed!')
return this
}
logger.info('adding "%s" event listener:', event, listener)
this.emitter.on(event, listener)
return this
}
public once<EventName extends ExtractEventNames<Events>>(
event: EventName,
listener: Listener<Events[EventName]>
): this {
this.emitter.once(event, listener)
return this
}
public off<EventName extends ExtractEventNames<Events>>(
event: EventName,
listener: Listener<Events[EventName]>
): this {
this.emitter.off(event, listener)
return this
}
public removeAllListeners<EventName extends ExtractEventNames<Events>>(
event?: EventName
): this {
this.emitter.removeAllListeners(event)
return this
}
/**
* Disposes of any side-effects this interceptor has introduced.
*/
public dispose(): void {
const logger = this.logger.extend('dispose')
if (this.readyState === InterceptorReadyState.DISPOSED) {
logger.info('cannot dispose, already disposed!')
return
}
logger.info('disposing the interceptor...')
this.readyState = InterceptorReadyState.DISPOSING
if (!this.getInstance()) {
logger.info('no interceptors running, skipping dispose...')
return
}
// Delete the global symbol as soon as possible,
// indicating that the interceptor is no longer running.
this.clearInstance()
logger.info('global symbol deleted:', getGlobalSymbol(this.symbol))
if (this.subscriptions.length > 0) {
logger.info('disposing of %d subscriptions...', this.subscriptions.length)
for (const dispose of this.subscriptions) {
dispose()
}
this.subscriptions = []
logger.info('disposed of all subscriptions!', this.subscriptions.length)
}
this.emitter.removeAllListeners()
logger.info('destroyed the listener!')
this.readyState = InterceptorReadyState.DISPOSED
}
private getInstance(): this | undefined {
const instance = getGlobalSymbol<this>(this.symbol)
this.logger.info('retrieved global instance:', instance?.constructor?.name)
return instance
}
private setInstance(): void {
setGlobalSymbol(this.symbol, this)
this.logger.info('set global instance!', this.symbol.description)
}
private clearInstance(): void {
deleteGlobalSymbol(this.symbol)
this.logger.info('cleared global instance!', this.symbol.description)
}
}

View File

@@ -0,0 +1,7 @@
export class InterceptorError extends Error {
constructor(message?: string) {
super(message)
this.name = 'InterceptorError'
Object.setPrototypeOf(this, InterceptorError.prototype)
}
}

View File

@@ -0,0 +1,251 @@
import { ChildProcess } from 'child_process'
import { HttpRequestEventMap } from './glossary'
import { Interceptor } from './Interceptor'
import { BatchInterceptor } from './BatchInterceptor'
import { ClientRequestInterceptor } from './interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from './interceptors/XMLHttpRequest'
import { FetchInterceptor } from './interceptors/fetch'
import { handleRequest } from './utils/handleRequest'
import { RequestController } from './RequestController'
import { FetchResponse } from './utils/fetchUtils'
import { isResponseError } from './utils/responseUtils'
export interface SerializedRequest {
id: string
url: string
method: string
headers: Array<[string, string]>
credentials: RequestCredentials
body: string
}
interface RevivedRequest extends Omit<SerializedRequest, 'url' | 'headers'> {
url: URL
headers: Headers
}
export interface SerializedResponse {
status: number
statusText: string
headers: Array<[string, string]>
body: string
}
export class RemoteHttpInterceptor extends BatchInterceptor<
[ClientRequestInterceptor, XMLHttpRequestInterceptor, FetchInterceptor]
> {
constructor() {
super({
name: 'remote-interceptor',
interceptors: [
new ClientRequestInterceptor(),
new XMLHttpRequestInterceptor(),
new FetchInterceptor(),
],
})
}
protected setup() {
super.setup()
let handleParentMessage: NodeJS.MessageListener
this.on('request', async ({ request, requestId, controller }) => {
// Send the stringified intercepted request to
// the parent process where the remote resolver is established.
const serializedRequest = JSON.stringify({
id: requestId,
method: request.method,
url: request.url,
headers: Array.from(request.headers.entries()),
credentials: request.credentials,
body: ['GET', 'HEAD'].includes(request.method)
? null
: await request.text(),
} as SerializedRequest)
this.logger.info(
'sent serialized request to the child:',
serializedRequest
)
process.send?.(`request:${serializedRequest}`)
const responsePromise = new Promise<void>((resolve) => {
handleParentMessage = (message) => {
if (typeof message !== 'string') {
return resolve()
}
if (message.startsWith(`response:${requestId}`)) {
const [, serializedResponse] =
message.match(/^response:.+?:(.+)$/) || []
if (!serializedResponse) {
return resolve()
}
const responseInit = JSON.parse(
serializedResponse
) as SerializedResponse
const mockedResponse = new FetchResponse(responseInit.body, {
url: request.url,
status: responseInit.status,
statusText: responseInit.statusText,
headers: responseInit.headers,
})
/**
* @todo Support "errorWith" as well.
* This response handling from the child is incomplete.
*/
controller.respondWith(mockedResponse)
return resolve()
}
}
})
// Listen for the mocked response message from the parent.
this.logger.info(
'add "message" listener to the parent process',
handleParentMessage
)
process.addListener('message', handleParentMessage)
return responsePromise
})
this.subscriptions.push(() => {
process.removeListener('message', handleParentMessage)
})
}
}
export function requestReviver(key: string, value: any) {
switch (key) {
case 'url':
return new URL(value)
case 'headers':
return new Headers(value)
default:
return value
}
}
export interface RemoveResolverOptions {
process: ChildProcess
}
export class RemoteHttpResolver extends Interceptor<HttpRequestEventMap> {
static symbol = Symbol('remote-resolver')
private process: ChildProcess
constructor(options: RemoveResolverOptions) {
super(RemoteHttpResolver.symbol)
this.process = options.process
}
protected setup() {
const logger = this.logger.extend('setup')
const handleChildMessage: NodeJS.MessageListener = async (message) => {
logger.info('received message from child!', message)
if (typeof message !== 'string' || !message.startsWith('request:')) {
logger.info('unknown message, ignoring...')
return
}
const [, serializedRequest] = message.match(/^request:(.+)$/) || []
if (!serializedRequest) {
return
}
const requestJson = JSON.parse(
serializedRequest,
requestReviver
) as RevivedRequest
logger.info('parsed intercepted request', requestJson)
const request = new Request(requestJson.url, {
method: requestJson.method,
headers: new Headers(requestJson.headers),
credentials: requestJson.credentials,
body: requestJson.body,
})
const controller = new RequestController(request, {
passthrough: () => {},
respondWith: async (response) => {
if (isResponseError(response)) {
this.logger.info('received a network error!', { response })
throw new Error('Not implemented')
}
this.logger.info('received mocked response!', { response })
const responseClone = response.clone()
const responseText = await responseClone.text()
// // Send the mocked response to the child process.
const serializedResponse = JSON.stringify({
status: response.status,
statusText: response.statusText,
headers: Array.from(response.headers.entries()),
body: responseText,
} as SerializedResponse)
this.process.send(
`response:${requestJson.id}:${serializedResponse}`,
(error) => {
if (error) {
return
}
// Emit an optimistic "response" event at this point,
// not to rely on the back-and-forth signaling for the sake of the event.
this.emitter.emit('response', {
request,
requestId: requestJson.id,
response: responseClone,
isMockedResponse: true,
})
}
)
logger.info(
'sent serialized mocked response to the parent:',
serializedResponse
)
},
errorWith: (reason) => {
this.logger.info('request has errored!', { error: reason })
throw new Error('Not implemented')
},
})
await handleRequest({
request,
requestId: requestJson.id,
controller,
emitter: this.emitter,
})
}
this.subscriptions.push(() => {
this.process.removeListener('message', handleChildMessage)
logger.info('removed the "message" listener from the child process!')
})
logger.info('adding a "message" listener to the child process')
this.process.addListener('message', handleChildMessage)
this.process.once('error', () => this.dispose())
this.process.once('exit', () => this.dispose())
}
}

View File

@@ -0,0 +1,104 @@
import { vi, it, expect } from 'vitest'
import {
RequestController,
type RequestControllerSource,
} from './RequestController'
import { InterceptorError } from './InterceptorError'
const defaultSource = {
passthrough() {},
respondWith() {},
errorWith() {},
} satisfies RequestControllerSource
it('has a pending state upon construction', () => {
const controller = new RequestController(
new Request('http://localhost'),
defaultSource
)
expect(controller.handled).toBeInstanceOf(Promise)
expect(controller.readyState).toBe(RequestController.PENDING)
})
it('handles a request when calling ".respondWith()" with a mocked response', async () => {
const respondWith = vi.fn<RequestControllerSource['respondWith']>()
const controller = new RequestController(new Request('http://localhost'), {
...defaultSource,
respondWith,
})
await controller.respondWith(new Response('hello world'))
expect(controller.readyState).toBe(RequestController.RESPONSE)
await expect(controller.handled).resolves.toBeUndefined()
expect(respondWith).toHaveBeenCalledOnce()
const [response] = respondWith.mock.calls[0]
expect(response).toBeInstanceOf(Response)
expect(response.status).toBe(200)
await expect(response.text()).resolves.toBe('hello world')
})
it('handles the request when calling ".errorWith()" with an error', async () => {
const errorWith = vi.fn<RequestControllerSource['errorWith']>()
const controller = new RequestController(new Request('http://localhost'), {
...defaultSource,
errorWith,
})
const error = new Error('Oops!')
await controller.errorWith(error)
expect(controller.readyState).toBe(RequestController.ERROR)
await expect(controller.handled).resolves.toBeUndefined()
expect(errorWith).toHaveBeenCalledOnce()
expect(errorWith).toHaveBeenCalledWith(error)
})
it('handles the request when calling ".errorWith()" with an arbitrary object', async () => {
const errorWith = vi.fn<RequestControllerSource['errorWith']>()
const controller = new RequestController(new Request('http://localhost'), {
...defaultSource,
errorWith,
})
const error = { message: 'Oops!' }
await controller.errorWith(error)
expect(controller.readyState).toBe(RequestController.ERROR)
await expect(controller.handled).resolves.toBeUndefined()
expect(errorWith).toHaveBeenCalledOnce()
expect(errorWith).toHaveBeenCalledWith(error)
})
it('throws when calling "respondWith" multiple times', async () => {
const controller = new RequestController(
new Request('http://localhost'),
defaultSource
)
controller.respondWith(new Response('hello world'))
expect(() => controller.respondWith(new Response('second response'))).toThrow(
new InterceptorError(
'Failed to respond to the "GET http://localhost/" request with "200 OK": the request has already been handled (2)'
)
)
})
it('throws when calling "errorWith" multiple times', async () => {
const controller = new RequestController(
new Request('http://localhost'),
defaultSource
)
controller.errorWith(new Error('Oops!'))
expect(() => controller.errorWith(new Error('second error'))).toThrow(
new InterceptorError(
'Failed to error the "GET http://localhost/" request with "Error: second error": the request has already been handled (3)'
)
)
})

View File

@@ -0,0 +1,109 @@
import { DeferredPromise } from '@open-draft/deferred-promise'
import { invariant } from 'outvariant'
import { InterceptorError } from './InterceptorError'
export interface RequestControllerSource {
passthrough(): void
respondWith(response: Response): void
errorWith(reason?: unknown): void
}
export class RequestController {
static PENDING = 0 as const
static PASSTHROUGH = 1 as const
static RESPONSE = 2 as const
static ERROR = 3 as const
public readyState: number
/**
* A Promise that resolves when this controller handles a request.
* See `controller.readyState` for more information on the handling result.
*/
public handled: Promise<void>
constructor(
protected readonly request: Request,
protected readonly source: RequestControllerSource
) {
this.readyState = RequestController.PENDING
this.handled = new DeferredPromise<void>()
}
get #handled() {
return this.handled as DeferredPromise<void>
}
/**
* Perform this request as-is.
*/
public async passthrough(): Promise<void> {
invariant.as(
InterceptorError,
this.readyState === RequestController.PENDING,
'Failed to passthrough the "%s %s" request: the request has already been handled',
this.request.method,
this.request.url
)
this.readyState = RequestController.PASSTHROUGH
await this.source.passthrough()
this.#handled.resolve()
}
/**
* Respond to this request with the given `Response` instance.
*
* @example
* controller.respondWith(new Response())
* controller.respondWith(Response.json({ id }))
* controller.respondWith(Response.error())
*/
public respondWith(response: Response): void {
invariant.as(
InterceptorError,
this.readyState === RequestController.PENDING,
'Failed to respond to the "%s %s" request with "%d %s": the request has already been handled (%d)',
this.request.method,
this.request.url,
response.status,
response.statusText || 'OK',
this.readyState
)
this.readyState = RequestController.RESPONSE
this.#handled.resolve()
/**
* @note Although `source.respondWith()` is potentially asynchronous,
* do NOT await it for backward-compatibility. Awaiting it will short-circuit
* the request listener invocation as soon as a listener responds to a request.
* Ideally, that's what we want, but that's not what we promise the user.
*/
this.source.respondWith(response)
}
/**
* Error this request with the given reason.
*
* @example
* controller.errorWith()
* controller.errorWith(new Error('Oops!'))
* controller.errorWith({ message: 'Oops!'})
*/
public errorWith(reason?: unknown): void {
invariant.as(
InterceptorError,
this.readyState === RequestController.PENDING,
'Failed to error the "%s %s" request with "%s": the request has already been handled (%d)',
this.request.method,
this.request.url,
reason?.toString(),
this.readyState
)
this.readyState = RequestController.ERROR
this.source.errorWith(reason)
this.#handled.resolve()
}
}

View File

@@ -0,0 +1,7 @@
import { it, expect } from 'vitest'
import { createRequestId } from './createRequestId'
import { REQUEST_ID_REGEXP } from '../test/helpers'
it('returns a request ID', () => {
expect(createRequestId()).toMatch(REQUEST_ID_REGEXP)
})

View File

@@ -0,0 +1,9 @@
/**
* Generate a random ID string to represent a request.
* @example
* createRequestId()
* // "f774b6c9c600f"
*/
export function createRequestId(): string {
return Math.random().toString(16).slice(2)
}

21
node_modules/@mswjs/interceptors/src/getRawRequest.ts generated vendored Normal file
View File

@@ -0,0 +1,21 @@
const kRawRequest = Symbol('kRawRequest')
/**
* Returns a raw request instance associated with this request.
*
* @example
* interceptor.on('request', ({ request }) => {
* const rawRequest = getRawRequest(request)
*
* if (rawRequest instanceof http.ClientRequest) {
* console.log(rawRequest.rawHeaders)
* }
* })
*/
export function getRawRequest(request: Request): unknown | undefined {
return Reflect.get(request, kRawRequest)
}
export function setRawRequest(request: Request, rawRequest: unknown): void {
Reflect.set(request, kRawRequest, rawRequest)
}

37
node_modules/@mswjs/interceptors/src/glossary.ts generated vendored Normal file
View File

@@ -0,0 +1,37 @@
import type { RequestController } from './RequestController'
export const IS_PATCHED_MODULE: unique symbol = Symbol('isPatchedModule')
/**
* @note Export `RequestController` as a type only.
* It's never meant to be created in the userland.
*/
export type { RequestController }
export type RequestCredentials = 'omit' | 'include' | 'same-origin'
export type HttpRequestEventMap = {
request: [
args: {
request: Request
requestId: string
controller: RequestController
}
]
response: [
args: {
response: Response
isMockedResponse: boolean
request: Request
requestId: string
}
]
unhandledException: [
args: {
error: unknown
request: Request
requestId: string
controller: RequestController
}
]
}

15
node_modules/@mswjs/interceptors/src/index.ts generated vendored Normal file
View File

@@ -0,0 +1,15 @@
export * from './glossary'
export * from './Interceptor'
export * from './BatchInterceptor'
export {
RequestController,
type RequestControllerSource,
} from './RequestController'
/* Utils */
export { createRequestId } from './createRequestId'
export { getCleanUrl } from './utils/getCleanUrl'
export { encodeBuffer, decodeBuffer } from './utils/bufferUtils'
export { FetchResponse } from './utils/fetchUtils'
export { getRawRequest } from './getRawRequest'
export { resolveWebSocketUrl } from './utils/resolveWebSocketUrl'

View File

@@ -0,0 +1,724 @@
import net from 'node:net'
import {
type HeadersCallback,
HTTPParser,
type RequestHeadersCompleteCallback,
type ResponseHeadersCompleteCallback,
} from '_http_common'
import { STATUS_CODES, IncomingMessage, ServerResponse } from 'node:http'
import { Readable } from 'node:stream'
import { invariant } from 'outvariant'
import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor'
import { MockSocket } from '../Socket/MockSocket'
import type { NormalizedSocketWriteArgs } from '../Socket/utils/normalizeSocketWriteArgs'
import { isPropertyAccessible } from '../../utils/isPropertyAccessible'
import { baseUrlFromConnectionOptions } from '../Socket/utils/baseUrlFromConnectionOptions'
import { createRequestId } from '../../createRequestId'
import { getRawFetchHeaders } from './utils/recordRawHeaders'
import { FetchResponse } from '../../utils/fetchUtils'
import { setRawRequest } from '../../getRawRequest'
import { setRawRequestBodyStream } from '../../utils/node'
import { freeParser } from './utils/parserUtils'
type HttpConnectionOptions = any
export type MockHttpSocketRequestCallback = (args: {
requestId: string
request: Request
socket: MockHttpSocket
}) => void
export type MockHttpSocketResponseCallback = (args: {
requestId: string
request: Request
response: Response
isMockedResponse: boolean
socket: MockHttpSocket
}) => Promise<void>
interface MockHttpSocketOptions {
connectionOptions: HttpConnectionOptions
createConnection: () => net.Socket
onRequest: MockHttpSocketRequestCallback
onResponse: MockHttpSocketResponseCallback
}
export const kRequestId = Symbol('kRequestId')
export class MockHttpSocket extends MockSocket {
private connectionOptions: HttpConnectionOptions
private createConnection: () => net.Socket
private baseUrl: URL
private onRequest: MockHttpSocketRequestCallback
private onResponse: MockHttpSocketResponseCallback
private responseListenersPromise?: Promise<void>
private requestRawHeadersBuffer: Array<string> = []
private responseRawHeadersBuffer: Array<string> = []
private writeBuffer: Array<NormalizedSocketWriteArgs> = []
private request?: Request
private requestParser: HTTPParser<0>
private requestStream?: Readable
private shouldKeepAlive?: boolean
private socketState: 'unknown' | 'mock' | 'passthrough' = 'unknown'
private responseParser: HTTPParser<1>
private responseStream?: Readable
private originalSocket?: net.Socket
constructor(options: MockHttpSocketOptions) {
super({
write: (chunk, encoding, callback) => {
// Buffer the writes so they can be flushed in case of the original connection
// and when reading the request body in the interceptor. If the connection has
// been established, no need to buffer the chunks anymore, they will be forwarded.
if (this.socketState !== 'passthrough') {
this.writeBuffer.push([chunk, encoding, callback])
}
if (chunk) {
/**
* Forward any writes to the mock socket to the underlying original socket.
* This ensures functional duplex connections, like WebSocket.
* @see https://github.com/mswjs/interceptors/issues/682
*/
if (this.socketState === 'passthrough') {
this.originalSocket?.write(chunk, encoding, callback)
}
this.requestParser.execute(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding)
)
}
},
read: (chunk) => {
if (chunk !== null) {
/**
* @todo We need to free the parser if the connection has been
* upgraded to a non-HTTP protocol. It won't be able to parse data
* from that point onward anyway. No need to keep it in memory.
*/
this.responseParser.execute(
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
)
}
},
})
this.connectionOptions = options.connectionOptions
this.createConnection = options.createConnection
this.onRequest = options.onRequest
this.onResponse = options.onResponse
this.baseUrl = baseUrlFromConnectionOptions(this.connectionOptions)
// Request parser.
this.requestParser = new HTTPParser()
this.requestParser.initialize(HTTPParser.REQUEST, {})
this.requestParser[HTTPParser.kOnHeaders] = this.onRequestHeaders.bind(this)
this.requestParser[HTTPParser.kOnHeadersComplete] =
this.onRequestStart.bind(this)
this.requestParser[HTTPParser.kOnBody] = this.onRequestBody.bind(this)
this.requestParser[HTTPParser.kOnMessageComplete] =
this.onRequestEnd.bind(this)
// Response parser.
this.responseParser = new HTTPParser()
this.responseParser.initialize(HTTPParser.RESPONSE, {})
this.responseParser[HTTPParser.kOnHeaders] =
this.onResponseHeaders.bind(this)
this.responseParser[HTTPParser.kOnHeadersComplete] =
this.onResponseStart.bind(this)
this.responseParser[HTTPParser.kOnBody] = this.onResponseBody.bind(this)
this.responseParser[HTTPParser.kOnMessageComplete] =
this.onResponseEnd.bind(this)
// Once the socket is finished, nothing can write to it
// anymore. It has also flushed any buffered chunks.
this.once('finish', () => freeParser(this.requestParser, this))
if (this.baseUrl.protocol === 'https:') {
Reflect.set(this, 'encrypted', true)
// The server certificate is not the same as a CA
// passed to the TLS socket connection options.
Reflect.set(this, 'authorized', false)
Reflect.set(this, 'getProtocol', () => 'TLSv1.3')
Reflect.set(this, 'getSession', () => undefined)
Reflect.set(this, 'isSessionReused', () => false)
Reflect.set(this, 'getCipher', () => ({
name: 'AES256-SHA',
standardName: 'TLS_RSA_WITH_AES_256_CBC_SHA',
version: 'TLSv1.3',
}))
}
}
public emit(event: string | symbol, ...args: any[]): boolean {
const emitEvent = super.emit.bind(this, event as any, ...args)
if (this.responseListenersPromise) {
this.responseListenersPromise.finally(emitEvent)
return this.listenerCount(event) > 0
}
return emitEvent()
}
public destroy(error?: Error | undefined): this {
// Destroy the response parser when the socket gets destroyed.
// Normally, we should listen to the "close" event but it
// can be suppressed by using the "emitClose: false" option.
freeParser(this.responseParser, this)
if (error) {
this.emit('error', error)
}
return super.destroy(error)
}
/**
* Establish this Socket connection as-is and pipe
* its data/events through this Socket.
*/
public passthrough(): void {
this.socketState = 'passthrough'
if (this.destroyed) {
return
}
const socket = this.createConnection()
this.originalSocket = socket
/**
* @note Inherit the original socket's connection handle.
* Without this, each push to the mock socket results in a
* new "connection" listener being added (i.e. buffering pushes).
* @see https://github.com/nodejs/node/blob/b18153598b25485ce4f54d0c5cb830a9457691ee/lib/net.js#L734
*/
if ('_handle' in socket) {
Object.defineProperty(this, '_handle', {
value: socket._handle,
enumerable: true,
writable: true,
})
}
// The client-facing socket can be destroyed in two ways:
// 1. The developer destroys the socket.
// 2. The passthrough socket "close" is forwarded to the socket.
this.once('close', () => {
socket.removeAllListeners()
// If the closure didn't originate from the passthrough socket, destroy it.
if (!socket.destroyed) {
socket.destroy()
}
this.originalSocket = undefined
})
this.address = socket.address.bind(socket)
// Flush the buffered "socket.write()" calls onto
// the original socket instance (i.e. write request body).
// Exhaust the "requestBuffer" in case this Socket
// gets reused for different requests.
let writeArgs: NormalizedSocketWriteArgs | undefined
let headersWritten = false
while ((writeArgs = this.writeBuffer.shift())) {
if (writeArgs !== undefined) {
if (!headersWritten) {
const [chunk, encoding, callback] = writeArgs
const chunkString = chunk.toString()
const chunkBeforeRequestHeaders = chunkString.slice(
0,
chunkString.indexOf('\r\n') + 2
)
const chunkAfterRequestHeaders = chunkString.slice(
chunk.indexOf('\r\n\r\n')
)
const rawRequestHeaders = getRawFetchHeaders(this.request!.headers)
const requestHeadersString = rawRequestHeaders
// Skip the internal request ID deduplication header.
.filter(([name]) => {
return name.toLowerCase() !== INTERNAL_REQUEST_ID_HEADER_NAME
})
.map(([name, value]) => `${name}: ${value}`)
.join('\r\n')
// Modify the HTTP request message headers
// to reflect any changes to the request headers
// from the "request" event listener.
const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}`
socket.write(headersChunk, encoding, callback)
headersWritten = true
continue
}
socket.write(...writeArgs)
}
}
// Forward TLS Socket properties onto this Socket instance
// in the case of a TLS/SSL connection.
if (Reflect.get(socket, 'encrypted')) {
const tlsProperties = [
'encrypted',
'authorized',
'getProtocol',
'getSession',
'isSessionReused',
'getCipher',
]
tlsProperties.forEach((propertyName) => {
Object.defineProperty(this, propertyName, {
enumerable: true,
get: () => {
const value = Reflect.get(socket, propertyName)
return typeof value === 'function' ? value.bind(socket) : value
},
})
})
}
socket
.on('lookup', (...args) => this.emit('lookup', ...args))
.on('connect', () => {
this.connecting = socket.connecting
this.emit('connect')
})
.on('secureConnect', () => this.emit('secureConnect'))
.on('secure', () => this.emit('secure'))
.on('session', (session) => this.emit('session', session))
.on('ready', () => this.emit('ready'))
.on('drain', () => this.emit('drain'))
.on('data', (chunk) => {
// Push the original response to this socket
// so it triggers the HTTP response parser. This unifies
// the handling pipeline for original and mocked response.
this.push(chunk)
})
.on('error', (error) => {
Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError'))
this.emit('error', error)
})
.on('resume', () => this.emit('resume'))
.on('timeout', () => this.emit('timeout'))
.on('prefinish', () => this.emit('prefinish'))
.on('finish', () => this.emit('finish'))
.on('close', (hadError) => this.emit('close', hadError))
.on('end', () => this.emit('end'))
}
/**
* Convert the given Fetch API `Response` instance to an
* HTTP message and push it to the socket.
*/
public async respondWith(response: Response): Promise<void> {
// Ignore the mocked response if the socket has been destroyed
// (e.g. aborted or timed out),
if (this.destroyed) {
return
}
// Prevent recursive calls.
invariant(
this.socketState !== 'mock',
'[MockHttpSocket] Failed to respond to the "%s %s" request with "%s %s": the request has already been handled',
this.request?.method,
this.request?.url,
response.status,
response.statusText
)
// Handle "type: error" responses.
if (isPropertyAccessible(response, 'type') && response.type === 'error') {
this.errorWith(new TypeError('Network error'))
return
}
// First, emit all the connection events
// to emulate a successful connection.
this.mockConnect()
this.socketState = 'mock'
// Flush the write buffer to trigger write callbacks
// if it hasn't been flushed already (e.g. someone started reading request stream).
this.flushWriteBuffer()
// Create a `ServerResponse` instance to delegate HTTP message parsing,
// Transfer-Encoding, and other things to Node.js internals.
const serverResponse = new ServerResponse(new IncomingMessage(this))
/**
* Assign a mock socket instance to the server response to
* spy on the response chunk writes. Push the transformed response chunks
* to this `MockHttpSocket` instance to trigger the "data" event.
* @note Providing the same `MockSocket` instance when creating `ServerResponse`
* does not have the same effect.
* @see https://github.com/nodejs/node/blob/10099bb3f7fd97bb9dd9667188426866b3098e07/test/parallel/test-http-server-response-standalone.js#L32
*/
serverResponse.assignSocket(
new MockSocket({
write: (chunk, encoding, callback) => {
this.push(chunk, encoding)
callback?.()
},
read() {},
})
)
/**
* @note Remove the `Connection` and `Date` response headers
* injected by `ServerResponse` by default. Those are required
* from the server but the interceptor is NOT technically a server.
* It's confusing to add response headers that the developer didn't
* specify themselves. They can always add these if they wish.
* @see https://www.rfc-editor.org/rfc/rfc9110#field.date
* @see https://www.rfc-editor.org/rfc/rfc9110#field.connection
*/
serverResponse.removeHeader('connection')
serverResponse.removeHeader('date')
const rawResponseHeaders = getRawFetchHeaders(response.headers)
/**
* @note Call `.writeHead` in order to set the raw response headers
* in the same case as they were provided by the developer. Using
* `.setHeader()`/`.appendHeader()` normalizes header names.
*/
serverResponse.writeHead(
response.status,
response.statusText || STATUS_CODES[response.status],
rawResponseHeaders
)
// If the developer destroy the socket, gracefully destroy the response.
this.once('error', () => {
serverResponse.destroy()
})
if (response.body) {
try {
const reader = response.body.getReader()
while (true) {
const { done, value } = await reader.read()
if (done) {
serverResponse.end()
break
}
serverResponse.write(value)
}
} catch (error) {
if (error instanceof Error) {
serverResponse.destroy()
/**
* @note Destroy the request socket gracefully.
* Response stream errors do NOT produce request errors.
*/
this.destroy()
return
}
serverResponse.destroy()
throw error
}
} else {
serverResponse.end()
}
// Close the socket if the connection wasn't marked as keep-alive.
if (!this.shouldKeepAlive) {
this.emit('readable')
/**
* @todo @fixme This is likely a hack.
* Since we push null to the socket, it never propagates to the
* parser, and the parser never calls "onResponseEnd" to close
* the response stream. We are closing the stream here manually
* but that shouldn't be the case.
*/
this.responseStream?.push(null)
this.push(null)
}
}
/**
* Close this socket connection with the given error.
*/
public errorWith(error?: Error): void {
this.destroy(error)
}
private mockConnect(): void {
// Calling this method immediately puts the socket
// into the connected state.
this.connecting = false
const isIPv6 =
net.isIPv6(this.connectionOptions.hostname) ||
this.connectionOptions.family === 6
const addressInfo = {
address: isIPv6 ? '::1' : '127.0.0.1',
family: isIPv6 ? 'IPv6' : 'IPv4',
port: this.connectionOptions.port,
}
// Return fake address information for the socket.
this.address = () => addressInfo
this.emit(
'lookup',
null,
addressInfo.address,
addressInfo.family === 'IPv6' ? 6 : 4,
this.connectionOptions.host
)
this.emit('connect')
this.emit('ready')
if (this.baseUrl.protocol === 'https:') {
this.emit('secure')
this.emit('secureConnect')
// A single TLS connection is represented by two "session" events.
this.emit(
'session',
this.connectionOptions.session ||
Buffer.from('mock-session-renegotiate')
)
this.emit('session', Buffer.from('mock-session-resume'))
}
}
private flushWriteBuffer(): void {
for (const writeCall of this.writeBuffer) {
if (typeof writeCall[2] === 'function') {
writeCall[2]()
/**
* @note Remove the callback from the write call
* so it doesn't get called twice on passthrough
* if `request.end()` was called within `request.write()`.
* @see https://github.com/mswjs/interceptors/issues/684
*/
writeCall[2] = undefined
}
}
}
/**
* This callback might be called when the request is "slow":
* - Request headers were fragmented across multiple TCP packages;
* - Request headers were too large to be processed in a single run
* (e.g. more than 30 request headers).
* @note This is called before request start.
*/
private onRequestHeaders: HeadersCallback = (rawHeaders) => {
this.requestRawHeadersBuffer.push(...rawHeaders)
}
private onRequestStart: RequestHeadersCompleteCallback = (
versionMajor,
versionMinor,
rawHeaders,
_,
path,
__,
___,
____,
shouldKeepAlive
) => {
this.shouldKeepAlive = shouldKeepAlive
const url = new URL(path || '', this.baseUrl)
const method = this.connectionOptions.method?.toUpperCase() || 'GET'
const headers = FetchResponse.parseRawHeaders([
...this.requestRawHeadersBuffer,
...(rawHeaders || []),
])
this.requestRawHeadersBuffer.length = 0
const canHaveBody = method !== 'GET' && method !== 'HEAD'
// Translate the basic authorization in the URL to the request header.
// Constructing a Request instance with a URL containing auth is no-op.
if (url.username || url.password) {
if (!headers.has('authorization')) {
headers.set('authorization', `Basic ${url.username}:${url.password}`)
}
url.username = ''
url.password = ''
}
// Create a new stream for each request.
// If this Socket is reused for multiple requests,
// this ensures that each request gets its own stream.
// One Socket instance can only handle one request at a time.
this.requestStream = new Readable({
/**
* @note Provide the `read()` method so a `Readable` could be
* used as the actual request body (the stream calls "read()").
* We control the queue in the onRequestBody/End functions.
*/
read: () => {
// If the user attempts to read the request body,
// flush the write buffer to trigger the callbacks.
// This way, if the request stream ends in the write callback,
// it will indeed end correctly.
this.flushWriteBuffer()
},
})
const requestId = createRequestId()
this.request = new Request(url, {
method,
headers,
credentials: 'same-origin',
// @ts-expect-error Undocumented Fetch property.
duplex: canHaveBody ? 'half' : undefined,
body: canHaveBody ? (Readable.toWeb(this.requestStream!) as any) : null,
})
Reflect.set(this.request, kRequestId, requestId)
// Set the raw `http.ClientRequest` instance on the request instance.
// This is useful for cases like getting the raw headers of the request.
setRawRequest(this.request, Reflect.get(this, '_httpMessage'))
// Create a copy of the request body stream and store it on the request.
// This is only needed for the consumers who wish to read the request body stream
// of requests that cannot have a body per Fetch API specification (i.e. GET, HEAD).
setRawRequestBodyStream(this.request, this.requestStream)
// Skip handling the request that's already being handled
// by another (parent) interceptor. For example, XMLHttpRequest
// is often implemented via ClientRequest in Node.js (e.g. JSDOM).
// In that case, XHR interceptor will bubble down to the ClientRequest
// interceptor. No need to try to handle that request again.
/**
* @fixme Stop relying on the "X-Request-Id" request header
* to figure out if one interceptor has been invoked within another.
* @see https://github.com/mswjs/interceptors/issues/378
*/
if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) {
this.passthrough()
return
}
this.onRequest({
requestId,
request: this.request,
socket: this,
})
}
private onRequestBody(chunk: Buffer): void {
invariant(
this.requestStream,
'Failed to write to a request stream: stream does not exist'
)
this.requestStream.push(chunk)
}
private onRequestEnd(): void {
// Request end can be called for requests without body.
if (this.requestStream) {
this.requestStream.push(null)
}
}
/**
* This callback might be called when the response is "slow":
* - Response headers were fragmented across multiple TCP packages;
* - Response headers were too large to be processed in a single run
* (e.g. more than 30 response headers).
* @note This is called before response start.
*/
private onResponseHeaders: HeadersCallback = (rawHeaders) => {
this.responseRawHeadersBuffer.push(...rawHeaders)
}
private onResponseStart: ResponseHeadersCompleteCallback = (
versionMajor,
versionMinor,
rawHeaders,
method,
url,
status,
statusText
) => {
const headers = FetchResponse.parseRawHeaders([
...this.responseRawHeadersBuffer,
...(rawHeaders || []),
])
this.responseRawHeadersBuffer.length = 0
const response = new FetchResponse(
/**
* @note The Fetch API response instance exposed to the consumer
* is created over the response stream of the HTTP parser. It is NOT
* related to the Socket instance. This way, you can read response body
* in response listener while the Socket instance delays the emission
* of "end" and other events until those response listeners are finished.
*/
FetchResponse.isResponseWithBody(status)
? (Readable.toWeb(
(this.responseStream = new Readable({ read() {} }))
) as any)
: null,
{
url,
status,
statusText,
headers,
}
)
invariant(
this.request,
'Failed to handle a response: request does not exist'
)
FetchResponse.setUrl(this.request.url, response)
/**
* @fixme Stop relying on the "X-Request-Id" request header
* to figure out if one interceptor has been invoked within another.
* @see https://github.com/mswjs/interceptors/issues/378
*/
if (this.request.headers.has(INTERNAL_REQUEST_ID_HEADER_NAME)) {
return
}
this.responseListenersPromise = this.onResponse({
response,
isMockedResponse: this.socketState === 'mock',
requestId: Reflect.get(this.request, kRequestId),
request: this.request,
socket: this,
})
}
private onResponseBody(chunk: Buffer) {
invariant(
this.responseStream,
'Failed to write to a response stream: stream does not exist'
)
this.responseStream.push(chunk)
}
private onResponseEnd(): void {
// Response end can be called for responses without body.
if (this.responseStream) {
this.responseStream.push(null)
}
}
}

View File

@@ -0,0 +1,110 @@
/**
* Here's how requests are handled in Node.js:
*
* 1. http.ClientRequest instance calls `agent.addRequest(request, options, cb)`.
* 2. Agent creates a new socket: `agent.createSocket(options, cb)`.
* 3. Agent creates a new connection: `agent.createConnection(options, cb)`.
*/
import net from 'node:net'
import http from 'node:http'
import https from 'node:https'
import {
MockHttpSocket,
type MockHttpSocketRequestCallback,
type MockHttpSocketResponseCallback,
} from './MockHttpSocket'
declare module 'node:http' {
interface Agent {
options?: http.AgentOptions
createConnection(options: any, callback: any): net.Socket
}
}
interface MockAgentOptions {
customAgent?: http.RequestOptions['agent']
onRequest: MockHttpSocketRequestCallback
onResponse: MockHttpSocketResponseCallback
}
export class MockAgent extends http.Agent {
private customAgent?: http.RequestOptions['agent']
private onRequest: MockHttpSocketRequestCallback
private onResponse: MockHttpSocketResponseCallback
constructor(options: MockAgentOptions) {
super()
this.customAgent = options.customAgent
this.onRequest = options.onRequest
this.onResponse = options.onResponse
}
public createConnection(options: any, callback: any): net.Socket {
const createConnection =
this.customAgent instanceof http.Agent
? this.customAgent.createConnection
: super.createConnection
const createConnectionOptions =
this.customAgent instanceof http.Agent
? {
...options,
...this.customAgent.options,
}
: options
const socket = new MockHttpSocket({
connectionOptions: options,
createConnection: createConnection.bind(
this.customAgent || this,
createConnectionOptions,
callback
),
onRequest: this.onRequest.bind(this),
onResponse: this.onResponse.bind(this),
})
return socket
}
}
export class MockHttpsAgent extends https.Agent {
private customAgent?: https.RequestOptions['agent']
private onRequest: MockHttpSocketRequestCallback
private onResponse: MockHttpSocketResponseCallback
constructor(options: MockAgentOptions) {
super()
this.customAgent = options.customAgent
this.onRequest = options.onRequest
this.onResponse = options.onResponse
}
public createConnection(options: any, callback: any): net.Socket {
const createConnection =
this.customAgent instanceof http.Agent
? this.customAgent.createConnection
: super.createConnection
const createConnectionOptions =
this.customAgent instanceof http.Agent
? {
...options,
...this.customAgent.options,
}
: options
const socket = new MockHttpSocket({
connectionOptions: options,
createConnection: createConnection.bind(
this.customAgent || this,
createConnectionOptions,
callback
),
onRequest: this.onRequest.bind(this),
onResponse: this.onResponse.bind(this),
})
return socket
}
}

View File

@@ -0,0 +1,75 @@
import { it, expect, beforeAll, afterEach, afterAll } from 'vitest'
import http from 'node:http'
import { HttpServer } from '@open-draft/test-server/http'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { ClientRequestInterceptor } from '.'
import { sleep, waitForClientRequest } from '../../../test/helpers'
const httpServer = new HttpServer((app) => {
app.get('/', (_req, res) => {
res.status(200).send('/')
})
app.get('/get', (_req, res) => {
res.status(200).send('/get')
})
})
const interceptor = new ClientRequestInterceptor()
beforeAll(async () => {
interceptor.apply()
await httpServer.listen()
})
afterEach(() => {
interceptor.removeAllListeners()
})
afterAll(async () => {
interceptor.dispose()
await httpServer.close()
})
it('abort the request if the abort signal is emitted', async () => {
const requestUrl = httpServer.http.url('/')
interceptor.on('request', async function delayedResponse({ controller }) {
await sleep(1_000)
controller.respondWith(new Response())
})
const abortController = new AbortController()
const request = http.get(requestUrl, { signal: abortController.signal })
abortController.abort()
const abortErrorPromise = new DeferredPromise<Error>()
request.on('error', function (error) {
abortErrorPromise.resolve(error)
})
const abortError = await abortErrorPromise
expect(abortError.name).toEqual('AbortError')
expect(request.destroyed).toBe(true)
})
it('patch the Headers object correctly after dispose and reapply', async () => {
interceptor.dispose()
interceptor.apply()
interceptor.on('request', ({ controller }) => {
const headers = new Headers({
'X-CustoM-HeadeR': 'Yes',
})
controller.respondWith(new Response(null, { headers }))
})
const request = http.get(httpServer.http.url('/'))
const { res } = await waitForClientRequest(request)
expect(res.rawHeaders).toEqual(
expect.arrayContaining(['X-CustoM-HeadeR', 'Yes'])
)
expect(res.headers['x-custom-header']).toEqual('Yes')
})

View File

@@ -0,0 +1,193 @@
import http from 'node:http'
import https from 'node:https'
import { Interceptor } from '../../Interceptor'
import type { HttpRequestEventMap } from '../../glossary'
import {
kRequestId,
MockHttpSocketRequestCallback,
MockHttpSocketResponseCallback,
} from './MockHttpSocket'
import { MockAgent, MockHttpsAgent } from './agents'
import { RequestController } from '../../RequestController'
import { emitAsync } from '../../utils/emitAsync'
import { normalizeClientRequestArgs } from './utils/normalizeClientRequestArgs'
import { handleRequest } from '../../utils/handleRequest'
import {
recordRawFetchHeaders,
restoreHeadersPrototype,
} from './utils/recordRawHeaders'
export class ClientRequestInterceptor extends Interceptor<HttpRequestEventMap> {
static symbol = Symbol('client-request-interceptor')
constructor() {
super(ClientRequestInterceptor.symbol)
}
protected setup(): void {
const {
ClientRequest: OriginalClientRequest,
get: originalGet,
request: originalRequest,
} = http
const { get: originalHttpsGet, request: originalHttpsRequest } = https
const onRequest = this.onRequest.bind(this)
const onResponse = this.onResponse.bind(this)
// Support requests performed via the `ClientRequest` constructor directly.
http.ClientRequest = new Proxy(http.ClientRequest, {
construct: (target, args: Parameters<typeof http.request>) => {
const [url, options, callback] = normalizeClientRequestArgs(
'http:',
args
)
// Create a mock agent instance appropriate for the request protocol.
const Agent = options.protocol === 'https:' ? MockHttpsAgent : MockAgent
const mockAgent = new Agent({
customAgent: options.agent,
onRequest,
onResponse,
})
options.agent = mockAgent
return Reflect.construct(target, [url, options, callback])
},
})
http.request = new Proxy(http.request, {
apply: (target, thisArg, args: Parameters<typeof http.request>) => {
const [url, options, callback] = normalizeClientRequestArgs(
'http:',
args
)
const mockAgent = new MockAgent({
customAgent: options.agent,
onRequest,
onResponse,
})
options.agent = mockAgent
return Reflect.apply(target, thisArg, [url, options, callback])
},
})
http.get = new Proxy(http.get, {
apply: (target, thisArg, args: Parameters<typeof http.get>) => {
const [url, options, callback] = normalizeClientRequestArgs(
'http:',
args
)
const mockAgent = new MockAgent({
customAgent: options.agent,
onRequest,
onResponse,
})
options.agent = mockAgent
return Reflect.apply(target, thisArg, [url, options, callback])
},
})
//
// HTTPS.
//
https.request = new Proxy(https.request, {
apply: (target, thisArg, args: Parameters<typeof https.request>) => {
const [url, options, callback] = normalizeClientRequestArgs(
'https:',
args
)
const mockAgent = new MockHttpsAgent({
customAgent: options.agent,
onRequest,
onResponse,
})
options.agent = mockAgent
return Reflect.apply(target, thisArg, [url, options, callback])
},
})
https.get = new Proxy(https.get, {
apply: (target, thisArg, args: Parameters<typeof https.get>) => {
const [url, options, callback] = normalizeClientRequestArgs(
'https:',
args
)
const mockAgent = new MockHttpsAgent({
customAgent: options.agent,
onRequest,
onResponse,
})
options.agent = mockAgent
return Reflect.apply(target, thisArg, [url, options, callback])
},
})
// Spy on `Header.prototype.set` and `Header.prototype.append` calls
// and record the raw header names provided. This is to support
// `IncomingMessage.prototype.rawHeaders`.
recordRawFetchHeaders()
this.subscriptions.push(() => {
http.ClientRequest = OriginalClientRequest
http.get = originalGet
http.request = originalRequest
https.get = originalHttpsGet
https.request = originalHttpsRequest
restoreHeadersPrototype()
})
}
private onRequest: MockHttpSocketRequestCallback = async ({
request,
socket,
}) => {
const controller = new RequestController(request, {
passthrough() {
socket.passthrough()
},
async respondWith(response) {
await socket.respondWith(response)
},
errorWith(reason) {
if (reason instanceof Error) {
socket.errorWith(reason)
}
},
})
await handleRequest({
request,
requestId: Reflect.get(request, kRequestId),
controller,
emitter: this.emitter,
})
}
public onResponse: MockHttpSocketResponseCallback = async ({
requestId,
request,
response,
isMockedResponse,
}) => {
// Return the promise to when all the response event listeners
// are finished.
return emitAsync(this.emitter, 'response', {
requestId,
request,
response,
isMockedResponse,
})
}
}

View File

@@ -0,0 +1,54 @@
import { it, expect } from 'vitest'
import { IncomingMessage } from 'http'
import { Socket } from 'net'
import * as zlib from 'zlib'
import { getIncomingMessageBody } from './getIncomingMessageBody'
it('returns utf8 string given a utf8 response body', async () => {
const utfBuffer = Buffer.from('one')
const message = new IncomingMessage(new Socket())
const pendingResponseBody = getIncomingMessageBody(message)
message.emit('data', utfBuffer)
message.emit('end')
expect(await pendingResponseBody).toEqual('one')
})
it('returns utf8 string given a gzipped response body', async () => {
const utfBuffer = zlib.gzipSync(Buffer.from('two'))
const message = new IncomingMessage(new Socket())
message.headers = {
'content-encoding': 'gzip',
}
const pendingResponseBody = getIncomingMessageBody(message)
message.emit('data', utfBuffer)
message.emit('end')
expect(await pendingResponseBody).toEqual('two')
})
it('returns utf8 string given a gzipped response body with incorrect "content-length"', async () => {
const utfBuffer = zlib.gzipSync(Buffer.from('three'))
const message = new IncomingMessage(new Socket())
message.headers = {
'content-encoding': 'gzip',
'content-length': '500',
}
const pendingResponseBody = getIncomingMessageBody(message)
message.emit('data', utfBuffer)
message.emit('end')
expect(await pendingResponseBody).toEqual('three')
})
it('returns empty string given an empty body', async () => {
const message = new IncomingMessage(new Socket())
const pendingResponseBody = getIncomingMessageBody(message)
message.emit('end')
expect(await pendingResponseBody).toEqual('')
})

View File

@@ -0,0 +1,45 @@
import { IncomingMessage } from 'http'
import { PassThrough } from 'stream'
import * as zlib from 'zlib'
import { Logger } from '@open-draft/logger'
const logger = new Logger('http getIncomingMessageBody')
export function getIncomingMessageBody(
response: IncomingMessage
): Promise<string> {
return new Promise((resolve, reject) => {
logger.info('cloning the original response...')
// Pipe the original response to support non-clone
// "response" input. No need to clone the response,
// as we always have access to the full "response" input,
// either a clone or an original one (in tests).
const responseClone = response.pipe(new PassThrough())
const stream =
response.headers['content-encoding'] === 'gzip'
? responseClone.pipe(zlib.createGunzip())
: responseClone
const encoding = response.readableEncoding || 'utf8'
stream.setEncoding(encoding)
logger.info('using encoding:', encoding)
let body = ''
stream.on('data', (responseBody) => {
logger.info('response body read:', responseBody)
body += responseBody
})
stream.once('end', () => {
logger.info('response body end')
resolve(body)
})
stream.once('error', (error) => {
logger.info('error while reading response body:', error)
reject(error)
})
})
}

View File

@@ -0,0 +1,427 @@
import { it, expect } from 'vitest'
import { parse } from 'url'
import { globalAgent as httpGlobalAgent, RequestOptions } from 'http'
import { Agent as HttpsAgent, globalAgent as httpsGlobalAgent } from 'https'
import { getUrlByRequestOptions } from '../../../utils/getUrlByRequestOptions'
import { normalizeClientRequestArgs } from './normalizeClientRequestArgs'
it('handles [string, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('https:', [
'https://mswjs.io/resource',
function cb() {},
])
// URL string must be converted to a URL instance.
expect(url.href).toEqual('https://mswjs.io/resource')
// Request options must be derived from the URL instance.
expect(options).toHaveProperty('method', 'GET')
expect(options).toHaveProperty('protocol', 'https:')
expect(options).toHaveProperty('hostname', 'mswjs.io')
expect(options).toHaveProperty('path', '/resource')
// Callback must be preserved.
expect(callback?.name).toEqual('cb')
})
it('handles [string, RequestOptions, callback] input', () => {
const initialOptions = {
headers: {
'Content-Type': 'text/plain',
},
}
const [url, options, callback] = normalizeClientRequestArgs('https:', [
'https://mswjs.io/resource',
initialOptions,
function cb() {},
])
// URL must be created from the string.
expect(url.href).toEqual('https://mswjs.io/resource')
// Request options must be preserved.
expect(options).toHaveProperty('headers', initialOptions.headers)
// Callback must be preserved.
expect(callback?.name).toEqual('cb')
})
it('handles [URL, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('https:', [
new URL('https://mswjs.io/resource'),
function cb() {},
])
// URL must be preserved.
expect(url.href).toEqual('https://mswjs.io/resource')
// Request options must be derived from the URL instance.
expect(options.method).toEqual('GET')
expect(options.protocol).toEqual('https:')
expect(options.hostname).toEqual('mswjs.io')
expect(options.path).toEqual('/resource')
// Callback must be preserved.
expect(callback?.name).toEqual('cb')
})
it('handles [Absolute Legacy URL, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('https:', [
parse('https://cherry:durian@mswjs.io:12345/resource?apple=banana'),
function cb() {},
])
// URL must be preserved.
expect(url.toJSON()).toEqual(
new URL(
'https://cherry:durian@mswjs.io:12345/resource?apple=banana'
).toJSON()
)
// Request options must be derived from the URL instance.
expect(options.method).toEqual('GET')
expect(options.protocol).toEqual('https:')
expect(options.hostname).toEqual('mswjs.io')
expect(options.path).toEqual('/resource?apple=banana')
expect(options.port).toEqual(12345)
expect(options.auth).toEqual('cherry:durian')
// Callback must be preserved.
expect(callback?.name).toEqual('cb')
})
it('handles [Relative Legacy URL, RequestOptions without path set, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('http:', [
parse('/resource?apple=banana'),
{ host: 'mswjs.io' },
function cb() {},
])
// Correct WHATWG URL generated.
expect(url.toJSON()).toEqual(
new URL('http://mswjs.io/resource?apple=banana').toJSON()
)
// No path in request options, so legacy url path is copied-in.
expect(options.protocol).toEqual('http:')
expect(options.host).toEqual('mswjs.io')
expect(options.path).toEqual('/resource?apple=banana')
// Callback must be preserved.
expect(callback?.name).toEqual('cb')
})
it('handles [Relative Legacy URL, RequestOptions with path set, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('http:', [
parse('/resource?apple=banana'),
{ host: 'mswjs.io', path: '/other?cherry=durian' },
function cb() {},
])
// Correct WHATWG URL generated.
expect(url.toJSON()).toEqual(
new URL('http://mswjs.io/other?cherry=durian').toJSON()
)
// Path in request options, so that path is preferred.
expect(options.protocol).toEqual('http:')
expect(options.host).toEqual('mswjs.io')
expect(options.path).toEqual('/other?cherry=durian')
// Callback must be preserved.
expect(callback?.name).toEqual('cb')
})
it('handles [Relative Legacy URL, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('http:', [
parse('/resource?apple=banana'),
function cb() {},
])
// Correct WHATWG URL generated.
expect(url.toJSON()).toMatch(
getUrlByRequestOptions({ path: '/resource?apple=banana' }).toJSON()
)
// Check path is in options.
expect(options.protocol).toEqual('http:')
expect(options.path).toEqual('/resource?apple=banana')
// Callback must be preserved.
expect(callback).toBeTypeOf('function')
expect(callback?.name).toEqual('cb')
})
it('handles [Relative Legacy URL] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('http:', [
parse('/resource?apple=banana'),
])
// Correct WHATWG URL generated.
expect(url.toJSON()).toMatch(
getUrlByRequestOptions({ path: '/resource?apple=banana' }).toJSON()
)
// Check path is in options.
expect(options.protocol).toEqual('http:')
expect(options.path).toEqual('/resource?apple=banana')
// Callback must be preserved.
expect(callback).toBeUndefined()
})
it('handles [URL, RequestOptions, callback] input', () => {
const [url, options, callback] = normalizeClientRequestArgs('https:', [
new URL('https://mswjs.io/resource'),
{
agent: false,
headers: {
'Content-Type': 'text/plain',
},
},
function cb() {},
])
// URL must be preserved.
expect(url.href).toEqual('https://mswjs.io/resource')
// Options must be preserved.
// `urlToHttpOptions` from `node:url` generates additional
// ClientRequest options, some of which are not legally allowed.
expect(options).toMatchObject<RequestOptions>({
agent: false,
_defaultAgent: httpsGlobalAgent,
protocol: url.protocol,
method: 'GET',
headers: {
'Content-Type': 'text/plain',
},
hostname: url.hostname,
path: url.pathname,
})
// Callback must be preserved.
expect(callback).toBeTypeOf('function')
expect(callback?.name).toEqual('cb')
})
it('handles [URL, RequestOptions] where options have custom "hostname"', () => {
const [url, options] = normalizeClientRequestArgs('http:', [
new URL('http://example.com/path-from-url'),
{
hostname: 'host-from-options.com',
},
])
expect(url.href).toBe('http://host-from-options.com/path-from-url')
expect(options).toMatchObject({
hostname: 'host-from-options.com',
path: '/path-from-url',
})
})
it('handles [URL, RequestOptions] where options contain "host" and "path" and "port"', () => {
const [url, options] = normalizeClientRequestArgs('http:', [
new URL('http://example.com/path-from-url?a=b&c=d'),
{
hostname: 'host-from-options.com',
path: '/path-from-options',
port: 1234,
},
])
// Must remove the query string since it's not specified in "options.path"
expect(url.href).toBe('http://host-from-options.com:1234/path-from-options')
expect(options).toMatchObject<RequestOptions>({
hostname: 'host-from-options.com',
path: '/path-from-options',
port: 1234,
})
})
it('handles [URL, RequestOptions] where options contain "path" with query string', () => {
const [url, options] = normalizeClientRequestArgs('http:', [
new URL('http://example.com/path-from-url?a=b&c=d'),
{
path: '/path-from-options?foo=bar&baz=xyz',
},
])
expect(url.href).toBe('http://example.com/path-from-options?foo=bar&baz=xyz')
expect(options).toMatchObject<RequestOptions>({
hostname: 'example.com',
path: '/path-from-options?foo=bar&baz=xyz',
})
})
it('handles [RequestOptions, callback] input', () => {
const initialOptions = {
method: 'POST',
protocol: 'https:',
host: 'mswjs.io',
/**
* @see https://github.com/mswjs/msw/issues/705
*/
origin: 'https://mswjs.io',
path: '/resource',
headers: {
'Content-Type': 'text/plain',
},
}
const [url, options, callback] = normalizeClientRequestArgs('https:', [
initialOptions,
function cb() {},
])
// URL must be derived from request options.
expect(url.href).toEqual('https://mswjs.io/resource')
// Request options must be preserved.
expect(options).toMatchObject(initialOptions)
// Callback must be preserved.
expect(callback).toBeTypeOf('function')
expect(callback?.name).toEqual('cb')
})
it('handles [Empty RequestOptions, callback] input', () => {
const [_, options, callback] = normalizeClientRequestArgs('https:', [
{},
function cb() {},
])
expect(options.protocol).toEqual('https:')
// Callback must be preserved
expect(callback?.name).toEqual('cb')
})
/**
* @see https://github.com/mswjs/interceptors/issues/19
*/
it('handles [PartialRequestOptions, callback] input', () => {
const initialOptions = {
method: 'GET',
port: '50176',
path: '/resource',
host: '127.0.0.1',
ca: undefined,
key: undefined,
pfx: undefined,
cert: undefined,
passphrase: undefined,
agent: false,
}
const [url, options, callback] = normalizeClientRequestArgs('https:', [
initialOptions,
function cb() {},
])
// URL must be derived from request options.
expect(url.toJSON()).toEqual(
new URL('https://127.0.0.1:50176/resource').toJSON()
)
// Request options must be preserved.
expect(options).toMatchObject(initialOptions)
// Options protocol must be inferred from the request issuing module.
expect(options.protocol).toEqual('https:')
// Callback must be preserved.
expect(callback).toBeTypeOf('function')
expect(callback?.name).toEqual('cb')
})
it('sets the default Agent for HTTP request', () => {
const [, options] = normalizeClientRequestArgs('http:', [
'http://github.com',
{},
])
expect(options._defaultAgent).toEqual(httpGlobalAgent)
})
it('sets the default Agent for HTTPS request', () => {
const [, options] = normalizeClientRequestArgs('https:', [
'https://github.com',
{},
])
expect(options._defaultAgent).toEqual(httpsGlobalAgent)
})
it('preserves a custom default Agent when set', () => {
const [, options] = normalizeClientRequestArgs('https:', [
'https://github.com',
{
/**
* @note Intentionally incorrect Agent for HTTPS request.
*/
_defaultAgent: httpGlobalAgent,
},
])
expect(options._defaultAgent).toEqual(httpGlobalAgent)
})
it('merges URL-based RequestOptions with the custom RequestOptions', () => {
const [url, options] = normalizeClientRequestArgs('https:', [
'https://github.com/graphql',
{
method: 'GET',
pfx: 'PFX_KEY',
},
])
expect(url.href).toEqual('https://github.com/graphql')
// Original request options must be preserved.
expect(options.method).toEqual('GET')
expect(options.pfx).toEqual('PFX_KEY')
// Other options must be inferred from the URL.
expect(options.protocol).toEqual(url.protocol)
expect(options.hostname).toEqual(url.hostname)
expect(options.path).toEqual(url.pathname)
})
it('respects custom "options.path" over URL path', () => {
const [url, options] = normalizeClientRequestArgs('http:', [
new URL('http://example.com/path-from-url'),
{
path: '/path-from-options',
},
])
expect(url.href).toBe('http://example.com/path-from-options')
expect(options.protocol).toBe('http:')
expect(options.hostname).toBe('example.com')
expect(options.path).toBe('/path-from-options')
})
it('respects custom "options.path" over URL path with query string', () => {
const [url, options] = normalizeClientRequestArgs('http:', [
new URL('http://example.com/path-from-url?a=b&c=d'),
{
path: '/path-from-options',
},
])
// Must replace both the path and the query string.
expect(url.href).toBe('http://example.com/path-from-options')
expect(options.protocol).toBe('http:')
expect(options.hostname).toBe('example.com')
expect(options.path).toBe('/path-from-options')
})
it('preserves URL query string', () => {
const [url, options] = normalizeClientRequestArgs('http:', [
new URL('http://example.com:8080/resource?a=b&c=d'),
])
expect(url.href).toBe('http://example.com:8080/resource?a=b&c=d')
expect(options.protocol).toBe('http:')
// expect(options.host).toBe('example.com:8080')
expect(options.hostname).toBe('example.com')
// Query string is a part of the options path.
expect(options.path).toBe('/resource?a=b&c=d')
expect(options.port).toBe(8080)
})

View File

@@ -0,0 +1,268 @@
import { urlToHttpOptions } from 'node:url'
import {
Agent as HttpAgent,
globalAgent as httpGlobalAgent,
IncomingMessage,
} from 'node:http'
import {
RequestOptions,
Agent as HttpsAgent,
globalAgent as httpsGlobalAgent,
} from 'node:https'
import {
/**
* @note Use the Node.js URL instead of the global URL
* because environments like JSDOM may override the global,
* breaking the compatibility with Node.js.
* @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555
*/
URL,
Url as LegacyURL,
parse as parseUrl,
} from 'node:url'
import { Logger } from '@open-draft/logger'
import {
ResolvedRequestOptions,
getUrlByRequestOptions,
} from '../../../utils/getUrlByRequestOptions'
import { cloneObject } from '../../../utils/cloneObject'
import { isObject } from '../../../utils/isObject'
const logger = new Logger('http normalizeClientRequestArgs')
export type HttpRequestCallback = (response: IncomingMessage) => void
export type ClientRequestArgs =
// Request without any arguments is also possible.
| []
| [string | URL | LegacyURL, HttpRequestCallback?]
| [string | URL | LegacyURL, RequestOptions, HttpRequestCallback?]
| [RequestOptions, HttpRequestCallback?]
function resolveRequestOptions(
args: ClientRequestArgs,
url: URL
): RequestOptions {
// Calling `fetch` provides only URL to `ClientRequest`
// without any `RequestOptions` or callback.
if (typeof args[1] === 'undefined' || typeof args[1] === 'function') {
logger.info('request options not provided, deriving from the url', url)
return urlToHttpOptions(url)
}
if (args[1]) {
logger.info('has custom RequestOptions!', args[1])
const requestOptionsFromUrl = urlToHttpOptions(url)
logger.info('derived RequestOptions from the URL:', requestOptionsFromUrl)
/**
* Clone the request options to lock their state
* at the moment they are provided to `ClientRequest`.
* @see https://github.com/mswjs/interceptors/issues/86
*/
logger.info('cloning RequestOptions...')
const clonedRequestOptions = cloneObject(args[1])
logger.info('successfully cloned RequestOptions!', clonedRequestOptions)
return {
...requestOptionsFromUrl,
...clonedRequestOptions,
}
}
logger.info('using an empty object as request options')
return {} as RequestOptions
}
/**
* Overrides the given `URL` instance with the explicit properties provided
* on the `RequestOptions` object. The options object takes precedence,
* and will replace URL properties like "host", "path", and "port", if specified.
*/
function overrideUrlByRequestOptions(url: URL, options: RequestOptions): URL {
url.host = options.host || url.host
url.hostname = options.hostname || url.hostname
url.port = options.port ? options.port.toString() : url.port
if (options.path) {
const parsedOptionsPath = parseUrl(options.path, false)
url.pathname = parsedOptionsPath.pathname || ''
url.search = parsedOptionsPath.search || ''
}
return url
}
function resolveCallback(
args: ClientRequestArgs
): HttpRequestCallback | undefined {
return typeof args[1] === 'function' ? args[1] : args[2]
}
export type NormalizedClientRequestArgs = [
url: URL,
options: ResolvedRequestOptions,
callback?: HttpRequestCallback
]
/**
* Normalizes parameters given to a `http.request` call
* so it always has a `URL` and `RequestOptions`.
*/
export function normalizeClientRequestArgs(
defaultProtocol: string,
args: ClientRequestArgs
): NormalizedClientRequestArgs {
let url: URL
let options: ResolvedRequestOptions
let callback: HttpRequestCallback | undefined
logger.info('arguments', args)
logger.info('using default protocol:', defaultProtocol)
// Support "http.request()" calls without any arguments.
// That call results in a "GET http://localhost" request.
if (args.length === 0) {
const url = new URL('http://localhost')
const options = resolveRequestOptions(args, url)
return [url, options]
}
// Convert a url string into a URL instance
// and derive request options from it.
if (typeof args[0] === 'string') {
logger.info('first argument is a location string:', args[0])
url = new URL(args[0])
logger.info('created a url:', url)
const requestOptionsFromUrl = urlToHttpOptions(url)
logger.info('request options from url:', requestOptionsFromUrl)
options = resolveRequestOptions(args, url)
logger.info('resolved request options:', options)
callback = resolveCallback(args)
}
// Handle a given URL instance as-is
// and derive request options from it.
else if (args[0] instanceof URL) {
url = args[0]
logger.info('first argument is a URL:', url)
// Check if the second provided argument is RequestOptions.
// If it is, check if "options.path" was set and rewrite it
// on the input URL.
// Do this before resolving options from the URL below
// to prevent query string from being duplicated in the path.
if (typeof args[1] !== 'undefined' && isObject<RequestOptions>(args[1])) {
url = overrideUrlByRequestOptions(url, args[1])
}
options = resolveRequestOptions(args, url)
logger.info('derived request options:', options)
callback = resolveCallback(args)
}
// Handle a legacy URL instance and re-normalize from either a RequestOptions object
// or a WHATWG URL.
else if ('hash' in args[0] && !('method' in args[0])) {
const [legacyUrl] = args
logger.info('first argument is a legacy URL:', legacyUrl)
if (legacyUrl.hostname === null) {
/**
* We are dealing with a relative url, so use the path as an "option" and
* merge in any existing options, giving priority to existing options -- i.e. a path in any
* existing options will take precedence over the one contained in the url. This is consistent
* with the behaviour in ClientRequest.
* @see https://github.com/nodejs/node/blob/d84f1312915fe45fe0febe888db692c74894c382/lib/_http_client.js#L122
*/
logger.info('given legacy URL is relative (no hostname)')
return isObject(args[1])
? normalizeClientRequestArgs(defaultProtocol, [
{ path: legacyUrl.path, ...args[1] },
args[2],
])
: normalizeClientRequestArgs(defaultProtocol, [
{ path: legacyUrl.path },
args[1] as HttpRequestCallback,
])
}
logger.info('given legacy url is absolute')
// We are dealing with an absolute URL, so convert to WHATWG and try again.
const resolvedUrl = new URL(legacyUrl.href)
return args[1] === undefined
? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl])
: typeof args[1] === 'function'
? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl, args[1]])
: normalizeClientRequestArgs(defaultProtocol, [
resolvedUrl,
args[1],
args[2],
])
}
// Handle a given "RequestOptions" object as-is
// and derive the URL instance from it.
else if (isObject(args[0])) {
options = { ...(args[0] as any) }
logger.info('first argument is RequestOptions:', options)
// When handling a "RequestOptions" object without an explicit "protocol",
// infer the protocol from the request issuing module (http/https).
options.protocol = options.protocol || defaultProtocol
logger.info('normalized request options:', options)
url = getUrlByRequestOptions(options)
logger.info('created a URL from RequestOptions:', url.href)
callback = resolveCallback(args)
} else {
throw new Error(
`Failed to construct ClientRequest with these parameters: ${args}`
)
}
options.protocol = options.protocol || url.protocol
options.method = options.method || 'GET'
/**
* Ensure that the default Agent is always set.
* This prevents the protocol mismatch for requests with { agent: false },
* where the global Agent is inferred.
* @see https://github.com/mswjs/msw/issues/1150
* @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L130
* @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L157-L159
*/
if (!options._defaultAgent) {
logger.info(
'has no default agent, setting the default agent for "%s"',
options.protocol
)
options._defaultAgent =
options.protocol === 'https:' ? httpsGlobalAgent : httpGlobalAgent
}
logger.info('successfully resolved url:', url.href)
logger.info('successfully resolved options:', options)
logger.info('successfully resolved callback:', callback)
/**
* @note If the user-provided URL is not a valid URL in Node.js,
* (e.g. the one provided by the JSDOM polyfills), case it to
* string. Otherwise, this throws on Node.js incompatibility
* (`ERR_INVALID_ARG_TYPE` on the connection listener)
* @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555
*/
if (!(url instanceof URL)) {
url = (url as any).toString()
}
return [url, options, callback]
}

View File

@@ -0,0 +1,48 @@
import type { Socket } from 'node:net'
import { HTTPParser } from '_http_common'
/**
* @see https://github.com/nodejs/node/blob/f3adc11e37b8bfaaa026ea85c1cf22e3a0e29ae9/lib/_http_common.js#L180
*/
export function freeParser(parser: HTTPParser<any>, socket?: Socket): void {
if (parser._consumed) {
parser.unconsume()
}
parser._headers = []
parser._url = ''
parser.socket = null
parser.incoming = null
parser.outgoing = null
parser.maxHeaderPairs = 2000
parser._consumed = false
parser.onIncoming = null
parser[HTTPParser.kOnHeaders] = null
parser[HTTPParser.kOnHeadersComplete] = null
parser[HTTPParser.kOnMessageBegin] = null
parser[HTTPParser.kOnMessageComplete] = null
parser[HTTPParser.kOnBody] = null
parser[HTTPParser.kOnExecute] = null
parser[HTTPParser.kOnTimeout] = null
parser.remove()
parser.free()
if (socket) {
/**
* @note Unassigning the socket's parser will fail this assertion
* if there's still some data being processed on the socket:
* @see https://github.com/nodejs/node/blob/4e1f39b678b37017ac9baa0971e3aeecd3b67b51/lib/_http_client.js#L613
*/
if (socket.destroyed) {
// @ts-expect-error Node.js internals.
socket.parser = null
} else {
socket.once('end', () => {
// @ts-expect-error Node.js internals.
socket.parser = null
})
}
}
}

View File

@@ -0,0 +1,258 @@
// @vitest-environment node
import { it, expect, afterEach } from 'vitest'
import {
recordRawFetchHeaders,
restoreHeadersPrototype,
getRawFetchHeaders,
} from './recordRawHeaders'
const url = 'http://localhost'
afterEach(() => {
restoreHeadersPrototype()
})
it('returns an empty list if no headers were set', () => {
expect(getRawFetchHeaders(new Headers())).toEqual([])
expect(getRawFetchHeaders(new Headers(undefined))).toEqual([])
expect(getRawFetchHeaders(new Headers({}))).toEqual([])
expect(getRawFetchHeaders(new Headers([]))).toEqual([])
expect(getRawFetchHeaders(new Request(url).headers)).toEqual([])
expect(getRawFetchHeaders(new Response().headers)).toEqual([])
})
it('records raw headers (Headers / object as init)', () => {
recordRawFetchHeaders()
const headers = new Headers({
'Content-Type': 'application/json',
'X-My-Header': '1',
})
expect(getRawFetchHeaders(headers)).toEqual([
['Content-Type', 'application/json'],
['X-My-Header', '1'],
])
expect(Object.fromEntries(headers)).toEqual({
'content-type': 'application/json',
'x-my-header': '1',
})
})
it('records raw headers (Headers / array as init)', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
expect(getRawFetchHeaders(headers)).toEqual([['X-My-Header', '1']])
expect(Object.fromEntries(headers)).toEqual({
'x-my-header': '1',
})
})
it('records raw headers (Headers / Headers as init', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
expect(getRawFetchHeaders(new Headers(headers))).toEqual([
['X-My-Header', '1'],
])
})
it('records raw headers added via ".set()"', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
headers.set('X-Another-Header', '2')
expect(getRawFetchHeaders(headers)).toEqual([
['X-My-Header', '1'],
['X-Another-Header', '2'],
])
})
it('records raw headers added via ".append()"', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
headers.append('X-My-Header', '2')
expect(getRawFetchHeaders(headers)).toEqual([
['X-My-Header', '1'],
['X-My-Header', '2'],
])
})
it('deletes the header when called ".delete()"', () => {
const headers = new Headers([['X-My-Header', '1']])
headers.delete('X-My-Header')
expect(getRawFetchHeaders(headers)).toEqual([])
})
it('records raw headers (Request / object as init)', () => {
recordRawFetchHeaders()
const request = new Request(url, {
headers: {
'Content-Type': 'application/json',
'X-My-Header': '1',
},
})
expect(getRawFetchHeaders(request.headers)).toEqual([
['Content-Type', 'application/json'],
['X-My-Header', '1'],
])
})
it('records raw headers (Request / array as init)', () => {
recordRawFetchHeaders()
const request = new Request(url, {
headers: [['X-My-Header', '1']],
})
expect(getRawFetchHeaders(request.headers)).toEqual([['X-My-Header', '1']])
})
it('records raw headers (Request / Headers as init)', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
const request = new Request(url, { headers })
expect(getRawFetchHeaders(request.headers)).toEqual([['X-My-Header', '1']])
})
it('records raw headers (Request / Request as init)', () => {
recordRawFetchHeaders()
const init = new Request(url, { headers: [['X-My-Header', '1']] })
const request = new Request(init)
expect(getRawFetchHeaders(request.headers)).toEqual([['X-My-Header', '1']])
})
it('preserves headers instanceof (Request / Request as init)', () => {
recordRawFetchHeaders()
const init = new Request(url, { headers: [['X-My-Header', '1']] })
new Request(init)
expect(init.headers).toBeInstanceOf(Headers)
})
it('preserves headers instanceof (Request / Request with Headers as init)', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
const init = new Request(url, { headers })
new Request(init)
expect(init.headers).toBeInstanceOf(Headers)
})
it('preserves headers instanceof (Response / Response with Headers as init)', () => {
recordRawFetchHeaders()
const init = { headers: new Headers([['X-My-Header', '1']]) }
new Response(url, init)
expect(init.headers).toBeInstanceOf(Headers)
})
it('records raw headers (Request / Request+Headers as init)', () => {
recordRawFetchHeaders()
const init = new Request(url, { headers: [['X-My-Header', '1']] })
expect(getRawFetchHeaders(init.headers)).toEqual([['X-My-Header', '1']])
const request = new Request(init, {
headers: new Headers([['X-Another-Header', '2']]),
})
// Must merge the raw headers from the request init
// and the request instance itself.
expect(getRawFetchHeaders(request.headers)).toEqual([
['X-My-Header', '1'],
['X-Another-Header', '2'],
])
})
it('records raw headers (Response / object as init)', () => {
recordRawFetchHeaders()
const response = new Response(null, {
headers: {
'Content-Type': 'application/json',
'X-My-Header': '1',
},
})
expect(getRawFetchHeaders(response.headers)).toEqual([
['Content-Type', 'application/json'],
['X-My-Header', '1'],
])
})
it('records raw headers (Response / array as init)', () => {
recordRawFetchHeaders()
const response = new Response(null, {
headers: [['X-My-Header', '1']],
})
expect(getRawFetchHeaders(response.headers)).toEqual([['X-My-Header', '1']])
})
it('records raw headers (Response / Headers as init)', () => {
recordRawFetchHeaders()
const headers = new Headers([['X-My-Header', '1']])
const response = new Response(null, { headers })
expect(getRawFetchHeaders(response.headers)).toEqual([['X-My-Header', '1']])
})
it('stops recording once the patches are restored', () => {
restoreHeadersPrototype()
const headers = new Headers({ 'X-My-Header': '1' })
// Must return the normalized headers (no access to raw headers).
expect(getRawFetchHeaders(headers)).toEqual([['x-my-header', '1']])
})
it('overrides an existing header when calling ".set()"', () => {
recordRawFetchHeaders()
const headers = new Headers([['a', '1']])
expect(headers.get('a')).toBe('1')
headers.set('a', '2')
expect(headers.get('a')).toBe('2')
const headersClone = new Headers(headers)
expect(headersClone.get('a')).toBe('2')
})
it('overrides an existing multi-value header when calling ".set()"', () => {
recordRawFetchHeaders()
const headers = new Headers([
['a', '1'],
['a', '2'],
])
expect(headers.get('a')).toBe('1, 2')
headers.set('a', '3')
expect(headers.get('a')).toBe('3')
})
it('does not throw on using Headers before recording', () => {
// If the consumer constructs a Headers instance before
// the interceptor is enabled, it will have no internal symbol set.
const headers = new Headers()
recordRawFetchHeaders()
const request = new Request(url, { headers })
expect(getRawFetchHeaders(request.headers)).toEqual([])
request.headers.set('X-My-Header', '1')
expect(getRawFetchHeaders(request.headers)).toEqual([['X-My-Header', '1']])
})
/**
* @see https://github.com/mswjs/interceptors/issues/681
*/
it('isolates headers between different headers instances', async () => {
recordRawFetchHeaders()
const original = new Headers()
const firstClone = new Headers(original)
firstClone.set('Content-Type', 'application/json')
const secondClone = new Headers(original)
expect(original.get('Content-Type')).toBeNull()
expect(firstClone.get('Content-Type')).toBe('application/json')
expect(secondClone.get('Content-Type')).toBeNull()
})

View File

@@ -0,0 +1,262 @@
type HeaderTuple = [string, string]
type RawHeaders = Array<HeaderTuple>
type SetHeaderBehavior = 'set' | 'append'
const kRawHeaders = Symbol('kRawHeaders')
const kRestorePatches = Symbol('kRestorePatches')
function recordRawHeader(
headers: Headers,
args: HeaderTuple,
behavior: SetHeaderBehavior
) {
ensureRawHeadersSymbol(headers, [])
const rawHeaders = Reflect.get(headers, kRawHeaders) as RawHeaders
if (behavior === 'set') {
// When recording a set header, ensure we remove any matching existing headers.
for (let index = rawHeaders.length - 1; index >= 0; index--) {
if (rawHeaders[index][0].toLowerCase() === args[0].toLowerCase()) {
rawHeaders.splice(index, 1)
}
}
}
rawHeaders.push(args)
}
/**
* Define the raw headers symbol on the given `Headers` instance.
* If the symbol already exists, this function does nothing.
*/
function ensureRawHeadersSymbol(
headers: Headers,
rawHeaders: RawHeaders
): void {
if (Reflect.has(headers, kRawHeaders)) {
return
}
defineRawHeadersSymbol(headers, rawHeaders)
}
/**
* Define the raw headers symbol on the given `Headers` instance.
* If the symbol already exists, it gets overridden.
*/
function defineRawHeadersSymbol(headers: Headers, rawHeaders: RawHeaders) {
Object.defineProperty(headers, kRawHeaders, {
value: rawHeaders,
enumerable: false,
// Mark the symbol as configurable so its value can be overridden.
// Overrides happen when merging raw headers from multiple sources.
// E.g. new Request(new Request(url, { headers }), { headers })
configurable: true,
})
}
/**
* Patch the global `Headers` class to store raw headers.
* This is for compatibility with `IncomingMessage.prototype.rawHeaders`.
*
* @note Node.js has their own raw headers symbol but it
* only records the first header name in case of multi-value headers.
* Any other headers are normalized before comparing. This makes it
* incompatible with the `rawHeaders` format.
*
* let h = new Headers()
* h.append('X-Custom', 'one')
* h.append('x-custom', 'two')
* h[Symbol('headers map')] // Map { 'X-Custom' => 'one, two' }
*/
export function recordRawFetchHeaders() {
// Prevent patching the Headers prototype multiple times.
if (Reflect.get(Headers, kRestorePatches)) {
return Reflect.get(Headers, kRestorePatches)
}
const {
Headers: OriginalHeaders,
Request: OriginalRequest,
Response: OriginalResponse,
} = globalThis
const { set, append, delete: headersDeleteMethod } = Headers.prototype
Object.defineProperty(Headers, kRestorePatches, {
value: () => {
Headers.prototype.set = set
Headers.prototype.append = append
Headers.prototype.delete = headersDeleteMethod
globalThis.Headers = OriginalHeaders
globalThis.Request = OriginalRequest
globalThis.Response = OriginalResponse
Reflect.deleteProperty(Headers, kRestorePatches)
},
enumerable: false,
/**
* @note Mark this property as configurable
* so we can delete it using `Reflect.delete` during cleanup.
*/
configurable: true,
})
Object.defineProperty(globalThis, 'Headers', {
enumerable: true,
writable: true,
value: new Proxy(Headers, {
construct(target, args, newTarget) {
const headersInit = args[0] || []
if (
headersInit instanceof Headers &&
Reflect.has(headersInit, kRawHeaders)
) {
const headers = Reflect.construct(
target,
[Reflect.get(headersInit, kRawHeaders)],
newTarget
)
ensureRawHeadersSymbol(headers, [
/**
* @note Spread the retrieved headers to clone them.
* This prevents multiple Headers instances from pointing
* at the same internal "rawHeaders" array.
*/
...Reflect.get(headersInit, kRawHeaders),
])
return headers
}
const headers = Reflect.construct(target, args, newTarget)
// Request/Response constructors will set the symbol
// upon creating a new instance, using the raw developer
// input as the raw headers. Skip the symbol altogether
// in those cases because the input to Headers will be normalized.
if (!Reflect.has(headers, kRawHeaders)) {
const rawHeadersInit = Array.isArray(headersInit)
? headersInit
: Object.entries(headersInit)
ensureRawHeadersSymbol(headers, rawHeadersInit)
}
return headers
},
}),
})
Headers.prototype.set = new Proxy(Headers.prototype.set, {
apply(target, thisArg, args: HeaderTuple) {
recordRawHeader(thisArg, args, 'set')
return Reflect.apply(target, thisArg, args)
},
})
Headers.prototype.append = new Proxy(Headers.prototype.append, {
apply(target, thisArg, args: HeaderTuple) {
recordRawHeader(thisArg, args, 'append')
return Reflect.apply(target, thisArg, args)
},
})
Headers.prototype.delete = new Proxy(Headers.prototype.delete, {
apply(target, thisArg, args: [string]) {
const rawHeaders = Reflect.get(thisArg, kRawHeaders) as RawHeaders
if (rawHeaders) {
for (let index = rawHeaders.length - 1; index >= 0; index--) {
if (rawHeaders[index][0].toLowerCase() === args[0].toLowerCase()) {
rawHeaders.splice(index, 1)
}
}
}
return Reflect.apply(target, thisArg, args)
},
})
Object.defineProperty(globalThis, 'Request', {
enumerable: true,
writable: true,
value: new Proxy(Request, {
construct(target, args, newTarget) {
const request = Reflect.construct(target, args, newTarget)
const inferredRawHeaders: RawHeaders = []
// Infer raw headers from a `Request` instance used as init.
if (typeof args[0] === 'object' && args[0].headers != null) {
inferredRawHeaders.push(...inferRawHeaders(args[0].headers))
}
// Infer raw headers from the "headers" init argument.
if (typeof args[1] === 'object' && args[1].headers != null) {
inferredRawHeaders.push(...inferRawHeaders(args[1].headers))
}
if (inferredRawHeaders.length > 0) {
ensureRawHeadersSymbol(request.headers, inferredRawHeaders)
}
return request
},
}),
})
Object.defineProperty(globalThis, 'Response', {
enumerable: true,
writable: true,
value: new Proxy(Response, {
construct(target, args, newTarget) {
const response = Reflect.construct(target, args, newTarget)
if (typeof args[1] === 'object' && args[1].headers != null) {
ensureRawHeadersSymbol(
response.headers,
inferRawHeaders(args[1].headers)
)
}
return response
},
}),
})
}
export function restoreHeadersPrototype() {
if (!Reflect.get(Headers, kRestorePatches)) {
return
}
Reflect.get(Headers, kRestorePatches)()
}
export function getRawFetchHeaders(headers: Headers): RawHeaders {
// If the raw headers recording failed for some reason,
// use the normalized header entries instead.
if (!Reflect.has(headers, kRawHeaders)) {
return Array.from(headers.entries())
}
const rawHeaders = Reflect.get(headers, kRawHeaders) as RawHeaders
return rawHeaders.length > 0 ? rawHeaders : Array.from(headers.entries())
}
/**
* Infers the raw headers from the given `HeadersInit` provided
* to the Request/Response constructor.
*
* If the `init.headers` is a Headers instance, use it directly.
* That means the headers were created standalone and already have
* the raw headers stored.
* If the `init.headers` is a HeadersInit, create a new Headers
* instance out of it.
*/
function inferRawHeaders(headers: HeadersInit): RawHeaders {
if (headers instanceof Headers) {
return Reflect.get(headers, kRawHeaders) || []
}
return Reflect.get(new Headers(headers), kRawHeaders)
}

View File

@@ -0,0 +1,264 @@
/**
* @vitest-environment node
*/
import { Socket } from 'node:net'
import { vi, it, expect } from 'vitest'
import { MockSocket } from './MockSocket'
it(`keeps the socket connecting until it's destroyed`, () => {
const socket = new MockSocket({
write: vi.fn(),
read: vi.fn(),
})
expect(socket.connecting).toBe(true)
socket.destroy()
expect(socket.connecting).toBe(false)
})
it('calls the "write" on "socket.write()"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.write()
expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined)
})
it('calls the "write" on "socket.write(chunk)"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.write('hello')
expect(writeCallback).toHaveBeenCalledWith('hello', undefined, undefined)
})
it('calls the "write" on "socket.write(chunk, encoding)"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.write('hello', 'utf8')
expect(writeCallback).toHaveBeenCalledWith('hello', 'utf8', undefined)
})
it('calls the "write" on "socket.write(chunk, encoding, callback)"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
const callback = vi.fn()
socket.write('hello', 'utf8', callback)
expect(writeCallback).toHaveBeenCalledWith('hello', 'utf8', callback)
})
it('calls the "write" on "socket.end()"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.end()
expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined)
})
it('calls the "write" on "socket.end(chunk)"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.end('final')
expect(writeCallback).toHaveBeenCalledWith('final', undefined, undefined)
})
it('calls the "write" on "socket.end(chunk, encoding)"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.end('final', 'utf8')
expect(writeCallback).toHaveBeenCalledWith('final', 'utf8', undefined)
})
it('calls the "write" on "socket.end(chunk, encoding, callback)"', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
const callback = vi.fn()
socket.end('final', 'utf8', callback)
expect(writeCallback).toHaveBeenCalledWith('final', 'utf8', callback)
})
it('calls the "write" on "socket.end()" without any arguments', () => {
const writeCallback = vi.fn()
const socket = new MockSocket({
write: writeCallback,
read: vi.fn(),
})
socket.end()
expect(writeCallback).toHaveBeenCalledWith(undefined, undefined, undefined)
})
it('emits "finished" on .end() without any arguments', async () => {
const finishListener = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: vi.fn(),
})
socket.on('finish', finishListener)
socket.end()
await vi.waitFor(() => {
expect(finishListener).toHaveBeenCalledTimes(1)
})
})
it('calls the "read" on "socket.read(chunk)"', () => {
const readCallback = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: readCallback,
})
socket.push('hello')
expect(readCallback).toHaveBeenCalledWith('hello', undefined)
})
it('calls the "read" on "socket.read(chunk, encoding)"', () => {
const readCallback = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: readCallback,
})
socket.push('world', 'utf8')
expect(readCallback).toHaveBeenCalledWith('world', 'utf8')
})
it('calls the "read" on "socket.read(null)"', () => {
const readCallback = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: readCallback,
})
socket.push(null)
expect(readCallback).toHaveBeenCalledWith(null, undefined)
})
it('updates the writable state on "socket.end()"', async () => {
const finishListener = vi.fn()
const endListener = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: vi.fn(),
})
socket.on('finish', finishListener)
socket.on('end', endListener)
expect(socket.writable).toBe(true)
expect(socket.writableEnded).toBe(false)
expect(socket.writableFinished).toBe(false)
socket.write('hello')
// Finish the writable stream.
socket.end()
expect(socket.writable).toBe(false)
expect(socket.writableEnded).toBe(true)
// The "finish" event is emitted when writable is done.
// I.e. "socket.end()" is called.
await vi.waitFor(() => {
expect(finishListener).toHaveBeenCalledTimes(1)
})
expect(socket.writableFinished).toBe(true)
})
it('updates the readable state on "socket.push(null)"', async () => {
const endListener = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: vi.fn(),
})
socket.on('end', endListener)
expect(socket.readable).toBe(true)
expect(socket.readableEnded).toBe(false)
socket.push('hello')
socket.push(null)
expect(socket.readable).toBe(true)
expect(socket.readableEnded).toBe(false)
// Read the data to free the buffer and
// make Socket emit "end".
socket.read()
await vi.waitFor(() => {
expect(endListener).toHaveBeenCalledTimes(1)
})
expect(socket.readable).toBe(false)
expect(socket.readableEnded).toBe(true)
})
it('updates the readable/writable state on "socket.destroy()"', async () => {
const finishListener = vi.fn()
const endListener = vi.fn()
const closeListener = vi.fn()
const socket = new MockSocket({
write: vi.fn(),
read: vi.fn(),
})
socket.on('finish', finishListener)
socket.on('end', endListener)
socket.on('close', closeListener)
expect(socket.writable).toBe(true)
expect(socket.writableEnded).toBe(false)
expect(socket.writableFinished).toBe(false)
expect(socket.readable).toBe(true)
socket.destroy()
expect(socket.writable).toBe(false)
// The ".end()" wasn't called.
expect(socket.writableEnded).toBe(false)
expect(socket.writableFinished).toBe(false)
expect(socket.readable).toBe(false)
await vi.waitFor(() => {
expect(closeListener).toHaveBeenCalledTimes(1)
})
// Neither "finish" nor "end" events are emitted
// when you destroy the stream. If you want those,
// call ".end()", then destroy the stream.
expect(finishListener).not.toHaveBeenCalled()
expect(endListener).not.toHaveBeenCalled()
expect(socket.writableFinished).toBe(false)
// The "end" event was never emitted so "readableEnded"
// remains false.
expect(socket.readableEnded).toBe(false)
})

View File

@@ -0,0 +1,58 @@
import net from 'node:net'
import {
normalizeSocketWriteArgs,
type WriteArgs,
type WriteCallback,
} from './utils/normalizeSocketWriteArgs'
export interface MockSocketOptions {
write: (
chunk: Buffer | string,
encoding: BufferEncoding | undefined,
callback?: WriteCallback
) => void
read: (chunk: Buffer, encoding: BufferEncoding | undefined) => void
}
export class MockSocket extends net.Socket {
public connecting: boolean
constructor(protected readonly options: MockSocketOptions) {
super()
this.connecting = false
this.connect()
this._final = (callback) => {
callback(null)
}
}
public connect() {
// The connection will remain pending until
// the consumer decides to handle it.
this.connecting = true
return this
}
public write(...args: Array<unknown>): boolean {
const [chunk, encoding, callback] = normalizeSocketWriteArgs(
args as WriteArgs
)
this.options.write(chunk, encoding, callback)
return true
}
public end(...args: Array<unknown>) {
const [chunk, encoding, callback] = normalizeSocketWriteArgs(
args as WriteArgs
)
this.options.write(chunk, encoding, callback)
return super.end.apply(this, args as any)
}
public push(chunk: any, encoding?: BufferEncoding): boolean {
this.options.read(chunk, encoding)
return super.push(chunk, encoding)
}
}

View File

@@ -0,0 +1,26 @@
export function baseUrlFromConnectionOptions(options: any): URL {
if ('href' in options) {
return new URL(options.href)
}
const protocol = options.port === 443 ? 'https:' : 'http:'
const host = options.host
const url = new URL(`${protocol}//${host}`)
if (options.port) {
url.port = options.port.toString()
}
if (options.path) {
url.pathname = options.path
}
if (options.auth) {
const [username, password] = options.auth.split(':')
url.username = username
url.password = password
}
return url
}

View File

@@ -0,0 +1,52 @@
/**
* @vitest-environment node
*/
import { it, expect } from 'vitest'
import { normalizeSocketWriteArgs } from './normalizeSocketWriteArgs'
it('normalizes .write()', () => {
expect(normalizeSocketWriteArgs([undefined])).toEqual([
undefined,
undefined,
undefined,
])
expect(normalizeSocketWriteArgs([null])).toEqual([null, undefined, undefined])
})
it('normalizes .write(chunk)', () => {
expect(normalizeSocketWriteArgs([Buffer.from('hello')])).toEqual([
Buffer.from('hello'),
undefined,
undefined,
])
expect(normalizeSocketWriteArgs(['hello'])).toEqual([
'hello',
undefined,
undefined,
])
expect(normalizeSocketWriteArgs([null])).toEqual([null, undefined, undefined])
})
it('normalizes .write(chunk, encoding)', () => {
expect(normalizeSocketWriteArgs([Buffer.from('hello'), 'utf8'])).toEqual([
Buffer.from('hello'),
'utf8',
undefined,
])
})
it('normalizes .write(chunk, callback)', () => {
const callback = () => {}
expect(normalizeSocketWriteArgs([Buffer.from('hello'), callback])).toEqual([
Buffer.from('hello'),
undefined,
callback,
])
})
it('normalizes .write(chunk, encoding, callback)', () => {
const callback = () => {}
expect(
normalizeSocketWriteArgs([Buffer.from('hello'), 'utf8', callback])
).toEqual([Buffer.from('hello'), 'utf8', callback])
})

View File

@@ -0,0 +1,33 @@
export type WriteCallback = (error?: Error | null) => void
export type WriteArgs =
| [chunk: unknown, callback?: WriteCallback]
| [chunk: unknown, encoding: BufferEncoding, callback?: WriteCallback]
export type NormalizedSocketWriteArgs = [
chunk: any,
encoding?: BufferEncoding,
callback?: WriteCallback,
]
/**
* Normalizes the arguments provided to the `Writable.prototype.write()`
* and `Writable.prototype.end()`.
*/
export function normalizeSocketWriteArgs(
args: WriteArgs
): NormalizedSocketWriteArgs {
const normalized: NormalizedSocketWriteArgs = [args[0], undefined, undefined]
if (typeof args[1] === 'string') {
normalized[1] = args[1]
} else if (typeof args[1] === 'function') {
normalized[2] = args[1]
}
if (typeof args[2] === 'function') {
normalized[2] = args[2]
}
return normalized
}

View File

@@ -0,0 +1,116 @@
import { bindEvent } from './utils/bindEvent'
import {
StrictEventListenerOrEventListenerObject,
WebSocketData,
WebSocketTransport,
WebSocketTransportEventMap,
} from './WebSocketTransport'
import { kOnSend, kClose, WebSocketOverride } from './WebSocketOverride'
import { CancelableMessageEvent, CloseEvent } from './utils/events'
/**
* Abstraction over the given mock `WebSocket` instance that allows
* for controlling that instance (e.g. sending and receiving messages).
*/
export class WebSocketClassTransport
extends EventTarget
implements WebSocketTransport
{
constructor(protected readonly socket: WebSocketOverride) {
super()
// Emit the "close" event on the transport if the close
// originates from the WebSocket client. E.g. the application
// calls "ws.close()", not the interceptor.
this.socket.addEventListener('close', (event) => {
this.dispatchEvent(bindEvent(this.socket, new CloseEvent('close', event)))
})
/**
* Emit the "outgoing" event on the transport
* whenever the WebSocket client sends data ("ws.send()").
*/
this.socket[kOnSend] = (data) => {
this.dispatchEvent(
bindEvent(
this.socket,
// Dispatch this as cancelable because "client" connection
// re-creates this message event (cannot dispatch the same event).
new CancelableMessageEvent('outgoing', {
data,
origin: this.socket.url,
cancelable: true,
})
)
)
}
}
public addEventListener<EventType extends keyof WebSocketTransportEventMap>(
type: EventType,
callback: StrictEventListenerOrEventListenerObject<
WebSocketTransportEventMap[EventType]
> | null,
options?: boolean | AddEventListenerOptions
): void {
return super.addEventListener(type, callback as EventListener, options)
}
public dispatchEvent<EventType extends keyof WebSocketTransportEventMap>(
event: WebSocketTransportEventMap[EventType]
): boolean {
return super.dispatchEvent(event)
}
public send(data: WebSocketData): void {
queueMicrotask(() => {
if (
this.socket.readyState === this.socket.CLOSING ||
this.socket.readyState === this.socket.CLOSED
) {
return
}
const dispatchEvent = () => {
this.socket.dispatchEvent(
bindEvent(
/**
* @note Setting this event's "target" to the
* WebSocket override instance is important.
* This way it can tell apart original incoming events
* (must be forwarded to the transport) from the
* mocked message events like the one below
* (must be dispatched on the client instance).
*/
this.socket,
new MessageEvent('message', {
data,
origin: this.socket.url,
})
)
)
}
if (this.socket.readyState === this.socket.CONNECTING) {
this.socket.addEventListener(
'open',
() => {
dispatchEvent()
},
{ once: true }
)
} else {
dispatchEvent()
}
})
}
public close(code: number, reason?: string): void {
/**
* @note Call the internal close method directly
* to allow closing the connection with the status codes
* that are non-configurable by the user (> 1000 <= 1015).
*/
this.socket[kClose](code, reason)
}
}

View File

@@ -0,0 +1,152 @@
import type { WebSocketData, WebSocketTransport } from './WebSocketTransport'
import type { WebSocketEventListener } from './WebSocketOverride'
import { bindEvent } from './utils/bindEvent'
import { CancelableMessageEvent, CloseEvent } from './utils/events'
import { createRequestId } from '../../createRequestId'
const kEmitter = Symbol('kEmitter')
const kBoundListener = Symbol('kBoundListener')
export interface WebSocketClientEventMap {
message: MessageEvent<WebSocketData>
close: CloseEvent
}
export abstract class WebSocketClientConnectionProtocol {
abstract id: string
abstract url: URL
public abstract send(data: WebSocketData): void
public abstract close(code?: number, reason?: string): void
public abstract addEventListener<
EventType extends keyof WebSocketClientEventMap,
>(
type: EventType,
listener: WebSocketEventListener<WebSocketClientEventMap[EventType]>,
options?: AddEventListenerOptions | boolean
): void
public abstract removeEventListener<
EventType extends keyof WebSocketClientEventMap,
>(
event: EventType,
listener: WebSocketEventListener<WebSocketClientEventMap[EventType]>,
options?: EventListenerOptions | boolean
): void
}
/**
* The WebSocket client instance represents an incoming
* client connection. The user can control the connection,
* send and receive events.
*/
export class WebSocketClientConnection implements WebSocketClientConnectionProtocol {
public readonly id: string
public readonly url: URL
private [kEmitter]: EventTarget
constructor(
public readonly socket: WebSocket,
private readonly transport: WebSocketTransport
) {
this.id = createRequestId()
this.url = new URL(socket.url)
this[kEmitter] = new EventTarget()
// Emit outgoing client data ("ws.send()") as "message"
// events on the "client" connection.
this.transport.addEventListener('outgoing', (event) => {
const message = bindEvent(
this.socket,
new CancelableMessageEvent('message', {
data: event.data,
origin: event.origin,
cancelable: true,
})
)
this[kEmitter].dispatchEvent(message)
// This is a bit silly but forward the cancellation state
// of the "client" message event to the "outgoing" transport event.
// This way, other agens (like "server" connection) can know
// whether the client listener has pervented the default.
if (message.defaultPrevented) {
event.preventDefault()
}
})
/**
* Emit the "close" event on the "client" connection
* whenever the underlying transport is closed.
* @note "client.close()" does NOT dispatch the "close"
* event on the WebSocket because it uses non-configurable
* close status code. Thus, we listen to the transport
* instead of the WebSocket's "close" event.
*/
this.transport.addEventListener('close', (event) => {
this[kEmitter].dispatchEvent(
bindEvent(this.socket, new CloseEvent('close', event))
)
})
}
/**
* Listen for the outgoing events from the connected WebSocket client.
*/
public addEventListener<EventType extends keyof WebSocketClientEventMap>(
type: EventType,
listener: WebSocketEventListener<WebSocketClientEventMap[EventType]>,
options?: AddEventListenerOptions | boolean
): void {
if (!Reflect.has(listener, kBoundListener)) {
const boundListener = listener.bind(this.socket)
// Store the bound listener on the original listener
// so the exact bound function can be accessed in "removeEventListener()".
Object.defineProperty(listener, kBoundListener, {
value: boundListener,
enumerable: false,
configurable: false,
})
}
this[kEmitter].addEventListener(
type,
Reflect.get(listener, kBoundListener) as EventListener,
options
)
}
/**
* Removes the listener for the given event.
*/
public removeEventListener<EventType extends keyof WebSocketClientEventMap>(
event: EventType,
listener: WebSocketEventListener<WebSocketClientEventMap[EventType]>,
options?: EventListenerOptions | boolean
): void {
this[kEmitter].removeEventListener(
event,
Reflect.get(listener, kBoundListener) as EventListener,
options
)
}
/**
* Send data to the connected client.
*/
public send(data: WebSocketData): void {
this.transport.send(data)
}
/**
* Close the WebSocket connection.
* @param {number} code A status code (see https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1).
* @param {string} reason A custom connection close reason.
*/
public close(code?: number, reason?: string): void {
this.transport.close(code, reason)
}
}

View File

@@ -0,0 +1,252 @@
import { invariant } from 'outvariant'
import { DeferredPromise } from '@open-draft/deferred-promise'
import type { WebSocketData } from './WebSocketTransport'
import { bindEvent } from './utils/bindEvent'
import { CloseEvent } from './utils/events'
import { resolveWebSocketUrl } from '../../utils/resolveWebSocketUrl'
export type WebSocketEventListener<
EventType extends WebSocketEventMap[keyof WebSocketEventMap] = Event,
> = (this: WebSocket, event: EventType) => void
const WEBSOCKET_CLOSE_CODE_RANGE_ERROR =
'InvalidAccessError: close code out of user configurable range'
export const kPassthroughPromise = Symbol('kPassthroughPromise')
export const kOnSend = Symbol('kOnSend')
export const kClose = Symbol('kClose')
export class WebSocketOverride extends EventTarget implements WebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
readonly CONNECTING = 0
readonly OPEN = 1
readonly CLOSING = 2
readonly CLOSED = 3
public url: string
public protocol: string
public extensions: string
public binaryType: BinaryType
public readyState: WebSocket['readyState']
public bufferedAmount: number
private _onopen: WebSocketEventListener | null = null
private _onmessage: WebSocketEventListener<
MessageEvent<WebSocketData>
> | null = null
private _onerror: WebSocketEventListener | null = null
private _onclose: WebSocketEventListener<CloseEvent> | null = null
private [kPassthroughPromise]: DeferredPromise<boolean>
private [kOnSend]?: (data: WebSocketData) => void
constructor(url: string | URL, protocols?: string | Array<string>) {
super()
this.url = resolveWebSocketUrl(url)
this.protocol = ''
this.extensions = ''
this.binaryType = 'blob'
this.readyState = this.CONNECTING
this.bufferedAmount = 0
this[kPassthroughPromise] = new DeferredPromise<boolean>()
queueMicrotask(async () => {
if (await this[kPassthroughPromise]) {
return
}
this.protocol =
typeof protocols === 'string'
? protocols
: Array.isArray(protocols) && protocols.length > 0
? protocols[0]
: ''
/**
* @note Check that nothing has prevented this connection
* (e.g. called `client.close()` in the connection listener).
* If the connection has been prevented, never dispatch the open event,.
*/
if (this.readyState === this.CONNECTING) {
this.readyState = this.OPEN
this.dispatchEvent(bindEvent(this, new Event('open')))
}
})
}
set onopen(listener: WebSocketEventListener | null) {
this.removeEventListener('open', this._onopen)
this._onopen = listener
if (listener !== null) {
this.addEventListener('open', listener)
}
}
get onopen(): WebSocketEventListener | null {
return this._onopen
}
set onmessage(
listener: WebSocketEventListener<MessageEvent<WebSocketData>> | null
) {
this.removeEventListener(
'message',
this._onmessage as WebSocketEventListener
)
this._onmessage = listener
if (listener !== null) {
this.addEventListener('message', listener)
}
}
get onmessage(): WebSocketEventListener<MessageEvent<WebSocketData>> | null {
return this._onmessage
}
set onerror(listener: WebSocketEventListener | null) {
this.removeEventListener('error', this._onerror)
this._onerror = listener
if (listener !== null) {
this.addEventListener('error', listener)
}
}
get onerror(): WebSocketEventListener | null {
return this._onerror
}
set onclose(listener: WebSocketEventListener<CloseEvent> | null) {
this.removeEventListener('close', this._onclose as WebSocketEventListener)
this._onclose = listener
if (listener !== null) {
this.addEventListener('close', listener)
}
}
get onclose(): WebSocketEventListener<CloseEvent> | null {
return this._onclose
}
/**
* @see https://websockets.spec.whatwg.org/#ref-for-dom-websocket-send%E2%91%A0
*/
public send(data: WebSocketData): void {
if (this.readyState === this.CONNECTING) {
this.close()
throw new DOMException('InvalidStateError')
}
// Sending when the socket is about to close
// discards the sent data.
if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) {
return
}
// Buffer the data to send in this even loop
// but send it in the next.
this.bufferedAmount += getDataSize(data)
queueMicrotask(() => {
// This is a bit optimistic but since no actual data transfer
// is involved, all the data will be "sent" on the next tick.
this.bufferedAmount = 0
/**
* @note Notify the parent about outgoing data.
* This notifies the transport and the connection
* listens to the outgoing data to emit the "message" event.
*/
this[kOnSend]?.(data)
})
}
public close(code: number = 1000, reason?: string): void {
invariant(code, WEBSOCKET_CLOSE_CODE_RANGE_ERROR)
invariant(
code === 1000 || (code >= 3000 && code <= 4999),
WEBSOCKET_CLOSE_CODE_RANGE_ERROR
)
this[kClose](code, reason)
}
private [kClose](
code: number = 1000,
reason?: string,
wasClean = true
): void {
/**
* @note Move this check here so that even internal closures,
* like those triggered by the `server` connection, are not
* performed twice.
*/
if (this.readyState === this.CLOSING || this.readyState === this.CLOSED) {
return
}
this.readyState = this.CLOSING
queueMicrotask(() => {
this.readyState = this.CLOSED
this.dispatchEvent(
bindEvent(
this,
new CloseEvent('close', {
code,
reason,
wasClean,
})
)
)
// Remove all event listeners once the socket is closed.
this._onopen = null
this._onmessage = null
this._onerror = null
this._onclose = null
})
}
public addEventListener<K extends keyof WebSocketEventMap>(
type: K,
listener: (this: WebSocket, event: WebSocketEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void
public addEventListener(
type: string,
listener: EventListenerOrEventListenerObject,
options?: boolean | AddEventListenerOptions
): void
public addEventListener(
type: unknown,
listener: unknown,
options?: unknown
): void {
return super.addEventListener(
type as string,
listener as EventListener,
options as AddEventListenerOptions
)
}
removeEventListener<K extends keyof WebSocketEventMap>(
type: K,
callback: EventListenerOrEventListenerObject | null,
options?: boolean | EventListenerOptions
): void {
return super.removeEventListener(type, callback, options)
}
}
function getDataSize(data: WebSocketData): number {
if (typeof data === 'string') {
return data.length
}
if (data instanceof Blob) {
return data.size
}
return data.byteLength
}

View File

@@ -0,0 +1,420 @@
import { invariant } from 'outvariant'
import {
kClose,
WebSocketEventListener,
WebSocketOverride,
} from './WebSocketOverride'
import type { WebSocketData } from './WebSocketTransport'
import type { WebSocketClassTransport } from './WebSocketClassTransport'
import { bindEvent } from './utils/bindEvent'
import {
CancelableMessageEvent,
CancelableCloseEvent,
CloseEvent,
} from './utils/events'
const kEmitter = Symbol('kEmitter')
const kBoundListener = Symbol('kBoundListener')
const kSend = Symbol('kSend')
export interface WebSocketServerEventMap {
open: Event
message: MessageEvent<WebSocketData>
error: Event
close: CloseEvent
}
export abstract class WebSocketServerConnectionProtocol {
public abstract connect(): void
public abstract send(data: WebSocketData): void
public abstract close(): void
public abstract addEventListener<
EventType extends keyof WebSocketServerEventMap,
>(
event: EventType,
listener: WebSocketEventListener<WebSocketServerEventMap[EventType]>,
options?: AddEventListenerOptions | boolean
): void
public abstract removeEventListener<
EventType extends keyof WebSocketServerEventMap,
>(
event: EventType,
listener: WebSocketEventListener<WebSocketServerEventMap[EventType]>,
options?: EventListenerOptions | boolean
): void
}
/**
* The WebSocket server instance represents the actual production
* WebSocket server connection. It's idle by default but you can
* establish it by calling `server.connect()`.
*/
export class WebSocketServerConnection implements WebSocketServerConnectionProtocol {
/**
* A WebSocket instance connected to the original server.
*/
private realWebSocket?: WebSocket
private mockCloseController: AbortController
private realCloseController: AbortController
private [kEmitter]: EventTarget
constructor(
private readonly client: WebSocketOverride,
private readonly transport: WebSocketClassTransport,
private readonly createConnection: () => WebSocket
) {
this[kEmitter] = new EventTarget()
this.mockCloseController = new AbortController()
this.realCloseController = new AbortController()
// Automatically forward outgoing client events
// to the actual server unless the outgoing message event
// has been prevented. The "outgoing" transport event it
// dispatched by the "client" connection.
this.transport.addEventListener('outgoing', (event) => {
// Ignore client messages if the server connection
// hasn't been established yet. Nowhere to forward.
if (typeof this.realWebSocket === 'undefined') {
return
}
// Every outgoing client message can prevent this forwarding
// by preventing the default of the outgoing message event.
// This listener will be added before user-defined listeners,
// so execute the logic on the next tick.
queueMicrotask(() => {
if (!event.defaultPrevented) {
/**
* @note Use the internal send mechanism so consumers can tell
* apart direct user calls to `server.send()` and internal calls.
* E.g. MSW has to ignore this internal call to log out messages correctly.
*/
this[kSend](event.data)
}
})
})
this.transport.addEventListener(
'incoming',
this.handleIncomingMessage.bind(this)
)
}
/**
* The `WebSocket` instance connected to the original server.
* Accessing this before calling `server.connect()` will throw.
*/
public get socket(): WebSocket {
invariant(
this.realWebSocket,
'Cannot access "socket" on the original WebSocket server object: the connection is not open. Did you forget to call `server.connect()`?'
)
return this.realWebSocket
}
/**
* Open connection to the original WebSocket server.
*/
public connect(): void {
invariant(
!this.realWebSocket || this.realWebSocket.readyState !== WebSocket.OPEN,
'Failed to call "connect()" on the original WebSocket instance: the connection already open'
)
const realWebSocket = this.createConnection()
// Inherit the binary type from the mock WebSocket client.
realWebSocket.binaryType = this.client.binaryType
// Allow the interceptor to listen to when the server connection
// has been established. This isn't necessary to operate with the connection
// but may be beneficial in some cases (like conditionally adding logging).
realWebSocket.addEventListener(
'open',
(event) => {
this[kEmitter].dispatchEvent(
bindEvent(this.realWebSocket!, new Event('open', event))
)
},
{ once: true }
)
realWebSocket.addEventListener('message', (event) => {
// Dispatch the "incoming" transport event instead of
// invoking the internal handler directly. This way,
// anyone can listen to the "incoming" event but this
// class is the one resulting in it.
this.transport.dispatchEvent(
bindEvent(
this.realWebSocket!,
new MessageEvent('incoming', {
data: event.data,
origin: event.origin,
})
)
)
})
// Close the original connection when the mock client closes.
// E.g. "client.close()" was called. This is never forwarded anywhere.
this.client.addEventListener(
'close',
(event) => {
this.handleMockClose(event)
},
{
signal: this.mockCloseController.signal,
}
)
// Forward the "close" event to let the interceptor handle
// closures initiated by the original server.
realWebSocket.addEventListener(
'close',
(event) => {
this.handleRealClose(event)
},
{
signal: this.realCloseController.signal,
}
)
realWebSocket.addEventListener('error', () => {
const errorEvent = bindEvent(
realWebSocket,
new Event('error', { cancelable: true })
)
// Emit the "error" event on the `server` connection
// to let the interceptor react to original server errors.
this[kEmitter].dispatchEvent(errorEvent)
// If the error event from the original server hasn't been prevented,
// forward it to the underlying client.
if (!errorEvent.defaultPrevented) {
this.client.dispatchEvent(bindEvent(this.client, new Event('error')))
}
})
this.realWebSocket = realWebSocket
}
/**
* Listen for the incoming events from the original WebSocket server.
*/
public addEventListener<EventType extends keyof WebSocketServerEventMap>(
event: EventType,
listener: WebSocketEventListener<WebSocketServerEventMap[EventType]>,
options?: AddEventListenerOptions | boolean
): void {
if (!Reflect.has(listener, kBoundListener)) {
const boundListener = listener.bind(this.client)
// Store the bound listener on the original listener
// so the exact bound function can be accessed in "removeEventListener()".
Object.defineProperty(listener, kBoundListener, {
value: boundListener,
enumerable: false,
})
}
this[kEmitter].addEventListener(
event,
Reflect.get(listener, kBoundListener) as EventListener,
options
)
}
/**
* Remove the listener for the given event.
*/
public removeEventListener<EventType extends keyof WebSocketServerEventMap>(
event: EventType,
listener: WebSocketEventListener<WebSocketServerEventMap[EventType]>,
options?: EventListenerOptions | boolean
): void {
this[kEmitter].removeEventListener(
event,
Reflect.get(listener, kBoundListener) as EventListener,
options
)
}
/**
* Send data to the original WebSocket server.
* @example
* server.send('hello')
* server.send(new Blob(['hello']))
* server.send(new TextEncoder().encode('hello'))
*/
public send(data: WebSocketData): void {
this[kSend](data)
}
private [kSend](data: WebSocketData): void {
const { realWebSocket } = this
invariant(
realWebSocket,
'Failed to call "server.send()" for "%s": the connection is not open. Did you forget to call "server.connect()"?',
this.client.url
)
// Silently ignore writes on the closed original WebSocket.
if (
realWebSocket.readyState === WebSocket.CLOSING ||
realWebSocket.readyState === WebSocket.CLOSED
) {
return
}
// Delegate the send to when the original connection is open.
// Unlike the mock, connecting to the original server may take time
// so we cannot call this on the next tick.
if (realWebSocket.readyState === WebSocket.CONNECTING) {
realWebSocket.addEventListener(
'open',
() => {
realWebSocket.send(data)
},
{ once: true }
)
return
}
// Send the data to the original WebSocket server.
realWebSocket.send(data)
}
/**
* Close the actual server connection.
*/
public close(): void {
const { realWebSocket } = this
invariant(
realWebSocket,
'Failed to close server connection for "%s": the connection is not open. Did you forget to call "server.connect()"?',
this.client.url
)
// Remove the "close" event listener from the server
// so it doesn't close the underlying WebSocket client
// when you call "server.close()". This also prevents the
// `close` event on the `server` connection from being dispatched twice.
this.realCloseController.abort()
if (
realWebSocket.readyState === WebSocket.CLOSING ||
realWebSocket.readyState === WebSocket.CLOSED
) {
return
}
// Close the actual client connection.
realWebSocket.close()
// Dispatch the "close" event on the `server` connection.
queueMicrotask(() => {
this[kEmitter].dispatchEvent(
bindEvent(
this.realWebSocket,
new CancelableCloseEvent('close', {
/**
* @note `server.close()` in the interceptor
* always results in clean closures.
*/
code: 1000,
cancelable: true,
})
)
)
})
}
private handleIncomingMessage(event: MessageEvent<WebSocketData>): void {
// Clone the event to dispatch it on this class
// once again and prevent the "already being dispatched"
// exception. Clone it here so we can observe this event
// being prevented in the "server.on()" listeners.
const messageEvent = bindEvent(
event.target,
new CancelableMessageEvent('message', {
data: event.data,
origin: event.origin,
cancelable: true,
})
)
/**
* @note Emit "message" event on the server connection
* instance to let the interceptor know about these
* incoming events from the original server. In that listener,
* the interceptor can modify or skip the event forwarding
* to the mock WebSocket instance.
*/
this[kEmitter].dispatchEvent(messageEvent)
/**
* @note Forward the incoming server events to the client.
* Preventing the default on the message event stops this.
*/
if (!messageEvent.defaultPrevented) {
this.client.dispatchEvent(
bindEvent(
/**
* @note Bind the forwarded original server events
* to the mock WebSocket instance so it would
* dispatch them straight away.
*/
this.client,
// Clone the message event again to prevent
// the "already being dispatched" exception.
new MessageEvent('message', {
data: event.data,
origin: event.origin,
})
)
)
}
}
private handleMockClose(_event: Event): void {
// Close the original connection if the mock client closes.
if (this.realWebSocket) {
this.realWebSocket.close()
}
}
private handleRealClose(event: CloseEvent): void {
// For closures originating from the original server,
// remove the "close" listener from the mock client.
// original close -> (?) client[kClose]() --X--> "close" (again).
this.mockCloseController.abort()
const closeEvent = bindEvent(
this.realWebSocket,
new CancelableCloseEvent('close', {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
cancelable: true,
})
)
this[kEmitter].dispatchEvent(closeEvent)
// If the close event from the server hasn't been prevented,
// forward the closure to the mock client.
if (!closeEvent.defaultPrevented) {
// Close the intercepted client forcefully to
// allow non-configurable status codes from the server.
// If the socket has been closed by now, no harm calling
// this again—it will have no effect.
this.client[kClose](event.code, event.reason)
}
}
}

View File

@@ -0,0 +1,39 @@
import { CloseEvent } from './utils/events'
export type WebSocketData = string | ArrayBufferLike | Blob | ArrayBufferView
export type WebSocketTransportEventMap = {
incoming: MessageEvent<WebSocketData>
outgoing: MessageEvent<WebSocketData>
close: CloseEvent
}
export type StrictEventListenerOrEventListenerObject<EventType extends Event> =
| ((this: WebSocket, event: EventType) => void)
| {
handleEvent(this: WebSocket, event: EventType): void
}
export interface WebSocketTransport {
addEventListener<EventType extends keyof WebSocketTransportEventMap>(
event: EventType,
listener: StrictEventListenerOrEventListenerObject<
WebSocketTransportEventMap[EventType]
> | null,
options?: boolean | AddEventListenerOptions
): void
dispatchEvent<EventType extends keyof WebSocketTransportEventMap>(
event: WebSocketTransportEventMap[EventType]
): boolean
/**
* Send the data from the server to this client.
*/
send(data: WebSocketData): void
/**
* Close the client connection.
*/
close(code?: number, reason?: string): void
}

View File

@@ -0,0 +1,191 @@
import { Interceptor } from '../../Interceptor'
import {
WebSocketClientConnectionProtocol,
WebSocketClientConnection,
type WebSocketClientEventMap,
} from './WebSocketClientConnection'
import {
WebSocketServerConnectionProtocol,
WebSocketServerConnection,
type WebSocketServerEventMap,
} from './WebSocketServerConnection'
import { WebSocketClassTransport } from './WebSocketClassTransport'
import {
kClose,
kPassthroughPromise,
WebSocketOverride,
} from './WebSocketOverride'
import { bindEvent } from './utils/bindEvent'
import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal'
import { emitAsync } from '../../utils/emitAsync'
export {
type WebSocketData,
type WebSocketTransport,
} from './WebSocketTransport'
export {
WebSocketClientEventMap,
WebSocketClientConnectionProtocol,
WebSocketClientConnection,
WebSocketServerEventMap,
WebSocketServerConnectionProtocol,
WebSocketServerConnection,
}
export {
CloseEvent,
CancelableCloseEvent,
CancelableMessageEvent,
} from './utils/events'
export type WebSocketEventMap = {
connection: [args: WebSocketConnectionData]
}
export type WebSocketConnectionData = {
/**
* The incoming WebSocket client connection.
*/
client: WebSocketClientConnection
/**
* The original WebSocket server connection.
*/
server: WebSocketServerConnection
/**
* The connection information.
*/
info: {
/**
* The protocols supported by the WebSocket client.
*/
protocols: string | Array<string> | undefined
}
}
/**
* Intercept the outgoing WebSocket connections created using
* the global `WebSocket` class.
*/
export class WebSocketInterceptor extends Interceptor<WebSocketEventMap> {
static symbol = Symbol('websocket')
constructor() {
super(WebSocketInterceptor.symbol)
}
protected checkEnvironment(): boolean {
return hasConfigurableGlobal('WebSocket')
}
protected setup(): void {
const originalWebSocketDescriptor = Object.getOwnPropertyDescriptor(
globalThis,
'WebSocket'
)
const WebSocketProxy = new Proxy(globalThis.WebSocket, {
construct: (
target,
args: ConstructorParameters<typeof globalThis.WebSocket>,
newTarget
) => {
const [url, protocols] = args
const createConnection = (): WebSocket => {
return Reflect.construct(target, args, newTarget)
}
// All WebSocket instances are mocked and don't forward
// any events to the original server (no connection established).
// To forward the events, the user must use the "server.send()" API.
const socket = new WebSocketOverride(url, protocols)
const transport = new WebSocketClassTransport(socket)
// Emit the "connection" event to the interceptor on the next tick
// so the client can modify WebSocket options, like "binaryType"
// while the connection is already pending.
queueMicrotask(async () => {
try {
const server = new WebSocketServerConnection(
socket,
transport,
createConnection
)
const hasConnectionListeners =
this.emitter.listenerCount('connection') > 0
// The "globalThis.WebSocket" class stands for
// the client-side connection. Assume it's established
// as soon as the WebSocket instance is constructed.
await emitAsync(this.emitter, 'connection', {
client: new WebSocketClientConnection(socket, transport),
server,
info: {
protocols,
},
})
if (hasConnectionListeners) {
socket[kPassthroughPromise].resolve(false)
} else {
socket[kPassthroughPromise].resolve(true)
server.connect()
// Forward the "open" event from the original server
// to the mock WebSocket client in the case of a passthrough connection.
server.addEventListener('open', () => {
socket.dispatchEvent(bindEvent(socket, new Event('open')))
// Forward the original connection protocol to the
// mock WebSocket client.
if (server['realWebSocket']) {
socket.protocol = server['realWebSocket'].protocol
}
})
}
} catch (error) {
/**
* @note Translate unhandled exceptions during the connection
* handling (i.e. interceptor exceptions) as WebSocket connection
* closures with error. This prevents from the exceptions occurring
* in `queueMicrotask` from being process-wide and uncatchable.
*/
if (error instanceof Error) {
socket.dispatchEvent(new Event('error'))
// No need to close the connection if it's already being closed.
// E.g. the interceptor called `client.close()` and then threw an error.
if (
socket.readyState !== WebSocket.CLOSING &&
socket.readyState !== WebSocket.CLOSED
) {
socket[kClose](1011, error.message, false)
}
console.error(error)
}
}
})
return socket
},
})
Object.defineProperty(globalThis, 'WebSocket', {
value: WebSocketProxy,
configurable: true,
})
this.subscriptions.push(() => {
Object.defineProperty(
globalThis,
'WebSocket',
originalWebSocketDescriptor!
)
})
}
}

View File

@@ -0,0 +1,27 @@
/**
* @vitest-environment node
*/
import { it, expect } from 'vitest'
import { bindEvent } from './bindEvent'
it('sets the "target" on the given event', () => {
class Target {}
const target = new Target()
const event = new Event('open')
bindEvent(target, event)
expect(event.type).toBe('open')
expect(event.target).toEqual(target)
})
it('overrides existing "target" on the given event', () => {
class Target {}
const oldTarget = new Target()
const newTarget = new Target()
const event = new Event('open')
bindEvent(oldTarget, event)
bindEvent(newTarget, event)
expect(event.type).toBe('open')
expect(event.target).toEqual(newTarget)
})

View File

@@ -0,0 +1,21 @@
type EventWithTarget<E extends Event, T> = E & { target: T }
export function bindEvent<E extends Event, T>(
target: T,
event: E
): EventWithTarget<E, T> {
Object.defineProperties(event, {
target: {
value: target,
enumerable: true,
writable: true,
},
currentTarget: {
value: target,
enumerable: true,
writable: true,
},
})
return event as EventWithTarget<E, T>
}

View File

@@ -0,0 +1,101 @@
/**
* @vitest-environment node
*/
import { describe, it, expect } from 'vitest'
import { CancelableMessageEvent, CloseEvent } from './events'
describe(CancelableMessageEvent, () => {
it('initiates with the right defaults', () => {
const event = new CancelableMessageEvent('message', {
data: 'hello',
})
expect(event).toBeInstanceOf(MessageEvent)
expect(event.type).toBe('message')
expect(event.data).toBe('hello')
expect(event.cancelable).toBe(false)
expect(event.defaultPrevented).toBe(false)
})
it('initiates a cancelable event', () => {
const event = new CancelableMessageEvent('message', {
data: 'hello',
cancelable: true,
})
expect(event).toBeInstanceOf(MessageEvent)
expect(event.type).toBe('message')
expect(event.data).toBe('hello')
expect(event.cancelable).toBe(true)
expect(event.defaultPrevented).toBe(false)
})
it('cancels a cancelable event when calling "preventDefault()"', () => {
const event = new CancelableMessageEvent('message', {
data: 'hello',
cancelable: true,
})
expect(event.defaultPrevented).toBe(false)
event.preventDefault()
expect(event.defaultPrevented).toBe(true)
})
it('does nothing when calling "preventDefault()" on a non-cancelable event', () => {
const event = new CancelableMessageEvent('message', {
data: 'hello',
})
expect(event.defaultPrevented).toBe(false)
event.preventDefault()
expect(event.defaultPrevented).toBe(false)
})
it('supports setting the "cancelable" value directly', () => {
const event = new CancelableMessageEvent('message', {})
/**
* @note HappyDOM sets the "cancelable" and "preventDefault"
* event properties directly. That's no-op as far as I know
* but they do it and we have to account for that.
*/
event.cancelable = true
expect(event.cancelable).toBe(true)
})
it('supports setting the "defaultPrevented" value directly', () => {
const event = new CancelableMessageEvent('message', {})
/**
* @note HappyDOM sets the "cancelable" and "preventDefault"
* event properties directly. That's no-op as far as I know
* but they do it and we have to account for that.
*/
event.defaultPrevented = true
expect(event.defaultPrevented).toBe(true)
})
})
describe(CloseEvent, () => {
it('initiates with the right defaults', () => {
const event = new CloseEvent('close')
expect(event).toBeInstanceOf(Event)
expect(event.type).toBe('close')
expect(event.code).toBe(0)
expect(event.reason).toBe('')
expect(event.wasClean).toBe(false)
})
it('initiates with custom values', () => {
const event = new CloseEvent('close', {
code: 1003,
reason: 'close reason',
wasClean: true,
})
expect(event).toBeInstanceOf(Event)
expect(event.type).toBe('close')
expect(event.code).toBe(1003)
expect(event.reason).toBe('close reason')
expect(event.wasClean).toBe(true)
})
})

View File

@@ -0,0 +1,94 @@
const kCancelable = Symbol('kCancelable')
const kDefaultPrevented = Symbol('kDefaultPrevented')
/**
* A `MessageEvent` superset that supports event cancellation
* in Node.js. It's rather non-intrusive so it can be safely
* used in the browser as well.
*
* @see https://github.com/nodejs/node/issues/51767
*/
export class CancelableMessageEvent<T = any> extends MessageEvent<T> {
[kCancelable]: boolean;
[kDefaultPrevented]: boolean
constructor(type: string, init: MessageEventInit<T>) {
super(type, init)
this[kCancelable] = !!init.cancelable
this[kDefaultPrevented] = false
}
get cancelable() {
return this[kCancelable]
}
set cancelable(nextCancelable) {
this[kCancelable] = nextCancelable
}
get defaultPrevented() {
return this[kDefaultPrevented]
}
set defaultPrevented(nextDefaultPrevented) {
this[kDefaultPrevented] = nextDefaultPrevented
}
public preventDefault(): void {
if (this.cancelable && !this[kDefaultPrevented]) {
this[kDefaultPrevented] = true
}
}
}
interface CloseEventInit extends EventInit {
code?: number
reason?: string
wasClean?: boolean
}
export class CloseEvent extends Event {
public code: number
public reason: string
public wasClean: boolean
constructor(type: string, init: CloseEventInit = {}) {
super(type, init)
this.code = init.code === undefined ? 0 : init.code
this.reason = init.reason === undefined ? '' : init.reason
this.wasClean = init.wasClean === undefined ? false : init.wasClean
}
}
export class CancelableCloseEvent extends CloseEvent {
[kCancelable]: boolean;
[kDefaultPrevented]: boolean
constructor(type: string, init: CloseEventInit = {}) {
super(type, init)
this[kCancelable] = !!init.cancelable
this[kDefaultPrevented] = false
}
get cancelable() {
return this[kCancelable]
}
set cancelable(nextCancelable) {
this[kCancelable] = nextCancelable
}
get defaultPrevented() {
return this[kDefaultPrevented]
}
set defaultPrevented(nextDefaultPrevented) {
this[kDefaultPrevented] = nextDefaultPrevented
}
public preventDefault(): void {
if (this.cancelable && !this[kDefaultPrevented]) {
this[kDefaultPrevented] = true
}
}
}

View File

@@ -0,0 +1,746 @@
import { invariant } from 'outvariant'
import { isNodeProcess } from 'is-node-process'
import type { Logger } from '@open-draft/logger'
import { concatArrayBuffer } from './utils/concatArrayBuffer'
import { createEvent } from './utils/createEvent'
import {
decodeBuffer,
encodeBuffer,
toArrayBuffer,
} from '../../utils/bufferUtils'
import { createProxy } from '../../utils/createProxy'
import { isDomParserSupportedType } from './utils/isDomParserSupportedType'
import { parseJson } from '../../utils/parseJson'
import { createResponse } from './utils/createResponse'
import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor'
import { createRequestId } from '../../createRequestId'
import { getBodyByteLength } from './utils/getBodyByteLength'
import { setRawRequest } from '../../getRawRequest'
const kIsRequestHandled = Symbol('kIsRequestHandled')
const IS_NODE = isNodeProcess()
const kFetchRequest = Symbol('kFetchRequest')
/**
* An `XMLHttpRequest` instance controller that allows us
* to handle any given request instance (e.g. responding to it).
*/
export class XMLHttpRequestController {
public request: XMLHttpRequest
public requestId: string
public onRequest?: (
this: XMLHttpRequestController,
args: {
request: Request
requestId: string
}
) => Promise<void>
public onResponse?: (
this: XMLHttpRequestController,
args: {
response: Response
isMockedResponse: boolean
request: Request
requestId: string
}
) => void;
[kIsRequestHandled]: boolean;
[kFetchRequest]?: Request
private method: string = 'GET'
private url: URL = null as any
private requestHeaders: Headers
private responseBuffer: Uint8Array
private events: Map<keyof XMLHttpRequestEventTargetEventMap, Array<Function>>
private uploadEvents: Map<
keyof XMLHttpRequestEventTargetEventMap,
Array<Function>
>
constructor(
readonly initialRequest: XMLHttpRequest,
public logger: Logger
) {
this[kIsRequestHandled] = false
this.events = new Map()
this.uploadEvents = new Map()
this.requestId = createRequestId()
this.requestHeaders = new Headers()
this.responseBuffer = new Uint8Array()
this.request = createProxy(initialRequest, {
setProperty: ([propertyName, nextValue], invoke) => {
switch (propertyName) {
case 'ontimeout': {
const eventName = propertyName.slice(
2
) as keyof XMLHttpRequestEventTargetEventMap
/**
* @note Proxy callbacks to event listeners because JSDOM has trouble
* translating these properties to callbacks. It seemed to be operating
* on events exclusively.
*/
this.request.addEventListener(eventName, nextValue as any)
return invoke()
}
default: {
return invoke()
}
}
},
methodCall: ([methodName, args], invoke) => {
switch (methodName) {
case 'open': {
const [method, url] = args as [string, string | undefined]
if (typeof url === 'undefined') {
this.method = 'GET'
this.url = toAbsoluteUrl(method)
} else {
this.method = method
this.url = toAbsoluteUrl(url)
}
this.logger = this.logger.extend(`${this.method} ${this.url.href}`)
this.logger.info('open', this.method, this.url.href)
return invoke()
}
case 'addEventListener': {
const [eventName, listener] = args as [
keyof XMLHttpRequestEventTargetEventMap,
Function,
]
this.registerEvent(eventName, listener)
this.logger.info('addEventListener', eventName, listener)
return invoke()
}
case 'setRequestHeader': {
const [name, value] = args as [string, string]
this.requestHeaders.set(name, value)
this.logger.info('setRequestHeader', name, value)
return invoke()
}
case 'send': {
const [body] = args as [
body?: XMLHttpRequestBodyInit | Document | null,
]
this.request.addEventListener('load', () => {
if (typeof this.onResponse !== 'undefined') {
// Create a Fetch API Response representation of whichever
// response this XMLHttpRequest received. Note those may
// be either a mocked and the original response.
const fetchResponse = createResponse(
this.request,
/**
* The `response` property is the right way to read
* the ambiguous response body, as the request's "responseType" may differ.
* @see https://xhr.spec.whatwg.org/#the-response-attribute
*/
this.request.response
)
// Notify the consumer about the response.
this.onResponse.call(this, {
response: fetchResponse,
isMockedResponse: this[kIsRequestHandled],
request: fetchRequest,
requestId: this.requestId!,
})
}
})
const requestBody =
typeof body === 'string' ? encodeBuffer(body) : body
// Delegate request handling to the consumer.
const fetchRequest = this.toFetchApiRequest(requestBody)
this[kFetchRequest] = fetchRequest.clone()
/**
* @note Start request handling on the next tick so that the user
* could add event listeners for "loadend" before the interceptor fires it.
*/
queueMicrotask(() => {
const onceRequestSettled =
this.onRequest?.call(this, {
request: fetchRequest,
requestId: this.requestId!,
}) || Promise.resolve()
onceRequestSettled.finally(() => {
// If the consumer didn't handle the request (called `.respondWith()`) perform it as-is.
if (!this[kIsRequestHandled]) {
this.logger.info(
'request callback settled but request has not been handled (readystate %d), performing as-is...',
this.request.readyState
)
/**
* @note Set the intercepted request ID on the original request in Node.js
* so that if it triggers any other interceptors, they don't attempt
* to process it once again.
*
* For instance, XMLHttpRequest is often implemented via "http.ClientRequest"
* and we don't want for both XHR and ClientRequest interceptors to
* handle the same request at the same time (e.g. emit the "response" event twice).
*/
if (IS_NODE) {
this.request.setRequestHeader(
INTERNAL_REQUEST_ID_HEADER_NAME,
this.requestId!
)
}
return invoke()
}
})
})
break
}
default: {
return invoke()
}
}
},
})
/**
* Proxy the `.upload` property to gather the event listeners/callbacks.
*/
define(
this.request,
'upload',
createProxy(this.request.upload, {
setProperty: ([propertyName, nextValue], invoke) => {
switch (propertyName) {
case 'onloadstart':
case 'onprogress':
case 'onaboart':
case 'onerror':
case 'onload':
case 'ontimeout':
case 'onloadend': {
const eventName = propertyName.slice(
2
) as keyof XMLHttpRequestEventTargetEventMap
this.registerUploadEvent(eventName, nextValue as Function)
}
}
return invoke()
},
methodCall: ([methodName, args], invoke) => {
switch (methodName) {
case 'addEventListener': {
const [eventName, listener] = args as [
keyof XMLHttpRequestEventTargetEventMap,
Function,
]
this.registerUploadEvent(eventName, listener)
this.logger.info('upload.addEventListener', eventName, listener)
return invoke()
}
}
},
})
)
}
private registerEvent(
eventName: keyof XMLHttpRequestEventTargetEventMap,
listener: Function
): void {
const prevEvents = this.events.get(eventName) || []
const nextEvents = prevEvents.concat(listener)
this.events.set(eventName, nextEvents)
this.logger.info('registered event "%s"', eventName, listener)
}
private registerUploadEvent(
eventName: keyof XMLHttpRequestEventTargetEventMap,
listener: Function
): void {
const prevEvents = this.uploadEvents.get(eventName) || []
const nextEvents = prevEvents.concat(listener)
this.uploadEvents.set(eventName, nextEvents)
this.logger.info('registered upload event "%s"', eventName, listener)
}
/**
* Responds to the current request with the given
* Fetch API `Response` instance.
*/
public async respondWith(response: Response): Promise<void> {
/**
* @note Since `XMLHttpRequestController` delegates the handling of the responses
* to the "load" event listener that doesn't distinguish between the mocked and original
* responses, mark the request that had a mocked response with a corresponding symbol.
*
* Mark this request as having a mocked response immediately since
* calculating request/response total body length is asynchronous.
*/
this[kIsRequestHandled] = true
/**
* Dispatch request upload events for requests with a body.
* @see https://github.com/mswjs/interceptors/issues/573
*/
if (this[kFetchRequest]) {
const totalRequestBodyLength = await getBodyByteLength(
this[kFetchRequest]
)
this.trigger('loadstart', this.request.upload, {
loaded: 0,
total: totalRequestBodyLength,
})
this.trigger('progress', this.request.upload, {
loaded: totalRequestBodyLength,
total: totalRequestBodyLength,
})
this.trigger('load', this.request.upload, {
loaded: totalRequestBodyLength,
total: totalRequestBodyLength,
})
this.trigger('loadend', this.request.upload, {
loaded: totalRequestBodyLength,
total: totalRequestBodyLength,
})
}
this.logger.info(
'responding with a mocked response: %d %s',
response.status,
response.statusText
)
define(this.request, 'status', response.status)
define(this.request, 'statusText', response.statusText)
define(this.request, 'responseURL', this.url.href)
this.request.getResponseHeader = new Proxy(this.request.getResponseHeader, {
apply: (_, __, args: [name: string]) => {
this.logger.info('getResponseHeader', args[0])
if (this.request.readyState < this.request.HEADERS_RECEIVED) {
this.logger.info('headers not received yet, returning null')
// Headers not received yet, nothing to return.
return null
}
const headerValue = response.headers.get(args[0])
this.logger.info(
'resolved response header "%s" to',
args[0],
headerValue
)
return headerValue
},
})
this.request.getAllResponseHeaders = new Proxy(
this.request.getAllResponseHeaders,
{
apply: () => {
this.logger.info('getAllResponseHeaders')
if (this.request.readyState < this.request.HEADERS_RECEIVED) {
this.logger.info('headers not received yet, returning empty string')
// Headers not received yet, nothing to return.
return ''
}
const headersList = Array.from(response.headers.entries())
const allHeaders = headersList
.map(([headerName, headerValue]) => {
return `${headerName}: ${headerValue}`
})
.join('\r\n')
this.logger.info('resolved all response headers to', allHeaders)
return allHeaders
},
}
)
// Update the response getters to resolve against the mocked response.
Object.defineProperties(this.request, {
response: {
enumerable: true,
configurable: false,
get: () => this.response,
},
responseText: {
enumerable: true,
configurable: false,
get: () => this.responseText,
},
responseXML: {
enumerable: true,
configurable: false,
get: () => this.responseXML,
},
})
const totalResponseBodyLength = await getBodyByteLength(response.clone())
this.logger.info('calculated response body length', totalResponseBodyLength)
this.trigger('loadstart', this.request, {
loaded: 0,
total: totalResponseBodyLength,
})
this.setReadyState(this.request.HEADERS_RECEIVED)
this.setReadyState(this.request.LOADING)
const finalizeResponse = () => {
this.logger.info('finalizing the mocked response...')
this.setReadyState(this.request.DONE)
this.trigger('load', this.request, {
loaded: this.responseBuffer.byteLength,
total: totalResponseBodyLength,
})
this.trigger('loadend', this.request, {
loaded: this.responseBuffer.byteLength,
total: totalResponseBodyLength,
})
}
if (response.body) {
this.logger.info('mocked response has body, streaming...')
const reader = response.body.getReader()
const readNextResponseBodyChunk = async () => {
const { value, done } = await reader.read()
if (done) {
this.logger.info('response body stream done!')
finalizeResponse()
return
}
if (value) {
this.logger.info('read response body chunk:', value)
this.responseBuffer = concatArrayBuffer(this.responseBuffer, value)
this.trigger('progress', this.request, {
loaded: this.responseBuffer.byteLength,
total: totalResponseBodyLength,
})
}
readNextResponseBodyChunk()
}
readNextResponseBodyChunk()
} else {
finalizeResponse()
}
}
private responseBufferToText(): string {
return decodeBuffer(this.responseBuffer)
}
get response(): unknown {
this.logger.info(
'getResponse (responseType: %s)',
this.request.responseType
)
if (this.request.readyState !== this.request.DONE) {
return null
}
switch (this.request.responseType) {
case 'json': {
const responseJson = parseJson(this.responseBufferToText())
this.logger.info('resolved response JSON', responseJson)
return responseJson
}
case 'arraybuffer': {
const arrayBuffer = toArrayBuffer(this.responseBuffer)
this.logger.info('resolved response ArrayBuffer', arrayBuffer)
return arrayBuffer
}
case 'blob': {
const mimeType =
this.request.getResponseHeader('Content-Type') || 'text/plain'
const responseBlob = new Blob([this.responseBufferToText()], {
type: mimeType,
})
this.logger.info(
'resolved response Blob (mime type: %s)',
responseBlob,
mimeType
)
return responseBlob
}
default: {
const responseText = this.responseBufferToText()
this.logger.info(
'resolving "%s" response type as text',
this.request.responseType,
responseText
)
return responseText
}
}
}
get responseText(): string {
/**
* Throw when trying to read the response body as text when the
* "responseType" doesn't expect text. This just respects the spec better.
* @see https://xhr.spec.whatwg.org/#the-responsetext-attribute
*/
invariant(
this.request.responseType === '' || this.request.responseType === 'text',
'InvalidStateError: The object is in invalid state.'
)
if (
this.request.readyState !== this.request.LOADING &&
this.request.readyState !== this.request.DONE
) {
return ''
}
const responseText = this.responseBufferToText()
this.logger.info('getResponseText: "%s"', responseText)
return responseText
}
get responseXML(): Document | null {
invariant(
this.request.responseType === '' ||
this.request.responseType === 'document',
'InvalidStateError: The object is in invalid state.'
)
if (this.request.readyState !== this.request.DONE) {
return null
}
const contentType = this.request.getResponseHeader('Content-Type') || ''
if (typeof DOMParser === 'undefined') {
console.warn(
'Cannot retrieve XMLHttpRequest response body as XML: DOMParser is not defined. You are likely using an environment that is not browser or does not polyfill browser globals correctly.'
)
return null
}
if (isDomParserSupportedType(contentType)) {
return new DOMParser().parseFromString(
this.responseBufferToText(),
contentType
)
}
return null
}
public errorWith(error?: Error): void {
/**
* @note Mark this request as handled even if it received a mock error.
* This prevents the controller from trying to perform this request as-is.
*/
this[kIsRequestHandled] = true
this.logger.info('responding with an error')
this.setReadyState(this.request.DONE)
this.trigger('error', this.request)
this.trigger('loadend', this.request)
}
/**
* Transitions this request's `readyState` to the given one.
*/
private setReadyState(nextReadyState: number): void {
this.logger.info(
'setReadyState: %d -> %d',
this.request.readyState,
nextReadyState
)
if (this.request.readyState === nextReadyState) {
this.logger.info('ready state identical, skipping transition...')
return
}
define(this.request, 'readyState', nextReadyState)
this.logger.info('set readyState to: %d', nextReadyState)
if (nextReadyState !== this.request.UNSENT) {
this.logger.info('triggering "readystatechange" event...')
this.trigger('readystatechange', this.request)
}
}
/**
* Triggers given event on the `XMLHttpRequest` instance.
*/
private trigger<
EventName extends keyof (XMLHttpRequestEventTargetEventMap & {
readystatechange: ProgressEvent<XMLHttpRequestEventTarget>
}),
>(
eventName: EventName,
target: XMLHttpRequest | XMLHttpRequestUpload,
options?: ProgressEventInit
): void {
const callback = (target as XMLHttpRequest)[`on${eventName}`]
const event = createEvent(target, eventName, options)
this.logger.info('trigger "%s"', eventName, options || '')
// Invoke direct callbacks.
if (typeof callback === 'function') {
this.logger.info('found a direct "%s" callback, calling...', eventName)
callback.call(target as XMLHttpRequest, event)
}
// Invoke event listeners.
const events =
target instanceof XMLHttpRequestUpload ? this.uploadEvents : this.events
for (const [registeredEventName, listeners] of events) {
if (registeredEventName === eventName) {
this.logger.info(
'found %d listener(s) for "%s" event, calling...',
listeners.length,
eventName
)
listeners.forEach((listener) => listener.call(target, event))
}
}
}
/**
* Converts this `XMLHttpRequest` instance into a Fetch API `Request` instance.
*/
private toFetchApiRequest(
body: XMLHttpRequestBodyInit | Document | null | undefined
): Request {
this.logger.info('converting request to a Fetch API Request...')
// If the `Document` is used as the body of this XMLHttpRequest,
// set its inner text as the Fetch API Request body.
const resolvedBody =
body instanceof Document ? body.documentElement.innerText : body
const fetchRequest = new Request(this.url.href, {
method: this.method,
headers: this.requestHeaders,
/**
* @see https://xhr.spec.whatwg.org/#cross-origin-credentials
*/
credentials: this.request.withCredentials ? 'include' : 'same-origin',
body: ['GET', 'HEAD'].includes(this.method.toUpperCase())
? null
: resolvedBody,
})
const proxyHeaders = createProxy(fetchRequest.headers, {
methodCall: ([methodName, args], invoke) => {
// Forward the latest state of the internal request headers
// because the interceptor might have modified them
// without responding to the request.
switch (methodName) {
case 'append':
case 'set': {
const [headerName, headerValue] = args as [string, string]
this.request.setRequestHeader(headerName, headerValue)
break
}
case 'delete': {
const [headerName] = args as [string]
console.warn(
`XMLHttpRequest: Cannot remove a "${headerName}" header from the Fetch API representation of the "${fetchRequest.method} ${fetchRequest.url}" request. XMLHttpRequest headers cannot be removed.`
)
break
}
}
return invoke()
},
})
define(fetchRequest, 'headers', proxyHeaders)
setRawRequest(fetchRequest, this.request)
this.logger.info('converted request to a Fetch API Request!', fetchRequest)
return fetchRequest
}
}
function toAbsoluteUrl(url: string | URL): URL {
/**
* @note XMLHttpRequest interceptor may run in environments
* that implement XMLHttpRequest but don't implement "location"
* (for example, React Native). If that's the case, return the
* input URL as-is (nothing to be relative to).
* @see https://github.com/mswjs/msw/issues/1777
*/
if (typeof location === 'undefined') {
return new URL(url)
}
return new URL(url.toString(), location.href)
}
function define(
target: object,
property: string | symbol,
value: unknown
): void {
Reflect.defineProperty(target, property, {
// Ensure writable properties to allow redefining readonly properties.
writable: true,
enumerable: true,
value,
})
}

View File

@@ -0,0 +1,121 @@
import type { Logger } from '@open-draft/logger'
import { XMLHttpRequestEmitter } from '.'
import { RequestController } from '../../RequestController'
import { XMLHttpRequestController } from './XMLHttpRequestController'
import { handleRequest } from '../../utils/handleRequest'
import { isResponseError } from '../../utils/responseUtils'
export interface XMLHttpRequestProxyOptions {
emitter: XMLHttpRequestEmitter
logger: Logger
}
/**
* Create a proxied `XMLHttpRequest` class.
* The proxied class establishes spies on certain methods,
* allowing us to intercept requests and respond to them.
*/
export function createXMLHttpRequestProxy({
emitter,
logger,
}: XMLHttpRequestProxyOptions) {
const XMLHttpRequestProxy = new Proxy(globalThis.XMLHttpRequest, {
construct(target, args, newTarget) {
logger.info('constructed new XMLHttpRequest')
const originalRequest = Reflect.construct(
target,
args,
newTarget
) as XMLHttpRequest
/**
* @note Forward prototype descriptors onto the proxied object.
* XMLHttpRequest is implemented in JSDOM in a way that assigns
* a bunch of descriptors, like "set responseType()" on the prototype.
* With this propagation, we make sure that those descriptors trigger
* when the user operates with the proxied request instance.
*/
const prototypeDescriptors = Object.getOwnPropertyDescriptors(
target.prototype
)
for (const propertyName in prototypeDescriptors) {
Reflect.defineProperty(
originalRequest,
propertyName,
prototypeDescriptors[propertyName]
)
}
const xhrRequestController = new XMLHttpRequestController(
originalRequest,
logger
)
xhrRequestController.onRequest = async function ({ request, requestId }) {
const controller = new RequestController(request, {
passthrough: () => {
this.logger.info(
'no mocked response received, performing request as-is...'
)
},
respondWith: async (response) => {
if (isResponseError(response)) {
this.errorWith(new TypeError('Network error'))
return
}
await this.respondWith(response)
},
errorWith: (reason) => {
this.logger.info('request errored!', { error: reason })
if (reason instanceof Error) {
this.errorWith(reason)
}
},
})
this.logger.info('awaiting mocked response...')
this.logger.info(
'emitting the "request" event for %s listener(s)...',
emitter.listenerCount('request')
)
await handleRequest({
request,
requestId,
controller,
emitter,
})
}
xhrRequestController.onResponse = async function ({
response,
isMockedResponse,
request,
requestId,
}) {
this.logger.info(
'emitting the "response" event for %s listener(s)...',
emitter.listenerCount('response')
)
emitter.emit('response', {
response,
isMockedResponse,
request,
requestId,
})
}
// Return the proxied request from the controller
// so that the controller can react to the consumer's interactions
// with this request (opening/sending/etc).
return xhrRequestController.request
},
})
return XMLHttpRequestProxy
}

View File

@@ -0,0 +1,61 @@
import { invariant } from 'outvariant'
import { Emitter } from 'strict-event-emitter'
import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary'
import { Interceptor } from '../../Interceptor'
import { createXMLHttpRequestProxy } from './XMLHttpRequestProxy'
import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal'
export type XMLHttpRequestEmitter = Emitter<HttpRequestEventMap>
export class XMLHttpRequestInterceptor extends Interceptor<HttpRequestEventMap> {
static interceptorSymbol = Symbol('xhr')
constructor() {
super(XMLHttpRequestInterceptor.interceptorSymbol)
}
protected checkEnvironment() {
return hasConfigurableGlobal('XMLHttpRequest')
}
protected setup() {
const logger = this.logger.extend('setup')
logger.info('patching "XMLHttpRequest" module...')
const PureXMLHttpRequest = globalThis.XMLHttpRequest
invariant(
!(PureXMLHttpRequest as any)[IS_PATCHED_MODULE],
'Failed to patch the "XMLHttpRequest" module: already patched.'
)
globalThis.XMLHttpRequest = createXMLHttpRequestProxy({
emitter: this.emitter,
logger: this.logger,
})
logger.info(
'native "XMLHttpRequest" module patched!',
globalThis.XMLHttpRequest.name
)
Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, {
enumerable: true,
configurable: true,
value: true,
})
this.subscriptions.push(() => {
Object.defineProperty(globalThis.XMLHttpRequest, IS_PATCHED_MODULE, {
value: undefined,
})
globalThis.XMLHttpRequest = PureXMLHttpRequest
logger.info(
'native "XMLHttpRequest" module restored!',
globalThis.XMLHttpRequest.name
)
})
}
}

View File

@@ -0,0 +1,51 @@
export class EventPolyfill implements Event {
readonly NONE = 0
readonly CAPTURING_PHASE = 1
readonly AT_TARGET = 2
readonly BUBBLING_PHASE = 3
public type: string = ''
public srcElement: EventTarget | null = null
public target: EventTarget | null
public currentTarget: EventTarget | null = null
public eventPhase: number = 0
public timeStamp: number
public isTrusted: boolean = true
public composed: boolean = false
public cancelable: boolean = true
public defaultPrevented: boolean = false
public bubbles: boolean = true
public lengthComputable: boolean = true
public loaded: number = 0
public total: number = 0
cancelBubble: boolean = false
returnValue: boolean = true
constructor(
type: string,
options?: { target: EventTarget; currentTarget: EventTarget }
) {
this.type = type
this.target = options?.target || null
this.currentTarget = options?.currentTarget || null
this.timeStamp = Date.now()
}
public composedPath(): EventTarget[] {
return []
}
public initEvent(type: string, bubbles?: boolean, cancelable?: boolean) {
this.type = type
this.bubbles = !!bubbles
this.cancelable = !!cancelable
}
public preventDefault() {
this.defaultPrevented = true
}
public stopPropagation() {}
public stopImmediatePropagation() {}
}

View File

@@ -0,0 +1,17 @@
import { EventPolyfill } from './EventPolyfill'
export class ProgressEventPolyfill extends EventPolyfill {
readonly lengthComputable: boolean
readonly composed: boolean
readonly loaded: number
readonly total: number
constructor(type: string, init?: ProgressEventInit) {
super(type)
this.lengthComputable = init?.lengthComputable || false
this.composed = init?.composed || false
this.loaded = init?.loaded || 0
this.total = init?.total || 0
}
}

View File

@@ -0,0 +1,12 @@
/**
* Concatenate two `Uint8Array` buffers.
*/
export function concatArrayBuffer(
left: Uint8Array,
right: Uint8Array
): Uint8Array {
const result = new Uint8Array(left.byteLength + right.byteLength)
result.set(left, 0)
result.set(right, left.byteLength)
return result
}

View File

@@ -0,0 +1,12 @@
import { it, expect } from 'vitest'
import { concatArrayBuffer } from './concatArrayBuffer'
const encoder = new TextEncoder()
it('concatenates two Uint8Array buffers', () => {
const result = concatArrayBuffer(
encoder.encode('hello'),
encoder.encode('world')
)
expect(result).toEqual(encoder.encode('helloworld'))
})

View File

@@ -0,0 +1,26 @@
// @vitest-environment jsdom
import { it, expect } from 'vitest'
import { createEvent } from './createEvent'
import { EventPolyfill } from '../polyfills/EventPolyfill'
const request = new XMLHttpRequest()
request.open('POST', '/user')
it('returns an EventPolyfill instance with the given target set', () => {
const event = createEvent(request, 'my-event')
const target = event.target as XMLHttpRequest
expect(event).toBeInstanceOf(EventPolyfill)
expect(target).toBeInstanceOf(XMLHttpRequest)
})
it('returns the ProgressEvent instance', () => {
const event = createEvent(request, 'load', {
loaded: 100,
total: 500,
})
expect(event).toBeInstanceOf(ProgressEvent)
expect(event.loaded).toBe(100)
expect(event.total).toBe(500)
})

View File

@@ -0,0 +1,41 @@
import { EventPolyfill } from '../polyfills/EventPolyfill'
import { ProgressEventPolyfill } from '../polyfills/ProgressEventPolyfill'
const SUPPORTS_PROGRESS_EVENT = typeof ProgressEvent !== 'undefined'
export function createEvent(
target: XMLHttpRequest | XMLHttpRequestUpload,
type: string,
init?: ProgressEventInit
): EventPolyfill | ProgressEvent {
const progressEvents = [
'error',
'progress',
'loadstart',
'loadend',
'load',
'timeout',
'abort',
]
/**
* `ProgressEvent` is not supported in React Native.
* @see https://github.com/mswjs/interceptors/issues/40
*/
const ProgressEventClass = SUPPORTS_PROGRESS_EVENT
? ProgressEvent
: ProgressEventPolyfill
const event = progressEvents.includes(type)
? new ProgressEventClass(type, {
lengthComputable: true,
loaded: init?.loaded || 0,
total: init?.total || 0,
})
: new EventPolyfill(type, {
target,
currentTarget: target,
})
return event
}

View File

@@ -0,0 +1,49 @@
import { FetchResponse } from '../../../utils/fetchUtils'
/**
* Creates a Fetch API `Response` instance from the given
* `XMLHttpRequest` instance and a response body.
*/
export function createResponse(
request: XMLHttpRequest,
body: BodyInit | null
): Response {
/**
* Handle XMLHttpRequest responses that must have null as the
* response body when represented using Fetch API Response.
* XMLHttpRequest response will always have an empty string
* as the "request.response" in those cases, resulting in an error
* when constructing a Response instance.
* @see https://github.com/mswjs/interceptors/issues/379
*/
const responseBodyOrNull = FetchResponse.isResponseWithBody(request.status)
? body
: null
return new FetchResponse(responseBodyOrNull, {
url: request.responseURL,
status: request.status,
statusText: request.statusText,
headers: createHeadersFromXMLHttpRequestHeaders(
request.getAllResponseHeaders()
),
})
}
function createHeadersFromXMLHttpRequestHeaders(headersString: string): Headers {
const headers = new Headers()
const lines = headersString.split(/[\r\n]+/)
for (const line of lines) {
if (line.trim() === '') {
continue
}
const [name, ...parts] = line.split(': ')
const value = parts.join(': ')
headers.append(name, value)
}
return headers
}

View File

@@ -0,0 +1,164 @@
// @vitest-environment node
import { it, expect } from 'vitest'
import { getBodyByteLength } from './getBodyByteLength'
const url = 'http://localhost'
it('returns explicit body length set in the "Content-Length" header', async () => {
await expect(
getBodyByteLength(new Request(url, { headers: { 'Content-Length': '10' } }))
).resolves.toBe(10)
await expect(
getBodyByteLength(
new Response('hello', { headers: { 'Content-Length': '5' } })
)
).resolves.toBe(5)
})
/**
* Request.
*/
it('returns 0 for a request with an empty body', async () => {
await expect(getBodyByteLength(new Request(url))).resolves.toBe(0)
await expect(
getBodyByteLength(new Request(url, { method: 'POST', body: null }))
).resolves.toBe(0)
await expect(
getBodyByteLength(new Request(url, { method: 'POST', body: undefined }))
).resolves.toBe(0)
await expect(
getBodyByteLength(new Request(url, { method: 'POST', body: '' }))
).resolves.toBe(0)
})
it('calculates body length from the text request body', async () => {
await expect(
getBodyByteLength(
new Request(url, {
method: 'POST',
body: 'hello world',
})
)
).resolves.toBe(11)
})
it('calculates body length from the URLSearchParams request body', async () => {
await expect(
getBodyByteLength(
new Request(url, {
method: 'POST',
body: new URLSearchParams([['hello', 'world']]),
})
)
).resolves.toBe(11)
})
it('calculates body length from the Blob request body', async () => {
await expect(
getBodyByteLength(
new Request(url, {
method: 'POST',
body: new Blob(['hello world']),
})
)
).resolves.toBe(11)
})
it('calculates body length from the ArrayBuffer request body', async () => {
await expect(
getBodyByteLength(
new Request(url, {
method: 'POST',
body: await new Blob(['hello world']).arrayBuffer(),
})
)
).resolves.toBe(11)
})
it('calculates body length from the FormData request body', async () => {
const formData = new FormData()
formData.append('hello', 'world')
await expect(
getBodyByteLength(
new Request(url, {
method: 'POST',
body: formData,
})
)
).resolves.toBe(129)
})
it('calculates body length from the ReadableStream request body', async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('hello world'))
controller.close()
},
})
await expect(
getBodyByteLength(
new Request(url, {
method: 'POST',
body: stream,
// @ts-expect-error Undocumented required Undici property.
duplex: 'half',
})
)
).resolves.toBe(11)
})
/**
* Response.
*/
it('returns 0 for a response with an empty body', async () => {
await expect(getBodyByteLength(new Response())).resolves.toBe(0)
await expect(getBodyByteLength(new Response(null))).resolves.toBe(0)
await expect(getBodyByteLength(new Response(undefined))).resolves.toBe(0)
await expect(getBodyByteLength(new Response(''))).resolves.toBe(0)
})
it('calculates body length from the text response body', async () => {
await expect(getBodyByteLength(new Response('hello world'))).resolves.toBe(11)
})
it('calculates body length from the URLSearchParams response body', async () => {
await expect(
getBodyByteLength(new Response(new URLSearchParams([['hello', 'world']])))
).resolves.toBe(11)
})
it('calculates body length from the Blob response body', async () => {
await expect(
getBodyByteLength(new Response(new Blob(['hello world'])))
).resolves.toBe(11)
})
it('calculates body length from the ArrayBuffer response body', async () => {
await expect(
getBodyByteLength(
new Response(await new Blob(['hello world']).arrayBuffer())
)
).resolves.toBe(11)
})
it('calculates body length from the FormData response body', async () => {
const formData = new FormData()
formData.append('hello', 'world')
await expect(getBodyByteLength(new Response(formData))).resolves.toBe(129)
})
it('calculates body length from the ReadableStream response body', async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('hello world'))
controller.close()
},
})
await expect(getBodyByteLength(new Response(stream))).resolves.toBe(11)
})

View File

@@ -0,0 +1,16 @@
/**
* Return a total byte length of the given request/response body.
* If the `Content-Length` header is present, it will be used as the byte length.
*/
export async function getBodyByteLength(
input: Request | Response
): Promise<number> {
const explicitContentLength = input.headers.get('content-length')
if (explicitContentLength != null && explicitContentLength !== '') {
return Number(explicitContentLength)
}
const buffer = await input.arrayBuffer()
return buffer.byteLength
}

View File

@@ -0,0 +1,14 @@
export function isDomParserSupportedType(
type: string
): type is DOMParserSupportedType {
const supportedTypes: Array<DOMParserSupportedType> = [
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]
return supportedTypes.some((supportedType) => {
return type.startsWith(supportedType)
})
}

View File

@@ -0,0 +1,214 @@
import { invariant } from 'outvariant'
import { until } from '@open-draft/until'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary'
import { Interceptor } from '../../Interceptor'
import { RequestController } from '../../RequestController'
import { emitAsync } from '../../utils/emitAsync'
import { handleRequest } from '../../utils/handleRequest'
import { canParseUrl } from '../../utils/canParseUrl'
import { createRequestId } from '../../createRequestId'
import { createNetworkError } from './utils/createNetworkError'
import { followFetchRedirect } from './utils/followRedirect'
import { decompressResponse } from './utils/decompression'
import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal'
import { FetchResponse } from '../../utils/fetchUtils'
import { setRawRequest } from '../../getRawRequest'
import { isResponseError } from '../../utils/responseUtils'
export class FetchInterceptor extends Interceptor<HttpRequestEventMap> {
static symbol = Symbol('fetch')
constructor() {
super(FetchInterceptor.symbol)
}
protected checkEnvironment() {
return hasConfigurableGlobal('fetch')
}
protected async setup() {
const pureFetch = globalThis.fetch
invariant(
!(pureFetch as any)[IS_PATCHED_MODULE],
'Failed to patch the "fetch" module: already patched.'
)
globalThis.fetch = async (input, init) => {
const requestId = createRequestId()
/**
* @note Resolve potentially relative request URL
* against the present `location`. This is mainly
* for native `fetch` in JSDOM.
* @see https://github.com/mswjs/msw/issues/1625
*/
const resolvedInput =
typeof input === 'string' &&
typeof location !== 'undefined' &&
!canParseUrl(input)
? new URL(input, location.href)
: input
const request = new Request(resolvedInput, init)
/**
* @note Set the raw request only if a Request instance was provided to fetch.
*/
if (input instanceof Request) {
setRawRequest(request, input)
}
const responsePromise = new DeferredPromise<Response>()
const controller = new RequestController(request, {
passthrough: async () => {
this.logger.info('request has not been handled, passthrough...')
/**
* @note Clone the request instance right before performing it.
* This preserves any modifications made to the intercepted request
* in the "request" listener. This also allows the user to read the
* request body in the "response" listener (otherwise "unusable").
*/
const requestCloneForResponseEvent = request.clone()
// Perform the intercepted request as-is.
const { error: responseError, data: originalResponse } = await until(
() => pureFetch(request)
)
if (responseError) {
return responsePromise.reject(responseError)
}
this.logger.info('original fetch performed', originalResponse)
if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')
const responseClone = originalResponse.clone()
await emitAsync(this.emitter, 'response', {
response: responseClone,
isMockedResponse: false,
request: requestCloneForResponseEvent,
requestId,
})
}
// Resolve the response promise with the original response
// since the `fetch()` return this internal promise.
responsePromise.resolve(originalResponse)
},
respondWith: async (rawResponse) => {
// Handle mocked `Response.error()` (i.e. request errors).
if (isResponseError(rawResponse)) {
this.logger.info('request has errored!', { response: rawResponse })
responsePromise.reject(createNetworkError(rawResponse))
return
}
this.logger.info('received mocked response!', {
rawResponse,
})
// Decompress the mocked response body, if applicable.
const decompressedStream = decompressResponse(rawResponse)
const response =
decompressedStream === null
? rawResponse
: new FetchResponse(decompressedStream, rawResponse)
FetchResponse.setUrl(request.url, response)
/**
* Undici's handling of following redirect responses.
* Treat the "manual" redirect mode as a regular mocked response.
* This way, the client can manually follow the redirect it receives.
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173
*/
if (FetchResponse.isRedirectResponse(response.status)) {
// Reject the request promise if its `redirect` is set to `error`
// and it receives a mocked redirect response.
if (request.redirect === 'error') {
responsePromise.reject(createNetworkError('unexpected redirect'))
return
}
if (request.redirect === 'follow') {
followFetchRedirect(request, response).then(
(response) => {
responsePromise.resolve(response)
},
(reason) => {
responsePromise.reject(reason)
}
)
return
}
}
if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')
// Await the response listeners to finish before resolving
// the response promise. This ensures all your logic finishes
// before the interceptor resolves the pending response.
await emitAsync(this.emitter, 'response', {
// Clone the mocked response for the "response" event listener.
// This way, the listener can read the response and not lock its body
// for the actual fetch consumer.
response: response.clone(),
isMockedResponse: true,
request,
requestId,
})
}
responsePromise.resolve(response)
},
errorWith: (reason) => {
this.logger.info('request has been aborted!', { reason })
responsePromise.reject(reason)
},
})
this.logger.info('[%s] %s', request.method, request.url)
this.logger.info('awaiting for the mocked response...')
this.logger.info(
'emitting the "request" event for %s listener(s)...',
this.emitter.listenerCount('request')
)
await handleRequest({
request,
requestId,
emitter: this.emitter,
controller,
})
return responsePromise
}
Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, {
enumerable: true,
configurable: true,
value: true,
})
this.subscriptions.push(() => {
Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, {
value: undefined,
})
globalThis.fetch = pureFetch
this.logger.info(
'restored native "globalThis.fetch"!',
globalThis.fetch.name
)
})
}
}

View File

@@ -0,0 +1,14 @@
export class BrotliDecompressionStream extends TransformStream {
constructor() {
console.warn(
'[Interceptors]: Brotli decompression of response streams is not supported in the browser'
)
super({
transform(chunk, controller) {
// Keep the stream as passthrough, it does nothing.
controller.enqueue(chunk)
},
})
}
}

View File

@@ -0,0 +1,31 @@
import zlib from 'node:zlib'
export class BrotliDecompressionStream extends TransformStream {
constructor() {
const decompress = zlib.createBrotliDecompress({
flush: zlib.constants.BROTLI_OPERATION_FLUSH,
finishFlush: zlib.constants.BROTLI_OPERATION_FLUSH,
})
super({
async transform(chunk, controller) {
const buffer = Buffer.from(chunk)
const decompressed = await new Promise<Buffer>((resolve, reject) => {
decompress.write(buffer, (error) => {
if (error) reject(error)
})
decompress.flush()
decompress.once('data', (data) => resolve(data))
decompress.once('error', (error) => reject(error))
decompress.once('end', () => controller.terminate())
}).catch((error) => {
controller.error(error)
})
controller.enqueue(decompressed)
},
})
}
}

View File

@@ -0,0 +1,5 @@
export function createNetworkError(cause?: unknown) {
return Object.assign(new TypeError('Failed to fetch'), {
cause,
})
}

View File

@@ -0,0 +1,85 @@
// Import from an internal alias that resolves to different modules
// depending on the environment. This way, we can keep the fetch interceptor
// intact while using different strategies for Brotli decompression.
import { BrotliDecompressionStream } from 'internal:brotli-decompress'
class PipelineStream extends TransformStream {
constructor(
transformStreams: Array<TransformStream>,
...strategies: Array<QueuingStrategy>
) {
super({}, ...strategies)
const readable = [super.readable as any, ...transformStreams].reduce(
(readable, transform) => readable.pipeThrough(transform)
)
Object.defineProperty(this, 'readable', {
get() {
return readable
},
})
}
}
export function parseContentEncoding(contentEncoding: string): Array<string> {
return contentEncoding
.toLowerCase()
.split(',')
.map((coding) => coding.trim())
}
function createDecompressionStream(
contentEncoding: string
): TransformStream | null {
if (contentEncoding === '') {
return null
}
const codings = parseContentEncoding(contentEncoding)
if (codings.length === 0) {
return null
}
const transformers = codings.reduceRight<Array<TransformStream>>(
(transformers, coding) => {
if (coding === 'gzip' || coding === 'x-gzip') {
return transformers.concat(new DecompressionStream('gzip'))
} else if (coding === 'deflate') {
return transformers.concat(new DecompressionStream('deflate'))
} else if (coding === 'br') {
return transformers.concat(new BrotliDecompressionStream())
} else {
transformers.length = 0
}
return transformers
},
[]
)
return new PipelineStream(transformers)
}
export function decompressResponse(
response: Response
): ReadableStream<any> | null {
if (response.body === null) {
return null
}
const decompressionStream = createDecompressionStream(
response.headers.get('content-encoding') || ''
)
if (!decompressionStream) {
return null
}
// Use `pipeTo` and return the decompression stream's readable
// instead of `pipeThrough` because that will lock the original
// response stream, making it unusable as the input to Response.
response.body.pipeTo(decompressionStream.writable)
return decompressionStream.readable
}

View File

@@ -0,0 +1,114 @@
import { createNetworkError } from './createNetworkError'
const REQUEST_BODY_HEADERS = [
'content-encoding',
'content-language',
'content-location',
'content-type',
'content-length',
]
const kRedirectCount = Symbol('kRedirectCount')
/**
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1210
*/
export async function followFetchRedirect(
request: Request,
response: Response
): Promise<Response> {
if (response.status !== 303 && request.body != null) {
return Promise.reject(createNetworkError())
}
const requestUrl = new URL(request.url)
let locationUrl: URL
try {
// If the location is a relative URL, use the request URL as the base URL.
locationUrl = new URL(response.headers.get('location')!, request.url)
} catch (error) {
return Promise.reject(createNetworkError(error))
}
if (
!(locationUrl.protocol === 'http:' || locationUrl.protocol === 'https:')
) {
return Promise.reject(
createNetworkError('URL scheme must be a HTTP(S) scheme')
)
}
if (Reflect.get(request, kRedirectCount) > 20) {
return Promise.reject(createNetworkError('redirect count exceeded'))
}
Object.defineProperty(request, kRedirectCount, {
value: (Reflect.get(request, kRedirectCount) || 0) + 1,
})
if (
request.mode === 'cors' &&
(locationUrl.username || locationUrl.password) &&
!sameOrigin(requestUrl, locationUrl)
) {
return Promise.reject(
createNetworkError('cross origin not allowed for request mode "cors"')
)
}
const requestInit: RequestInit = {}
if (
([301, 302].includes(response.status) && request.method === 'POST') ||
(response.status === 303 && !['HEAD', 'GET'].includes(request.method))
) {
requestInit.method = 'GET'
requestInit.body = null
REQUEST_BODY_HEADERS.forEach((headerName) => {
request.headers.delete(headerName)
})
}
if (!sameOrigin(requestUrl, locationUrl)) {
request.headers.delete('authorization')
request.headers.delete('proxy-authorization')
request.headers.delete('cookie')
request.headers.delete('host')
}
/**
* @note Undici "safely" extracts the request body.
* I suspect we cannot dispatch this request again
* since its body has been read and the stream is locked.
*/
requestInit.headers = request.headers
const finalResponse = await fetch(new Request(locationUrl, requestInit))
Object.defineProperty(finalResponse, 'redirected', {
value: true,
configurable: true,
})
return finalResponse
}
/**
* @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/util.js#L761
*/
function sameOrigin(left: URL, right: URL): boolean {
if (left.origin === right.origin && left.origin === 'null') {
return true
}
if (
left.protocol === right.protocol &&
left.hostname === right.hostname &&
left.port === right.port
) {
return true
}
return false
}

View File

@@ -0,0 +1,11 @@
import { FetchInterceptor } from '../interceptors/fetch'
import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest'
/**
* The default preset provisions the interception of requests
* regardless of their type (fetch/XMLHttpRequest).
*/
export default [
new FetchInterceptor(),
new XMLHttpRequestInterceptor(),
] as const

13
node_modules/@mswjs/interceptors/src/presets/node.ts generated vendored Normal file
View File

@@ -0,0 +1,13 @@
import { ClientRequestInterceptor } from '../interceptors/ClientRequest'
import { XMLHttpRequestInterceptor } from '../interceptors/XMLHttpRequest'
import { FetchInterceptor } from '../interceptors/fetch'
/**
* The default preset provisions the interception of requests
* regardless of their type (http/https/XMLHttpRequest).
*/
export default [
new ClientRequestInterceptor(),
new XMLHttpRequestInterceptor(),
new FetchInterceptor(),
] as const

View File

@@ -0,0 +1,21 @@
import { it, expect } from 'vitest'
import { decodeBuffer, encodeBuffer } from './bufferUtils'
it('encodes utf-8 string', () => {
const encoded = encodeBuffer('😁')
expect(new Uint8Array(encoded)).toEqual(new Uint8Array([240, 159, 152, 129]))
})
it('decodes utf-8 string', () => {
const array = new Uint8Array([240, 159, 152, 129])
const decoded = decodeBuffer(array.buffer)
expect(decoded).toEqual('😁')
})
it('decodes string with custom encoding', () => {
const array = new Uint8Array([
207, 240, 232, 226, 229, 242, 44, 32, 236, 232, 240, 33,
])
const decoded = decodeBuffer(array.buffer, 'windows-1251')
expect(decoded).toEqual('Привет, мир!')
})

View File

@@ -0,0 +1,22 @@
const encoder = new TextEncoder()
export function encodeBuffer(text: string): Uint8Array {
return encoder.encode(text)
}
export function decodeBuffer(buffer: ArrayBuffer, encoding?: string): string {
const decoder = new TextDecoder(encoding)
return decoder.decode(buffer)
}
/**
* Create an `ArrayBuffer` from the given `Uint8Array`.
* Takes the byte offset into account to produce the right buffer
* in the case when the buffer is bigger than the data view.
*/
export function toArrayBuffer(array: Uint8Array): ArrayBuffer {
return array.buffer.slice(
array.byteOffset,
array.byteOffset + array.byteLength
)
}

View File

@@ -0,0 +1,13 @@
/**
* Returns a boolean indicating whether the given URL string
* can be parsed into a `URL` instance.
* A substitute for `URL.canParse()` for Node.js 18.
*/
export function canParseUrl(url: string): boolean {
try {
new URL(url)
return true
} catch (_error) {
return false
}
}

View File

@@ -0,0 +1,94 @@
import { it, expect } from 'vitest'
import { cloneObject } from './cloneObject'
it('clones a shallow object', () => {
const original = { a: 1, b: 2, c: [1, 2, 3] }
const clone = cloneObject(original)
expect(clone).toEqual(original)
clone.a = 5
clone.b = 6
clone.c = [5, 6, 7]
expect(clone).toHaveProperty('a', 5)
expect(clone).toHaveProperty('b', 6)
expect(clone).toHaveProperty('c', [5, 6, 7])
expect(original).toHaveProperty('a', 1)
expect(original).toHaveProperty('b', 2)
expect(original).toHaveProperty('c', [1, 2, 3])
})
it('clones a nested object', () => {
const original = { a: { b: 1 }, c: { d: { e: 2 } } }
const clone = cloneObject(original)
expect(clone).toEqual(original)
clone.a.b = 10
clone.c.d.e = 20
expect(clone).toHaveProperty(['a', 'b'], 10)
expect(clone).toHaveProperty(['c', 'd', 'e'], 20)
expect(original).toHaveProperty(['a', 'b'], 1)
expect(original).toHaveProperty(['c', 'd', 'e'], 2)
})
it('clones a class instance', () => {
class Car {
public manufacturer: string
constructor() {
this.manufacturer = 'Audi'
}
getManufacturer() {
return this.manufacturer
}
}
const car = new Car()
const clone = cloneObject(car)
expect(clone).toHaveProperty('manufacturer', 'Audi')
expect(clone).toHaveProperty('getManufacturer')
expect(clone.getManufacturer).toBeInstanceOf(Function)
expect(clone.getManufacturer()).toEqual('Audi')
})
it('ignores nested class instances', () => {
class Car {
name: string
constructor(name: string) {
this.name = name
}
getName() {
return this.name
}
}
const original = {
a: 1,
car: new Car('Audi'),
}
const clone = cloneObject(original)
expect(clone).toEqual(original)
expect(clone.car).toBeInstanceOf(Car)
expect(clone.car.getName()).toEqual('Audi')
clone.car = new Car('BMW')
expect(clone.car).toBeInstanceOf(Car)
expect(clone.car.getName()).toEqual('BMW')
expect(original.car).toBeInstanceOf(Car)
expect(original.car.getName()).toEqual('Audi')
})
it('clones an object with null prototype', () => {
const original = {
key: Object.create(null),
}
const clone = cloneObject(original)
expect(clone).toEqual({
key: {},
})
})

View File

@@ -0,0 +1,36 @@
import { Logger } from '@open-draft/logger'
const logger = new Logger('cloneObject')
function isPlainObject(obj?: Record<string, any>): boolean {
logger.info('is plain object?', obj)
if (obj == null || !obj.constructor?.name) {
logger.info('given object is undefined, not a plain object...')
return false
}
logger.info('checking the object constructor:', obj.constructor.name)
return obj.constructor.name === 'Object'
}
export function cloneObject<ObjectType extends Record<string, any>>(
obj: ObjectType
): ObjectType {
logger.info('cloning object:', obj)
const enumerableProperties = Object.entries(obj).reduce<Record<string, any>>(
(acc, [key, value]) => {
logger.info('analyzing key-value pair:', key, value)
// Recursively clone only plain objects, omitting class instances.
acc[key] = isPlainObject(value) ? cloneObject(value) : value
return acc
},
{}
)
return isPlainObject(obj)
? enumerableProperties
: Object.assign(Object.getPrototypeOf(obj), enumerableProperties)
}

View File

@@ -0,0 +1,164 @@
import { vi, it, expect } from 'vitest'
import { createProxy } from './createProxy'
it('does not interfere with default constructors', () => {
const ProxyClass = createProxy(
class {
constructor(public name: string) {}
},
{}
)
const instance = new ProxyClass('John')
expect(instance.name).toBe('John')
})
it('does not interfere with default getters', () => {
const proxy = createProxy({ foo: 'initial' }, {})
expect(proxy.foo).toBe('initial')
})
it('does not interfere with default setters', () => {
const proxy = createProxy({ foo: 'initial' }, {})
proxy.foo = 'next'
expect(proxy.foo).toBe('next')
})
it('does not interfere with default methods', () => {
const proxy = createProxy({ getValue: () => 'initial' }, {})
expect(proxy.getValue()).toBe('initial')
})
it('does not interfere with existing descriptors', () => {
const target = {} as { foo: string; bar: number }
let internalBar = 0
Object.defineProperties(target, {
foo: {
get: () => 'initial',
},
bar: {
set: (value) => {
internalBar = value + 10
},
},
})
const proxy = createProxy(target, {
getProperty(data, next) {
return next()
},
})
expect(proxy.foo).toBe('initial')
proxy.bar = 5
expect(proxy.bar).toBeUndefined()
expect(internalBar).toBe(15)
})
it('infer prototype descriptors', () => {
class Child {
ok: boolean
set status(nextStatus: number) {
this.ok = nextStatus >= 200 && nextStatus < 300
}
}
Object.defineProperties(Child.prototype, {
status: { enumerable: true },
})
const scope = {} as { child: typeof Child }
Object.defineProperty(scope, 'child', {
enumerable: true,
value: Child,
})
const ProxyClass = createProxy(scope.child, {})
const instance = new ProxyClass()
instance.status = 201
expect(instance.ok).toBe(true)
})
it('spies on the constructor', () => {
const OriginalClass = class {
constructor(public name: string, public age: number) {}
}
const constructorCall = vi.fn<
(
args: ConstructorParameters<typeof OriginalClass>,
next: () => typeof OriginalClass
) => typeof OriginalClass
>((args, next) => next())
const ProxyClass = createProxy(OriginalClass, {
constructorCall,
})
new ProxyClass('John', 32)
expect(constructorCall).toHaveBeenCalledTimes(1)
expect(constructorCall).toHaveBeenCalledWith(
['John', 32],
expect.any(Function)
)
})
it('spies on property getters', () => {
const getProperty = vi.fn((args, next) => next())
const proxy = createProxy({ foo: 'initial' }, { getProperty })
proxy.foo
expect(getProperty).toHaveBeenCalledTimes(1)
expect(getProperty).toHaveBeenCalledWith(['foo', proxy], expect.any(Function))
})
it('spies on property setters', () => {
const setProperty = vi.fn((args, next) => next())
const proxy = createProxy({ foo: 'initial' }, { setProperty })
proxy.foo = 'next'
expect(setProperty).toHaveBeenCalledTimes(1)
expect(setProperty).toHaveBeenCalledWith(
['foo', 'next'],
expect.any(Function)
)
})
it('spies on method calls', () => {
const methodCall = vi.fn((args, next) => next())
const proxy = createProxy(
{
greet: (name: string) => `hello ${name}`,
},
{ methodCall }
)
proxy.greet('Clair')
expect(methodCall).toHaveBeenCalledTimes(1)
expect(methodCall).toHaveBeenCalledWith(
['greet', ['Clair']],
expect.any(Function)
)
})
it('proxies properties on the prototype level', () => {
const method = vi.fn()
const prototype = { method }
const proxy = createProxy(Object.create(prototype), {})
const proxyMethod = vi.fn()
proxy.method = proxyMethod
prototype.method()
expect(method).toHaveBeenCalledTimes(0)
expect(proxyMethod).toHaveBeenCalledTimes(1)
})

View File

@@ -0,0 +1,104 @@
import { findPropertySource } from './findPropertySource'
export interface ProxyOptions<Target extends Record<string, any>> {
constructorCall?(args: Array<unknown>, next: NextFunction<Target>): Target
methodCall?<F extends keyof Target>(
this: Target,
data: [methodName: F, args: Array<unknown>],
next: NextFunction<void>
): void
setProperty?(
data: [propertyName: string | symbol, nextValue: unknown],
next: NextFunction<boolean>
): boolean
getProperty?(
data: [propertyName: string | symbol, receiver: Target],
next: NextFunction<void>
): void
}
export type NextFunction<ReturnType> = () => ReturnType
export function createProxy<Target extends object>(
target: Target,
options: ProxyOptions<Target>
): Target {
const proxy = new Proxy(target, optionsToProxyHandler(options))
return proxy
}
function optionsToProxyHandler<T extends Record<string, any>>(
options: ProxyOptions<T>
): ProxyHandler<T> {
const { constructorCall, methodCall, getProperty, setProperty } = options
const handler: ProxyHandler<T> = {}
if (typeof constructorCall !== 'undefined') {
handler.construct = function (target, args, newTarget) {
const next = Reflect.construct.bind(null, target as any, args, newTarget)
return constructorCall.call(newTarget, args, next)
}
}
handler.set = function (target, propertyName, nextValue) {
const next = () => {
const propertySource = findPropertySource(target, propertyName) || target
const ownDescriptors = Reflect.getOwnPropertyDescriptor(
propertySource,
propertyName
)
// Respect any custom setters present for this property.
if (typeof ownDescriptors?.set !== 'undefined') {
ownDescriptors.set.apply(target, [nextValue])
return true
}
// Otherwise, set the property on the source.
return Reflect.defineProperty(propertySource, propertyName, {
writable: true,
enumerable: true,
configurable: true,
value: nextValue,
})
}
if (typeof setProperty !== 'undefined') {
return setProperty.call(target, [propertyName, nextValue], next)
}
return next()
}
handler.get = function (target, propertyName, receiver) {
/**
* @note Using `Reflect.get()` here causes "TypeError: Illegal invocation".
*/
const next = () => target[propertyName as any]
const value =
typeof getProperty !== 'undefined'
? getProperty.call(target, [propertyName, receiver], next)
: next()
if (typeof value === 'function') {
return (...args: Array<any>) => {
const next = value.bind(target, ...args)
if (typeof methodCall !== 'undefined') {
return methodCall.call(target, [propertyName as any, args], next)
}
return next()
}
}
return value
}
return handler
}

View File

@@ -0,0 +1,25 @@
import { Emitter, EventMap } from 'strict-event-emitter'
/**
* Emits an event on the given emitter but executes
* the listeners sequentially. This accounts for asynchronous
* listeners (e.g. those having "sleep" and handling the request).
*/
export async function emitAsync<
Events extends EventMap,
EventName extends keyof Events
>(
emitter: Emitter<Events>,
eventName: EventName,
...data: Events[EventName]
): Promise<void> {
const listeners = emitter.listeners(eventName)
if (listeners.length === 0) {
return
}
for (const listener of listeners) {
await listener.apply(emitter, data)
}
}

View File

@@ -0,0 +1,119 @@
import { canParseUrl } from './canParseUrl'
import { getValueBySymbol } from './getValueBySymbol'
export interface FetchResponseInit extends ResponseInit {
url?: string
}
interface UndiciFetchInternalState {
aborted: boolean
rangeRequested: boolean
timingAllowPassed: boolean
requestIncludesCredentials: boolean
type: ResponseType
status: number
statusText: string
timingInfo: unknown
cacheState: unknown
headersList: Record<symbol, Map<string, unknown>>
urlList: Array<URL>
body?: {
stream: ReadableStream
source: unknown
length: number
}
}
export class FetchResponse extends Response {
/**
* Response status codes for responses that cannot have body.
* @see https://fetch.spec.whatwg.org/#statuses
*/
static readonly STATUS_CODES_WITHOUT_BODY = [101, 103, 204, 205, 304]
static readonly STATUS_CODES_WITH_REDIRECT = [301, 302, 303, 307, 308]
static isConfigurableStatusCode(status: number): boolean {
return status >= 200 && status <= 599
}
static isRedirectResponse(status: number): boolean {
return FetchResponse.STATUS_CODES_WITH_REDIRECT.includes(status)
}
/**
* Returns a boolean indicating whether the given response status
* code represents a response that can have a body.
*/
static isResponseWithBody(status: number): boolean {
return !FetchResponse.STATUS_CODES_WITHOUT_BODY.includes(status)
}
static setUrl(url: string | undefined, response: Response): void {
if (!url || url === 'about:' || !canParseUrl(url)) {
return
}
const state = getValueBySymbol<UndiciFetchInternalState>('state', response)
if (state) {
// In Undici, push the URL to the internal list of URLs.
// This will respect the `response.url` getter logic correctly.
state.urlList.push(new URL(url))
} else {
// In other libraries, redefine the `url` property directly.
Object.defineProperty(response, 'url', {
value: url,
enumerable: true,
configurable: true,
writable: false,
})
}
}
/**
* Parses the given raw HTTP headers into a Fetch API `Headers` instance.
*/
static parseRawHeaders(rawHeaders: Array<string>): Headers {
const headers = new Headers()
for (let line = 0; line < rawHeaders.length; line += 2) {
headers.append(rawHeaders[line], rawHeaders[line + 1])
}
return headers
}
constructor(body?: BodyInit | null, init: FetchResponseInit = {}) {
const status = init.status ?? 200
const safeStatus = FetchResponse.isConfigurableStatusCode(status)
? status
: 200
const finalBody = FetchResponse.isResponseWithBody(status) ? body : null
super(finalBody, {
status: safeStatus,
statusText: init.statusText,
headers: init.headers,
})
if (status !== safeStatus) {
/**
* @note Undici keeps an internal "Symbol(state)" that holds
* the actual value of response status. Update that in Node.js.
*/
const state = getValueBySymbol<UndiciFetchInternalState>('state', this)
if (state) {
state.status = status
} else {
Object.defineProperty(this, 'status', {
value: status,
enumerable: true,
configurable: true,
writable: false,
})
}
}
FetchResponse.setUrl(init.url, this)
}
}

View File

@@ -0,0 +1,27 @@
import { it, expect } from 'vitest'
import { findPropertySource } from './findPropertySource'
it('returns the source for objects without prototypes', () => {
const obj = Object.create(null)
obj.test = undefined
const source = findPropertySource(obj, 'test')
expect(source).toBe(obj)
})
it('returns the source for objects with prototypes', () => {
const prototype = Object.create(null)
prototype.test = undefined
const obj = Object.create(prototype)
const source = findPropertySource(obj, 'test')
expect(source).toBe(prototype)
})
it('returns null if the prototype chain does not contain the property', () => {
const prototype = Object.create(null)
const obj = Object.create(prototype)
const source = findPropertySource(obj, 'test')
expect(source).toBeNull()
})

View File

@@ -0,0 +1,20 @@
/**
* Returns the source object of the given property on the target object
* (the target itself, any parent in its prototype, or null).
*/
export function findPropertySource(
target: object,
propertyName: string | symbol
): object | null {
if (!(propertyName in target)) {
return null
}
const hasProperty = Object.prototype.hasOwnProperty.call(target, propertyName)
if (hasProperty) {
return target
}
const prototype = Reflect.getPrototypeOf(target)
return prototype ? findPropertySource(prototype, propertyName) : null
}

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { getCleanUrl } from './getCleanUrl'
describe('getCleanUrl', () => {
describe('given a URL without query parameters', () => {
it('should return url href as-is', () => {
const url = new URL('https://github.com')
expect(getCleanUrl(url)).toEqual('https://github.com/')
})
})
describe('given a URL with query parameters', () => {
it('should return url without parameters', () => {
const url = new URL('https://github.com/mswjs/?userId=abc-123')
expect(getCleanUrl(url)).toEqual('https://github.com/mswjs/')
})
})
describe('given a URL with a hash', () => {
it('should return a url without hash', () => {
const url = new URL('https://github.com/mswjs/#hello-world')
expect(getCleanUrl(url)).toEqual('https://github.com/mswjs/')
})
})
describe('given an absolute URL ', () => {
it('should return a clean relative URL', () => {
const url = new URL('/login?query=value', 'https://github.com')
expect(getCleanUrl(url, false)).toEqual('/login')
})
})
})

View File

@@ -0,0 +1,6 @@
/**
* Removes query parameters and hashes from a given URL.
*/
export function getCleanUrl(url: URL, isAbsolute: boolean = true): string {
return [isAbsolute && url.origin, url.pathname].filter(Boolean).join('')
}

View File

@@ -0,0 +1,163 @@
import { it, expect } from 'vitest'
import { Agent as HttpAgent } from 'http'
import { RequestOptions, Agent as HttpsAgent } from 'https'
import { getUrlByRequestOptions } from './getUrlByRequestOptions'
it('returns a URL based on the basic RequestOptions', () => {
expect(
getUrlByRequestOptions({
protocol: 'https:',
host: '127.0.0.1',
path: '/resource',
}).href
).toBe('https://127.0.0.1/resource')
})
it('inherits protocol and port from http.Agent, if set', () => {
expect(
getUrlByRequestOptions({
host: '127.0.0.1',
path: '/',
agent: new HttpAgent(),
}).href
).toBe('http://127.0.0.1/')
})
it('inherits protocol and port from https.Agent, if set', () => {
expect(
getUrlByRequestOptions({
host: '127.0.0.1',
path: '/',
agent: new HttpsAgent({
port: 3080,
}),
}).href
).toBe('https://127.0.0.1:3080/')
})
it('resolves protocol to "http" given no explicit protocol and no certificate', () => {
expect(
getUrlByRequestOptions({
host: '127.0.0.1',
path: '/',
}).href
).toBe('http://127.0.0.1/')
})
it('resolves protocol to "https" given no explicit protocol, but certificate', () => {
expect(
getUrlByRequestOptions({
host: '127.0.0.1',
path: '/secure',
cert: '<!-- SSL certificate -->',
}).href
).toBe('https://127.0.0.1/secure')
})
it('resolves protocol to "https" given no explicit protocol, but port is 443', () => {
expect(
getUrlByRequestOptions({
host: '127.0.0.1',
port: 443,
path: '/resource',
}).href
).toBe('https://127.0.0.1/resource')
})
it('resolves protocol to "https" given no explicit protocol, but agent port is 443', () => {
expect(
getUrlByRequestOptions({
host: '127.0.0.1',
agent: new HttpsAgent({
port: 443,
}),
path: '/resource',
}).href
).toBe('https://127.0.0.1/resource')
})
it('respects explicitly provided port', () => {
expect(
getUrlByRequestOptions({
protocol: 'http:',
host: '127.0.0.1',
port: 4002,
path: '/',
}).href
).toBe('http://127.0.0.1:4002/')
})
it('inherits "username" and "password"', () => {
const url = getUrlByRequestOptions({
protocol: 'https:',
host: '127.0.0.1',
path: '/user',
auth: 'admin:abc-123',
})
expect(url).toBeInstanceOf(URL)
expect(url).toHaveProperty('username', 'admin')
expect(url).toHaveProperty('password', 'abc-123')
expect(url).toHaveProperty('href', 'https://admin:abc-123@127.0.0.1/user')
})
it('resolves hostname to localhost if none provided', () => {
expect(getUrlByRequestOptions({}).hostname).toBe('localhost')
})
it('resolves host to localhost if none provided', () => {
expect(getUrlByRequestOptions({}).host).toBe('localhost')
})
it('supports "hostname" and "port"', () => {
const options: RequestOptions = {
protocol: 'https:',
hostname: '127.0.0.1',
port: 1234,
path: '/resource',
}
expect(getUrlByRequestOptions(options).href).toBe(
'https://127.0.0.1:1234/resource'
)
})
it('use "hostname" if both "hostname" and "host" are specified', () => {
const options: RequestOptions = {
protocol: 'https:',
host: 'host',
hostname: 'hostname',
path: '/resource',
}
expect(getUrlByRequestOptions(options).href).toBe(
'https://hostname/resource'
)
})
it('parses "host" in IPv6', () => {
expect(
getUrlByRequestOptions({
host: '::1',
path: '/resource',
}).href
).toBe('http://[::1]/resource')
expect(
getUrlByRequestOptions({
host: '[::1]',
path: '/resource',
}).href
).toBe('http://[::1]/resource')
})
it('parses "host" and "port" in IPv6', () => {
expect(
getUrlByRequestOptions({
host: '::1',
port: 3001,
path: '/resource',
}).href
).toBe('http://[::1]:3001/resource')
})

View File

@@ -0,0 +1,152 @@
import { Agent } from 'http'
import { RequestOptions, Agent as HttpsAgent } from 'https'
import { Logger } from '@open-draft/logger'
const logger = new Logger('utils getUrlByRequestOptions')
// Request instance constructed by the "request" library
// has a "self" property that has a "uri" field. This is
// reproducible by performing a "XMLHttpRequest" request in JSDOM.
export interface RequestSelf {
uri?: URL
}
export type ResolvedRequestOptions = RequestOptions & RequestSelf
export const DEFAULT_PATH = '/'
const DEFAULT_PROTOCOL = 'http:'
const DEFAULT_HOSTNAME = 'localhost'
const SSL_PORT = 443
function getAgent(
options: ResolvedRequestOptions
): Agent | HttpsAgent | undefined {
return options.agent instanceof Agent ? options.agent : undefined
}
function getProtocolByRequestOptions(options: ResolvedRequestOptions): string {
if (options.protocol) {
return options.protocol
}
const agent = getAgent(options)
const agentProtocol = (agent as RequestOptions)?.protocol
if (agentProtocol) {
return agentProtocol
}
const port = getPortByRequestOptions(options)
const isSecureRequest = options.cert || port === SSL_PORT
return isSecureRequest ? 'https:' : options.uri?.protocol || DEFAULT_PROTOCOL
}
function getPortByRequestOptions(
options: ResolvedRequestOptions
): number | undefined {
// Use the explicitly provided port.
if (options.port) {
return Number(options.port)
}
// Otherwise, try to resolve port from the agent.
const agent = getAgent(options)
if ((agent as HttpsAgent)?.options.port) {
return Number((agent as HttpsAgent).options.port)
}
if ((agent as RequestOptions)?.defaultPort) {
return Number((agent as RequestOptions).defaultPort)
}
// Lastly, return undefined indicating that the port
// must inferred from the protocol. Do not infer it here.
return undefined
}
interface RequestAuth {
username: string
password: string
}
function getAuthByRequestOptions(
options: ResolvedRequestOptions
): RequestAuth | undefined {
if (options.auth) {
const [username, password] = options.auth.split(':')
return { username, password }
}
}
/**
* Returns true if host looks like an IPv6 address without surrounding brackets
* It assumes any host containing `:` is definitely not IPv4 and probably IPv6,
* but note that this could include invalid IPv6 addresses as well.
*/
function isRawIPv6Address(host: string): boolean {
return host.includes(':') && !host.startsWith('[') && !host.endsWith(']')
}
function getHostname(options: ResolvedRequestOptions): string | undefined {
let host = options.hostname || options.host
if (host) {
if (isRawIPv6Address(host)) {
host = `[${host}]`
}
// Check the presence of the port, and if it's present,
// remove it from the host, returning a hostname.
return new URL(`http://${host}`).hostname
}
return DEFAULT_HOSTNAME
}
/**
* Creates a `URL` instance from a given `RequestOptions` object.
*/
export function getUrlByRequestOptions(options: ResolvedRequestOptions): URL {
logger.info('request options', options)
if (options.uri) {
logger.info(
'constructing url from explicitly provided "options.uri": %s',
options.uri
)
return new URL(options.uri.href)
}
logger.info('figuring out url from request options...')
const protocol = getProtocolByRequestOptions(options)
logger.info('protocol', protocol)
const port = getPortByRequestOptions(options)
logger.info('port', port)
const hostname = getHostname(options)
logger.info('hostname', hostname)
const path = options.path || DEFAULT_PATH
logger.info('path', path)
const credentials = getAuthByRequestOptions(options)
logger.info('credentials', credentials)
const authString = credentials
? `${credentials.username}:${credentials.password}@`
: ''
logger.info('auth string:', authString)
const portString = typeof port !== 'undefined' ? `:${port}` : ''
const url = new URL(`${protocol}//${hostname}${portString}${path}`)
url.username = credentials?.username || ''
url.password = credentials?.password || ''
logger.info('created url:', url)
return url
}

View File

@@ -0,0 +1,14 @@
import { it, expect } from 'vitest'
import { getValueBySymbol } from './getValueBySymbol'
it('returns undefined given a non-existing symbol', () => {
expect(getValueBySymbol('non-existing', {})).toBeUndefined()
})
it('returns value behind the given symbol', () => {
const symbol = Symbol('kInternal')
expect(getValueBySymbol('kInternal', { [symbol]: null })).toBe(null)
expect(getValueBySymbol('kInternal', { [symbol]: true })).toBe(true)
expect(getValueBySymbol('kInternal', { [symbol]: 'value' })).toBe('value')
})

View File

@@ -0,0 +1,19 @@
/**
* Returns the value behind the symbol with the given name.
*/
export function getValueBySymbol<T>(
symbolName: string,
source: object
): T | undefined {
const ownSymbols = Object.getOwnPropertySymbols(source)
const symbol = ownSymbols.find((symbol) => {
return symbol.description === symbolName
})
if (symbol) {
return Reflect.get(source, symbol)
}
return
}

View File

@@ -0,0 +1,205 @@
import type { Emitter } from 'strict-event-emitter'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { until } from '@open-draft/until'
import type { HttpRequestEventMap } from '../glossary'
import { emitAsync } from './emitAsync'
import { RequestController } from '../RequestController'
import {
createServerErrorResponse,
isResponseError,
isResponseLike,
} from './responseUtils'
import { InterceptorError } from '../InterceptorError'
import { isNodeLikeError } from './isNodeLikeError'
import { isObject } from './isObject'
interface HandleRequestOptions {
requestId: string
request: Request
emitter: Emitter<HttpRequestEventMap>
controller: RequestController
}
export async function handleRequest(
options: HandleRequestOptions
): Promise<void> {
const handleResponse = async (
response: Response | Error | Record<string, any>
) => {
if (response instanceof Error) {
await options.controller.errorWith(response)
return true
}
// Handle "Response.error()" instances.
if (isResponseError(response)) {
await options.controller.respondWith(response)
return true
}
/**
* Handle normal responses or response-like objects.
* @note This must come before the arbitrary object check
* since Response instances are, in fact, objects.
*/
if (isResponseLike(response)) {
await options.controller.respondWith(response)
return true
}
// Handle arbitrary objects provided to `.errorWith(reason)`.
if (isObject(response)) {
await options.controller.errorWith(response)
return true
}
return false
}
const handleResponseError = async (error: unknown): Promise<boolean> => {
// Forward the special interceptor error instances
// to the developer. These must not be handled in any way.
if (error instanceof InterceptorError) {
throw result.error
}
// Support mocking Node.js-like errors.
if (isNodeLikeError(error)) {
await options.controller.errorWith(error)
return true
}
// Handle thrown responses.
if (error instanceof Response) {
return await handleResponse(error)
}
return false
}
// Add the last "request" listener to check if the request
// has been handled in any way. If it hasn't, resolve the
// response promise with undefined.
// options.emitter.once('request', async ({ requestId: pendingRequestId }) => {
// if (
// pendingRequestId === options.requestId &&
// options.controller.readyState === RequestController.PENDING
// ) {
// await options.controller.passthrough()
// }
// })
const requestAbortPromise = new DeferredPromise<void, unknown>()
/**
* @note `signal` is not always defined in React Native.
*/
if (options.request.signal) {
if (options.request.signal.aborted) {
await options.controller.errorWith(options.request.signal.reason)
return
}
options.request.signal.addEventListener(
'abort',
() => {
requestAbortPromise.reject(options.request.signal.reason)
},
{ once: true }
)
}
const result = await until(async () => {
// Emit the "request" event and wait until all the listeners
// for that event are finished (e.g. async listeners awaited).
// By the end of this promise, the developer cannot affect the
// request anymore.
const requestListenersPromise = emitAsync(options.emitter, 'request', {
requestId: options.requestId,
request: options.request,
controller: options.controller,
})
await Promise.race([
// Short-circuit the request handling promise if the request gets aborted.
requestAbortPromise,
requestListenersPromise,
options.controller.handled,
])
})
// Handle the request being aborted while waiting for the request listeners.
if (requestAbortPromise.state === 'rejected') {
await options.controller.errorWith(requestAbortPromise.rejectionReason)
return
}
if (result.error) {
// Handle the error during the request listener execution.
// These can be thrown responses or request errors.
if (await handleResponseError(result.error)) {
return
}
// If the developer has added "unhandledException" listeners,
// allow them to handle the error. They can translate it to a
// mocked response, network error, or forward it as-is.
if (options.emitter.listenerCount('unhandledException') > 0) {
// Create a new request controller just for the unhandled exception case.
// This is needed because the original controller might have been already
// interacted with (e.g. "respondWith" or "errorWith" called on it).
const unhandledExceptionController = new RequestController(
options.request,
{
/**
* @note Intentionally empty passthrough handle.
* This controller is created within another controller and we only need
* to know if `unhandledException` listeners handled the request.
*/
passthrough() {},
async respondWith(response) {
await handleResponse(response)
},
async errorWith(reason) {
/**
* @note Handle the result of the unhandled controller
* in the same way as the original request controller.
* The exception here is that thrown errors within the
* "unhandledException" event do NOT result in another
* emit of the same event. They are forwarded as-is.
*/
await options.controller.errorWith(reason)
},
}
)
await emitAsync(options.emitter, 'unhandledException', {
error: result.error,
request: options.request,
requestId: options.requestId,
controller: unhandledExceptionController,
})
// If all the "unhandledException" listeners have finished
// but have not handled the request in any way, passthrough.
if (
unhandledExceptionController.readyState !== RequestController.PENDING
) {
return
}
}
// Otherwise, coerce unhandled exceptions to a 500 Internal Server Error response.
await options.controller.respondWith(
createServerErrorResponse(result.error)
)
return
}
// If the request hasn't been handled by this point, passthrough.
if (options.controller.readyState === RequestController.PENDING) {
return await options.controller.passthrough()
}
return options.controller.handled
}

View File

@@ -0,0 +1,83 @@
import { vi, beforeAll, afterEach, afterAll, it, expect } from 'vitest'
import { hasConfigurableGlobal } from './hasConfigurableGlobal'
beforeAll(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.clearAllMocks()
})
afterAll(() => {
vi.restoreAllMocks()
})
it('returns true if the global property exists and is configurable', () => {
Object.defineProperty(global, '_existsAndConfigurable', {
value: 'something',
configurable: true,
})
expect(hasConfigurableGlobal('_existsAndConfigurable')).toBe(true)
})
it('returns false if the global property does not exist', () => {
expect(hasConfigurableGlobal('_non-existing')).toBe(false)
})
it('returns false for existing global with undefined as a value', () => {
Object.defineProperty(global, '_existsAndUndefined', {
value: undefined,
configurable: true,
})
expect(hasConfigurableGlobal('_existsAndUndefined')).toBe(false)
})
it('returns false for existing global with null as a value', () => {
Object.defineProperty(global, '_existsAndNull', {
value: null,
configurable: true,
})
expect(hasConfigurableGlobal('_existsAndNull')).toBe(false)
})
it('returns false for existing global with a getter that returns undefined', () => {
Object.defineProperty(global, '_existsGetterUndefined', {
get: () => undefined,
configurable: true,
})
expect(hasConfigurableGlobal('_existsGetterUndefined')).toBe(false)
})
it('returns false and prints an error for implicitly non-configurable global property', () => {
Object.defineProperty(global, '_implicitlyNonConfigurable', {
value: 'something',
})
expect(hasConfigurableGlobal('_implicitlyNonConfigurable')).toBe(false)
expect(console.error).toHaveBeenCalledWith(
'[MSW] Failed to apply interceptor: the global `_implicitlyNonConfigurable` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.'
)
})
it('returns false and prints an error for explicitly non-configurable global property', () => {
Object.defineProperty(global, '_explicitlyNonConfigurable', {
value: 'something',
configurable: false,
})
expect(hasConfigurableGlobal('_explicitlyNonConfigurable')).toBe(false)
expect(console.error).toHaveBeenCalledWith(
'[MSW] Failed to apply interceptor: the global `_explicitlyNonConfigurable` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.'
)
})
it('returns false and prints an error for global property that only has a getter', () => {
Object.defineProperty(global, '_onlyGetter', { get: () => 'something' })
expect(hasConfigurableGlobal('_onlyGetter')).toBe(false)
expect(console.error).toHaveBeenCalledWith(
'[MSW] Failed to apply interceptor: the global `_onlyGetter` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.'
)
})

View File

@@ -0,0 +1,34 @@
/**
* Returns a boolean indicating whether the given global property
* is defined and is configurable.
*/
export function hasConfigurableGlobal(propertyName: string): boolean {
const descriptor = Object.getOwnPropertyDescriptor(globalThis, propertyName)
// The property is not set at all.
if (typeof descriptor === 'undefined') {
return false
}
// The property is set to a getter that returns undefined.
if (
typeof descriptor.get === 'function' &&
typeof descriptor.get() === 'undefined'
) {
return false
}
// The property is set to a value equal to undefined.
if (typeof descriptor.get === 'undefined' && descriptor.value == null) {
return false
}
if (typeof descriptor.set === 'undefined' && !descriptor.configurable) {
console.error(
`[MSW] Failed to apply interceptor: the global \`${propertyName}\` property is non-configurable. This is likely an issue with your environment. If you are using a framework, please open an issue about this in their repository.`
)
return false
}
return true
}

View File

@@ -0,0 +1,13 @@
export function isNodeLikeError(
error: unknown
): error is NodeJS.ErrnoException {
if (error == null) {
return false
}
if (!(error instanceof Error)) {
return false
}
return 'code' in error && 'errno' in error
}

View File

@@ -0,0 +1,21 @@
import { it, expect } from 'vitest'
import { isObject } from './isObject'
it('returns true given an object', () => {
expect(isObject({})).toBe(true)
expect(isObject({ a: 1 })).toBe(true)
})
it('returns false given an object-like instance', () => {
expect(isObject([1])).toBe(false)
expect(isObject(function () {})).toBe(false)
expect(isObject(new Response())).toBe(false)
})
it('returns false given a non-object instance', () => {
expect(isObject(null)).toBe(false)
expect(isObject(undefined)).toBe(false)
expect(isObject(false)).toBe(false)
expect(isObject(123)).toBe(false)
expect(isObject(Symbol('object Object'))).toBe(false)
})

View File

@@ -0,0 +1,8 @@
/**
* Determines if a given value is an instance of object.
*/
export function isObject<T>(value: any, loose = false): value is T {
return loose
? Object.prototype.toString.call(value).startsWith('[object ')
: Object.prototype.toString.call(value) === '[object Object]'
}

View File

@@ -0,0 +1,19 @@
/**
* A function that validates if property access is possible on an object
* without throwing. It returns `true` if the property access is possible
* and `false` otherwise.
*
* Environments like miniflare will throw on property access on certain objects
* like Request and Response, for unimplemented properties.
*/
export function isPropertyAccessible<Obj extends Record<string, any>>(
obj: Obj,
key: keyof Obj
) {
try {
obj[key]
return true
} catch {
return false
}
}

11
node_modules/@mswjs/interceptors/src/utils/nextTick.ts generated vendored Normal file
View File

@@ -0,0 +1,11 @@
export function nextTick(callback: () => void) {
setTimeout(callback, 0)
}
export function nextTickAsync(callback: () => void) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(callback())
}, 0)
})
}

View File

@@ -0,0 +1,39 @@
import { ClientRequest } from 'node:http'
import { Readable } from 'node:stream'
import { invariant } from 'outvariant'
import { getRawRequest } from '../../getRawRequest'
const kRawRequestBodyStream = Symbol('kRawRequestBodyStream')
/**
* Returns the request body stream of the given request.
* @note This is only relevant in the context of `http.ClientRequest`.
* This function will throw if the given `request` wasn't created based on
* the `http.ClientRequest` instance.
* You must rely on the web stream consumers for other request clients.
*/
export function getClientRequestBodyStream(request: Request): Readable {
const rawRequest = getRawRequest(request)
invariant(
rawRequest instanceof ClientRequest,
`Failed to retrieve raw request body stream: request is not an instance of "http.ClientRequest". Note that you can only use the "getClientRequestBodyStream" function with the requests issued by "http.clientRequest".`
)
const requestBodyStream = Reflect.get(request, kRawRequestBodyStream)
invariant(
requestBodyStream instanceof Readable,
'Failed to retrieve raw request body stream: corrupted stream (%s)',
typeof requestBodyStream
)
return requestBodyStream
}
export function setRawRequestBodyStream(
request: Request,
stream: Readable
): void {
Reflect.set(request, kRawRequestBodyStream, stream)
}

View File

@@ -0,0 +1,10 @@
import { it, expect } from 'vitest'
import { parseJson } from './parseJson'
it('parses a given string into JSON', () => {
expect(parseJson('{"id":1}')).toEqual({ id: 1 })
})
it('returns null given invalid JSON string', () => {
expect(parseJson('{"o:2\'')).toBeNull()
})

View File

@@ -0,0 +1,12 @@
/**
* Parses a given string into JSON.
* Gracefully handles invalid JSON by returning `null`.
*/
export function parseJson(data: string): Record<string, unknown> | null {
try {
const json = JSON.parse(data)
return json
} catch (_) {
return null
}
}

View File

@@ -0,0 +1,45 @@
/**
* Resolve potentially relative WebSocket URLs the same way
* the browser does (replace the protocol, use the origin, etc).
*
* @see https://websockets.spec.whatwg.org//#dom-websocket-websocket
*/
export function resolveWebSocketUrl(url: string | URL): string {
if (typeof url === 'string') {
/**
* @note Cast the string to a URL first so the parsing errors
* are thrown as a part of the WebSocket constructor, not consumers.
*/
const urlRecord = new URL(
url,
typeof location !== 'undefined' ? location.href : undefined
)
return resolveWebSocketUrl(urlRecord)
}
if (url.protocol === 'http:') {
url.protocol = 'ws:'
} else if (url.protocol === 'https:') {
url.protocol = 'wss:'
}
if (url.protocol !== 'ws:' && url.protocol !== 'wss:') {
/**
* @note These errors are modeled after the browser errors.
* The exact error messages aren't provided in the specification.
* Node.js uses more obscure error messages that I don't wish to replicate.
*/
throw new SyntaxError(
`Failed to construct 'WebSocket': The URL's scheme must be either 'http', 'https', 'ws', or 'wss'. '${url.protocol}' is not allowed.`
)
}
if (url.hash !== '') {
throw new SyntaxError(
`Failed to construct 'WebSocket': The URL contains a fragment identifier ('${url.hash}'). Fragment identifiers are not allowed in WebSocket URLs.`
)
}
return url.href
}

View File

@@ -0,0 +1,59 @@
import { isObject } from './isObject'
import { isPropertyAccessible } from './isPropertyAccessible'
/**
* Creates a generic 500 Unhandled Exception response.
*/
export function createServerErrorResponse(body: unknown): Response {
return new Response(
JSON.stringify(
body instanceof Error
? {
name: body.name,
message: body.message,
stack: body.stack,
}
: body
),
{
status: 500,
statusText: 'Unhandled Exception',
headers: {
'Content-Type': 'application/json',
},
}
)
}
export type ResponseError = Response & { type: 'error' }
/**
* Check if the given response is a `Response.error()`.
*
* @note Some environments, like Miniflare (Cloudflare) do not
* implement the "Response.type" property and throw on its access.
* Safely check if we can access "type" on "Response" before continuing.
* @see https://github.com/mswjs/msw/issues/1834
*/
export function isResponseError(response: unknown): response is ResponseError {
return (
response != null &&
response instanceof Response &&
isPropertyAccessible(response, 'type') &&
response.type === 'error'
)
}
/**
* Check if the given value is a `Response` or a Response-like object.
* This is different from `value instanceof Response` because it supports
* custom `Response` constructors, like the one when using Undici directly.
*/
export function isResponseLike(value: unknown): value is Response {
return (
isObject<Record<string, any>>(value, true) &&
isPropertyAccessible(value, 'status') &&
isPropertyAccessible(value, 'statusText') &&
isPropertyAccessible(value, 'bodyUsed')
)
}