Skip to content

Commit c93d31f

Browse files
committed
Add a 'response-initiated' event
1 parent 031de2d commit c93d31f

File tree

8 files changed

+158
-30
lines changed

8 files changed

+158
-30
lines changed

src/admin/mockttp-admin-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { SubscribableEvent } from "../main";
3131
const graphqlSubscriptionPairs = Object.entries({
3232
'requestInitiated': 'request-initiated',
3333
'requestReceived': 'request',
34+
'responseInitiated': 'response-initiated',
3435
'responseCompleted': 'response',
3536
'webSocketRequest': 'websocket-request',
3637
'webSocketAccepted': 'websocket-accepted',

src/admin/mockttp-schema.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const MockttpSchema = gql`
2020
extend type Subscription {
2121
requestInitiated: InitiatedRequest!
2222
requestReceived: Request!
23+
responseInitiated: InitiatedResponse!
2324
responseCompleted: Response!
2425
webSocketRequest: Request!
2526
webSocketAccepted: Response!
@@ -206,6 +207,18 @@ export const MockttpSchema = gql`
206207
error: Json
207208
}
208209
210+
type InitiatedResponse {
211+
id: ID!
212+
timingEvents: Json!
213+
tags: [String!]!
214+
215+
statusCode: Int!
216+
statusMessage: String!
217+
218+
headers: Json!
219+
rawHeaders: Json!
220+
}
221+
209222
type Response {
210223
id: ID!
211224
timingEvents: Json!

src/client/mockttp-admin-request-builder.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ export class MockttpAdminRequestBuilder {
244244
tags
245245
}
246246
}`,
247+
'response-initiated': gql`subscription OnResponseInitiated {
248+
responseInitiated {
249+
id
250+
statusCode
251+
statusMessage
252+
rawHeaders
253+
timingEvents
254+
tags
255+
}
256+
}`,
247257
response: gql`subscription OnResponse {
248258
responseCompleted {
249259
id

src/mockttp.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
AbortedRequest,
2323
RuleEvent,
2424
RawPassthroughEvent,
25-
RawPassthroughDataEvent
25+
RawPassthroughDataEvent,
26+
InitiatedResponse
2627
} from "./types";
2728
import type { RequestRuleData } from "./rules/requests/request-rule";
2829
import type { WebSocketRuleData } from "./rules/websockets/websocket-rule";
@@ -364,6 +365,21 @@ export interface Mockttp {
364365
*/
365366
on(event: 'request', callback: (req: CompletedRequest) => void): Promise<void>;
366367

368+
/**
369+
* Subscribe to hear about response details as soon as the initial response (the
370+
* status code & headers) are sent, without waiting for the body.
371+
*
372+
* This is only useful in some niche use cases, such as logging all requests seen
373+
* by the server independently of the rules defined.
374+
*
375+
* The callback will be called asynchronously from request handling. This function
376+
* returns a promise, and the callback is not guaranteed to be registered until
377+
* the promise is resolved.
378+
*
379+
* @category Events
380+
*/
381+
on(event: 'response-initiated', callback: (req: InitiatedResponse) => void): Promise<void>;
382+
367383
/**
368384
* Subscribe to hear about response details when the response is completed.
369385
*
@@ -885,6 +901,7 @@ export interface MockttpOptions {
885901
export type SubscribableEvent =
886902
| 'request-initiated'
887903
| 'request'
904+
| 'response-initiated'
888905
| 'response'
889906
| 'websocket-request'
890907
| 'websocket-accepted'

src/server/mockttp-server.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ import {
3232
RawTrailers,
3333
RawPassthroughEvent,
3434
RawPassthroughDataEvent,
35-
RawHeaders
35+
RawHeaders,
36+
InitiatedResponse
3637
} from "../types";
3738
import { DestroyableServer } from "destroyable-server";
3839
import {
@@ -82,7 +83,8 @@ import {
8283
buildInitiatedRequest,
8384
tryToParseHttpRequest,
8485
buildBodyReader,
85-
parseRawHttpResponse
86+
parseRawHttpResponse,
87+
buildInitiatedResponse
8688
} from "../util/request-utils";
8789
import { asBuffer } from "../util/buffer-utils";
8890
import {
@@ -327,6 +329,7 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
327329

328330
public on(event: 'request-initiated', callback: (req: InitiatedRequest) => void): Promise<void>;
329331
public on(event: 'request', callback: (req: CompletedRequest) => void): Promise<void>;
332+
public on(event: 'response-initiated', callback: (req: InitiatedResponse) => void): Promise<void>;
330333
public on(event: 'response', callback: (req: CompletedResponse) => void): Promise<void>;
331334
public on(event: 'abort', callback: (req: InitiatedRequest) => void): Promise<void>;
332335
public on(event: 'websocket-request', callback: (req: CompletedRequest) => void): Promise<void>;
@@ -380,6 +383,21 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
380383
.catch(console.error);
381384
}
382385

386+
private announceInitialResponseAsync(response: OngoingResponse) {
387+
if (this.eventEmitter.listenerCount('response-initiated') === 0) return;
388+
389+
setImmediate(() => {
390+
const initiatedRes = buildInitiatedResponse(response);
391+
this.eventEmitter.emit('response-initiated', Object.assign(
392+
initiatedRes,
393+
{
394+
timingEvents: _.clone(initiatedRes.timingEvents),
395+
tags: _.clone(initiatedRes.tags)
396+
}
397+
));
398+
});
399+
}
400+
383401
private announceResponseAsync(response: OngoingResponse | CompletedResponse) {
384402
if (this.eventEmitter.listenerCount('response') === 0) return;
385403

@@ -790,7 +808,10 @@ export class MockttpServer extends AbstractMockttp implements Mockttp {
790808
rawResponse,
791809
request.timingEvents,
792810
request.tags,
793-
{ maxSize: this.maxBodySize }
811+
{
812+
maxSize: this.maxBodySize,
813+
onWriteHead: () => this.announceInitialResponseAsync(response)
814+
}
794815
);
795816
response.id = request.id;
796817
response.on('error', (error) => {

src/types.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -321,17 +321,20 @@ export interface OngoingResponse extends http.ServerResponse {
321321
tags: string[];
322322
}
323323

324-
export interface CompletedResponse {
324+
export interface InitiatedResponse {
325325
id: string;
326326
statusCode: number;
327327
statusMessage: string;
328328
headers: Headers;
329329
rawHeaders: RawHeaders;
330+
timingEvents: TimingEvents;
331+
tags: string[];
332+
}
333+
334+
export interface CompletedResponse extends InitiatedResponse {
330335
body: CompletedBody;
331336
rawTrailers: RawTrailers;
332337
trailers: Trailers;
333-
timingEvents: TimingEvents;
334-
tags: string[];
335338
}
336339

337340
export interface WebSocketMessage {

src/util/request-utils.ts

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ import {
2424
TimingEvents,
2525
InitiatedRequest,
2626
RawHeaders,
27-
Destination
27+
Destination,
28+
InitiatedResponse
2829
} from "../types";
2930

3031
import {
@@ -354,7 +355,10 @@ export function trackResponse(
354355
response: http.ServerResponse,
355356
timingEvents: TimingEvents,
356357
tags: string[],
357-
options: { maxSize: number }
358+
options: {
359+
maxSize: number,
360+
onWriteHead: () => void
361+
}
358362
): OngoingResponse {
359363
let trackedResponse = <OngoingResponse> response;
360364

@@ -378,6 +382,8 @@ export function trackResponse(
378382
trackedResponse.writeHead = function (this: typeof trackedResponse, ...args: any) {
379383
if (!timingEvents.headersSentTimestamp) {
380384
timingEvents.headersSentTimestamp = now();
385+
// Notify listeners that the head is being written:
386+
options.onWriteHead();
381387
}
382388

383389
// HTTP/2 responses shouldn't have a status message:
@@ -466,41 +472,47 @@ export function trackResponse(
466472
}
467473

468474
/**
469-
* Build a completed response: the external representation of a response
470-
* that's been completely written out and sent back to the client.
475+
* Build an initiated response: the external representation of a response
476+
* that's just started.
471477
*/
472-
export async function waitForCompletedResponse(
473-
response: OngoingResponse | CompletedResponse
474-
): Promise<CompletedResponse> {
475-
// Ongoing response has 'getHeaders' - completed has 'headers'.
476-
if ('headers' in response) return response;
477-
478-
const body = await waitForBody(response.body, response.getHeaders());
479-
response.timingEvents.responseSentTimestamp = response.timingEvents.responseSentTimestamp || now();
480-
481-
const completedResponse: CompletedResponse = _(response).pick([
478+
export function buildInitiatedResponse(response: OngoingResponse): InitiatedResponse {
479+
const initiatedResponse: InitiatedResponse = _(response).pick([
482480
'id',
483481
'statusCode',
484482
'timingEvents',
485483
'tags'
486484
]).assign({
487485
statusMessage: '',
488-
489486
headers: response.getHeaders(),
490487
rawHeaders: response.getRawHeaders(),
491-
492-
body: body,
493-
494-
rawTrailers: response.getRawTrailers(),
495-
trailers: rawHeadersToObject(response.getRawTrailers())
496488
}).valueOf();
497489

498490
if (!(response instanceof http2.Http2ServerResponse)) {
499491
// H2 has no status messages, and generates a warning if you look for one
500-
completedResponse.statusMessage = response.statusMessage;
492+
initiatedResponse.statusMessage = response.statusMessage;
501493
}
502494

503-
return completedResponse;
495+
return initiatedResponse;
496+
}
497+
498+
/**
499+
* Build a completed response: the external representation of a response
500+
* that's been completely written out and sent back to the client.
501+
*/
502+
export async function waitForCompletedResponse(
503+
response: OngoingResponse | CompletedResponse
504+
): Promise<CompletedResponse> {
505+
// Ongoing response has 'getHeaders' - completed has 'headers'.
506+
if ('headers' in response) return response;
507+
508+
const body = await waitForBody(response.body, response.getHeaders());
509+
response.timingEvents.responseSentTimestamp = response.timingEvents.responseSentTimestamp || now();
510+
511+
return Object.assign(buildInitiatedResponse(response), {
512+
body: body,
513+
rawTrailers: response.getRawTrailers(),
514+
trailers: rawHeadersToObject(response.getRawTrailers())
515+
});
504516
}
505517

506518
// Take raw HTTP request bytes received, have a go at parsing something useful out of them.

test/integration/subscriptions/response-events.spec.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as _ from 'lodash';
2+
import { PassThrough } from 'stream';
23
import * as http from 'http';
34
import * as zlib from 'zlib';
45

@@ -7,7 +8,8 @@ import {
78
CompletedRequest,
89
CompletedResponse,
910
TimingEvents,
10-
AbortedRequest
11+
AbortedRequest,
12+
InitiatedResponse
1113
} from "../../..";
1214
import {
1315
expect,
@@ -19,6 +21,55 @@ import {
1921
makeAbortableRequest
2022
} from "../../test-utils";
2123

24+
describe("Response initiated subscriptions", () => {
25+
describe("with a local HTTP server", () => {
26+
let server = getLocal();
27+
28+
beforeEach(() => server.start());
29+
afterEach(() => server.stop());
30+
31+
it("should notify with response details as soon as they're ready", async () => {
32+
let seenResponsePromise = getDeferred<InitiatedResponse>();
33+
await server.on('response-initiated', (r) => seenResponsePromise.resolve(r));
34+
35+
const bodyStream = new PassThrough();
36+
await server.forAnyRequest().thenStream(400, bodyStream, {
37+
'a': 'b',
38+
'access-control-allow-origin': '*',
39+
'access-control-expose-headers': '*'
40+
});
41+
42+
const realResponse = await fetch(server.urlFor("/mocked-endpoint"));
43+
const realResponseHeaders = Object.fromEntries(realResponse.headers as any);
44+
45+
let seenResponse = await seenResponsePromise;
46+
expect(seenResponse.statusCode).to.equal(400);
47+
expect(seenResponse.statusMessage).to.equal('Bad Request');
48+
49+
expect(seenResponse.headers).to.deep.equal(realResponseHeaders);
50+
expect(seenResponse.rawHeaders).to.deep.equal(Object.entries(realResponseHeaders));
51+
52+
expect((seenResponse as any).body).to.equal(undefined); // No body included yet
53+
expect((seenResponse as any).trailers).to.equal(undefined); // No trailers yet
54+
expect((seenResponse as any).rawTrailers).to.equal(undefined);
55+
56+
expect(seenResponse.id).to.be.a('string');
57+
expect(seenResponse.tags).to.deep.equal([]);
58+
59+
const timingEvents = seenResponse.timingEvents;
60+
expect(timingEvents.startTimestamp).to.be.a('number');
61+
expect(timingEvents.bodyReceivedTimestamp).to.be.a('number');
62+
expect(timingEvents.headersSentTimestamp).to.be.a('number');
63+
64+
expect(timingEvents.bodyReceivedTimestamp).to.be.greaterThan(timingEvents.startTimestamp);
65+
expect(timingEvents.headersSentTimestamp).to.be.greaterThan(timingEvents.startTimestamp);
66+
67+
expect(timingEvents.responseSentTimestamp).to.equal(undefined);
68+
expect(timingEvents.abortedTimestamp).to.equal(undefined);
69+
});
70+
});
71+
});
72+
2273
describe("Response subscriptions", () => {
2374

2475
describe("with an HTTP server", () => {

0 commit comments

Comments
 (0)