Skip to content

Commit

Permalink
feat(wip): forward life-cycle events over http
Browse files Browse the repository at this point in the history
  • Loading branch information
kettanaito committed Jan 6, 2025
1 parent 28875f9 commit 172dbd7
Show file tree
Hide file tree
Showing 2 changed files with 208 additions and 76 deletions.
10 changes: 5 additions & 5 deletions src/node/SetupServerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export class SetupServerApi
const remoteConnectionPromise = remoteClient.connect().then(
() => {
// Forward the life-cycle events from this process to the remote.
// this.forwardLifeCycleEventsToRemote()
this.forwardLifeCycleEventsToRemote()

this.handlersController.currentHandlers = new Proxy(
this.handlersController.currentHandlers,
Expand Down Expand Up @@ -184,10 +184,10 @@ export class SetupServerApi
for (const event of events) {
this.emitter.on(event, (args) => {
if (!shouldBypassRequest(args.request)) {
// remoteClient.handleLifeCycleEvent({
// type: event,
// args,
// })
remoteClient.handleLifeCycleEvent({
type: event,
args,
})
}
})
}
Expand Down
274 changes: 203 additions & 71 deletions src/node/setupRemoteServer.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import * as http from 'node:http'
import { Readable } from 'node:stream'
import * as streamConsumers from 'node:stream/consumers'
import { AsyncLocalStorage } from 'node:async_hooks'
import { invariant } from 'outvariant'
import { createRequestId, FetchResponse } from '@mswjs/interceptors'
import { DeferredPromise } from '@open-draft/deferred-promise'
import { Emitter } from 'strict-event-emitter'
import { SetupApi } from '~/core/SetupApi'
import { delay } from '~/core/delay'
import { bypass } from '~/core/bypass'
import type { RequestHandler } from '~/core/handlers/RequestHandler'
import type { WebSocketHandler } from '~/core/handlers/WebSocketHandler'
import { handleRequest } from '~/core/utils/handleRequest'
Expand All @@ -24,6 +27,30 @@ interface RemoteServerBoundaryContext {
handlers: Array<RequestHandler | WebSocketHandler>
}

export type ForwardedLifeCycleEventPayload = {
type: keyof LifeCycleEventsMap
args: {
requestId: string
request: {
method: string
url: string
headers: Array<[string, string]>
body: ArrayBuffer | null
}
response?: {
status: number
statusText: string
headers: Array<[string, string]>
body: ArrayBuffer | null
}
error?: {
name: string
message: string
stack?: string
}
}
}

export const remoteHandlersContext =
new AsyncLocalStorage<RemoteServerBoundaryContext>()

Expand Down Expand Up @@ -97,13 +124,18 @@ export class SetupRemoteServerApi
}

public async listen(): Promise<void> {
const dummyEmitter = new Emitter<LifeCycleEventsMap>()

const server = await createSyncServer()
this[kServerUrl] = getServerUrl(server)

process
.once('SIGTERM', () => closeSyncServer(server))
.once('SIGINT', () => closeSyncServer(server))

// Close the server if the setup API is disposed.
this.subscriptions.push(() => closeSyncServer(server))

server.on('request', async (incoming, outgoing) => {
if (!incoming.method) {
return
Expand Down Expand Up @@ -173,7 +205,12 @@ export class SetupRemoteServerApi
handlers,
/** @todo Support listen options */
{ onUnhandledRequest() {} },
this.emitter,
/**
* @note Use a dummy emitter because this context
* is only one layer that can resolve a request. For example,
* request can be resolved in the remote process and not here.
*/
dummyEmitter,
)

if (response) {
Expand Down Expand Up @@ -262,19 +299,35 @@ export class SetupRemoteServerApi
}

private async handleLifeCycleEventRequest(
_incoming: http.IncomingMessage,
_outgoing: http.ServerResponse<http.IncomingMessage> & {
incoming: http.IncomingMessage,
outgoing: http.ServerResponse<http.IncomingMessage> & {
req: http.IncomingMessage
},
) {
// const stream = Readable.toWeb(incoming)
// const { event, requestId, request, response, error } = await new Request(
// incoming.url,
// { body: stream },
// ).json()
// /** @todo Finish this. */
// this.emitter.emit(event, {})
// outgoing.writeHead(200).end()
const event = (await streamConsumers.json(
incoming,
)) as ForwardedLifeCycleEventPayload

invariant(
event.type,
'Failed to emit a forwarded life-cycle event: request payload corrupted',
)

// Emit the forwarded life-cycle event on this emitter.
this.emitter.emit(event.type, {
requestId: event.args.requestId,
request: deserializeFetchRequest(event.args.request),
response:

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / build (18)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / build (18)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / exports

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / exports

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / build (20)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / build (20)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (4.8)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (4.8)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (4.9)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (4.9)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.0)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.0)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.1)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.1)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.2)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.2)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.3)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.3)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.4)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.4)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.5)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.

Check failure on line 320 in src/node/setupRemoteServer.ts

View workflow job for this annotation

GitHub Actions / typescript (5.5)

Argument of type '[{ requestId: string; request: Request; response: Response | undefined; error: Error | undefined; }]' is not assignable to parameter of type '[args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: { request: Request; requestId: string; }] | [args: ...] | [args: ...] | [args: ...]'.
event.args.response != null
? deserializeFetchResponse(event.args.response)
: undefined,
error:
event.args.error != null
? deserializeError(event.args.error)
: undefined,
})

outgoing.writeHead(200).end()
}
}

Expand Down Expand Up @@ -426,14 +479,13 @@ export class RemoteClient {
args.request.url,
)

const fetchRequest = args.request.clone()
const responsePromise = new DeferredPromise<Response | undefined>()

fetchRequest.headers.set('accept', 'msw/passthrough')
fetchRequest.headers.set('x-msw-request-url', args.request.url)
fetchRequest.headers.set('x-msw-request-id', args.requestId)
fetchRequest.headers.set('x-msw-boundary-id', args.boundaryId)

const fetchRequest = bypass(args.request, {
headers: {
'x-msw-request-url': args.request.url,
'x-msw-request-id': args.requestId,
'x-msw-boundary-id': args.boundaryId,
},
})
const request = http.request(this.url, {
method: fetchRequest.method,
headers: Object.fromEntries(fetchRequest.headers),
Expand All @@ -445,6 +497,8 @@ export class RemoteClient {
request.end()
}

const responsePromise = new DeferredPromise<Response | undefined>()

request
.once('response', (response) => {
if (response.statusCode === 404) {
Expand Down Expand Up @@ -474,56 +528,134 @@ export class RemoteClient {
return responsePromise
}

// public async handleLifeCycleEvent<
// EventType extends keyof LifeCycleEventsMap,
// >(event: {
// type: EventType
// args: LifeCycleEventsMap[EventType][0]
// }): Promise<void> {
// const url = new URL('/life-cycle-events', this.url)
// const payload: Record<string, unknown> = {
// event: event.type,
// requestId: event.args.requestId,
// request: {
// url: event.args.request.url,
// method: event.args.request.method,
// headers: Array.from(event.args.request.headers),
// body: await event.args.request.arrayBuffer(),
// },
// }

// switch (event.type) {
// case 'unhandledException': {
// payload.error = event.args.error
// break
// }

// case 'response:bypass':
// case 'response:mocked': {
// payload.response = {
// status: event.args.response.status,
// statustext: event.args.response.statusText,
// headers: Array.from(event.args.response.headers),
// body: await event.args.response.arrayBuffer(),
// }
// break
// }
// }

// const response = await fetch(url, {
// method: 'POST',
// headers: {
// 'content-type': 'application/json',
// },
// body: JSON.stringify(payload),
// })

// invariant(
// response && response.ok,
// 'Failed to forward a life-cycle event "%s" (%s %s) to the remote',
// event.type,
// event.args.request.method,
// event.args.request.url,
// )
// }
public async handleLifeCycleEvent<
EventType extends keyof LifeCycleEventsMap,
>(event: {
type: EventType
args: LifeCycleEventsMap[EventType][0]
}): Promise<void> {
invariant(
this.connected,
'Failed to forward life-cycle events for "%s %s": remote client not connected',
event.args.request.method,
event.args.request.url,
)

const url = new URL('/life-cycle-events', this.url)
const payload = JSON.stringify({
type: event.type,
args: {
requestId: event.args.requestId,
request: await serializeFetchRequest(event.args.request),
response:
'response' in event.args
? await serializeFetchResponse(event.args.response)
: undefined,
error:
'error' in event.args ? serializeError(event.args.error) : undefined,
},
} satisfies ForwardedLifeCycleEventPayload)

invariant(
payload,
'Failed to serialize a life-cycle event "%s" for request "%s %s"',
event.type,
event.args.request.method,
event.args.request.url,
)

const donePromise = new DeferredPromise<void>()

http
.request(
url,
{
method: 'POST',
headers: {
accept: 'msw/passthrough',
'content-type': 'application/json',
},
},
(response) => {
if (response.statusCode === 200) {
donePromise.resolve()
} else {
donePromise.reject(
new Error(
`Failed to forward life-cycle event "${event.type}" for request "${event.args.request.method} ${event.args.request.url}": expected a 200 response but got ${response.statusCode}`,
),
)
}
},
)
.end(payload)
.once('error', (error) => {
// eslint-disable-next-line no-console
console.error(error)
donePromise.reject(
new Error(
`Failed to forward life-cycle event "${event.type}" for request "${event.args.request.method} ${event.args.request.url}": unexpected error. There's likely additional information above.`,
),
)
})

return donePromise
}
}

async function serializeFetchRequest(request: Request) {
return {
url: request.url,
method: request.method,
headers: Array.from(request.headers),
body: request.body ? await request.arrayBuffer() : null,
}
}

function deserializeFetchRequest(
value: NonNullable<ForwardedLifeCycleEventPayload['args']['request']>,
): Request {
return new Request(value.url, {
method: value.method,
headers: value.headers,
body: value.body,
})
}

async function serializeFetchResponse(response: Response) {
return {
status: response.status,
statusText: response.statusText,
headers: Array.from(response.headers),
body: await response.arrayBuffer(),
}
}

function deserializeFetchResponse(
value: NonNullable<ForwardedLifeCycleEventPayload['args']['response']>,
): Response {
return new Response(value.body, {
status: value.status,
statusText: value.statusText,
headers: value.headers,
})
}

function serializeError(
error: Error,
): ForwardedLifeCycleEventPayload['args']['error'] {
return {
name: error.name,
message: error.message,
stack: error.stack,
}
}

function deserializeError(
value: NonNullable<ForwardedLifeCycleEventPayload['args']['error']>,
): Error {
const error = new Error(value.message)
error.name = value.name
error.stack = value.stack
return error
}

0 comments on commit 172dbd7

Please sign in to comment.