Skip to content

Commit 1430de0

Browse files
committed
Additional 30% memory reduction by simplifying API data observability
With this change, we no longer use a lazy observable promise for this, and we no longer instantiate the promise at all before it's required, and we fix an issue where the list itself triggered API lookup for all visible rows. This drops the per-exchange memory overhead by about 3.7KB in some test examples.
1 parent ea34064 commit 1430de0

File tree

8 files changed

+109
-76
lines changed

8 files changed

+109
-76
lines changed

src/components/view/http/http-details-pane.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ export class HttpDetailsPane extends React.Component<{
101101
// The full API details - for paid APIs, and non-paid users, we don't show
102102
// the detailed API data in any of the cards, we just show the name (below)
103103
// in a collapsed API card.
104-
const apiExchange = (isPaidUser || exchange.api?.isBuiltInApi)
104+
const apiExchange = (isPaidUser || exchange.apiSpec?.isBuiltInApi)
105105
? exchange.api
106106
: undefined;
107107

src/components/view/view-event-list.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ const EventRow = observer((props: EventRowProps) => {
348348
tlsEvent={event}
349349
/>;
350350
} else if (event.isHttp()) {
351-
if (event.api?.isBuiltInApi && event.api.matchedOperation()) {
351+
if (event.apiSpec?.isBuiltInApi && event.api?.matchedOperation()) {
352352
return <BuiltInApiRow
353353
index={index}
354354
isSelected={isSelected}

src/model/api/api-interfaces.ts

-7
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,6 @@ export type ApiSpec =
2121

2222
export interface ApiExchange {
2323

24-
/**
25-
* Built-in API specs are special: they're shown for free users too, and they're
26-
* shown differently in the list (highlighting the operation, not the normal
27-
* lower-level HTTP details).
28-
*/
29-
readonly isBuiltInApi: boolean;
30-
3124
readonly service: ApiService;
3225
readonly operation: ApiOperation;
3326
readonly request: ApiRequest;

src/model/api/build-api-metadata.ts

+3
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { OpenRpcDocument, OpenRpcMetadata } from './jsonrpc';
1313
export interface OpenApiMetadata {
1414
type: 'openapi';
1515
spec: OpenAPIObject;
16+
isBuiltInApi: boolean;
1617
serverMatcher: RegExp;
1718
requestMatchers: Map<OpenApiRequestMatcher, Path>;
1819
}
@@ -167,6 +168,7 @@ export async function buildOpenApiMetadata(
167168
return {
168169
type: 'openapi',
169170
spec,
171+
isBuiltInApi: spec.info['x-httptoolkit-builtin-api'] === true,
170172
serverMatcher,
171173
requestMatchers
172174
};
@@ -201,6 +203,7 @@ export function buildOpenRpcMetadata(spec: OpenRpcDocument, baseUrlOverrides?: s
201203
return {
202204
type: 'openrpc',
203205
spec,
206+
isBuiltInApi: spec.info['x-httptoolkit-builtin-api'] === true,
204207
serverMatcher,
205208
requestMatchers: _.keyBy(spec.methods, 'name') as _.Dictionary<MethodObject> // Dereferenced
206209
};

src/model/api/jsonrpc.ts

+6-9
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
OpenrpcDocument
1111
} from "@open-rpc/meta-schema";
1212
import { SchemaObject } from "openapi-directory";
13-
import { HtkResponse, Html, HttpExchange } from "../../types";
13+
import { HtkResponse, Html, ViewableHttpExchange } from "../../types";
1414
import { ErrorLike, isErrorLike } from "../../util/error";
1515
import { fromMarkdown } from "../ui/markdown";
1616
import {
@@ -27,13 +27,14 @@ export type OpenRpcDocument = OpenrpcDocument;
2727
export interface OpenRpcMetadata {
2828
type: 'openrpc';
2929
spec: OpenRpcDocument;
30+
isBuiltInApi: boolean;
3031
serverMatcher: RegExp;
3132
requestMatchers: { [methodName: string] : MethodObject }; // JSON-RPC method name to method
3233
}
3334

3435
export async function parseRpcApiExchange(
3536
api: OpenRpcMetadata,
36-
exchange: HttpExchange
37+
exchange: ViewableHttpExchange
3738
): Promise<JsonRpcApiExchange> {
3839
try {
3940
const body = await exchange.request.body.waitForDecoding();
@@ -77,11 +78,9 @@ export class JsonRpcApiExchange implements ApiExchange {
7778

7879
constructor(
7980
_api: OpenRpcMetadata,
80-
_exchange: HttpExchange,
81+
_exchange: ViewableHttpExchange,
8182
private _rpcMethod: MatchedOperation | ErrorLike
8283
) {
83-
this.isBuiltInApi = _api.spec.info['x-httptoolkit-builtin-api'] === true;
84-
8584
this.service = new JsonRpcApiService(_api);
8685

8786
if (isErrorLike(_rpcMethod)) {
@@ -95,12 +94,10 @@ export class JsonRpcApiExchange implements ApiExchange {
9594
_rpcMethod,
9695
_api.spec.externalDocs?.['x-method-base-url'] // Custom extension
9796
);
98-
this.request = new JsonRpcApiRequest(_rpcMethod, _exchange);
97+
this.request = new JsonRpcApiRequest(_rpcMethod);
9998
}
10099
}
101100

102-
readonly isBuiltInApi: boolean;
103-
104101
readonly service: ApiService;
105102
readonly operation: ApiOperation;
106103
readonly request: ApiRequest;
@@ -180,7 +177,7 @@ const capitalizeFirst = (input: string | undefined) =>
180177

181178
export class JsonRpcApiRequest implements ApiRequest {
182179

183-
constructor(rpcMethod: MatchedOperation, exchange: HttpExchange) {
180+
constructor(rpcMethod: MatchedOperation) {
184181
const { methodSpec, parsedBody } = rpcMethod;
185182

186183
this.parameters = (methodSpec.params as ContentDescriptorObject[])

src/model/api/openapi.ts

+5-6
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as _ from 'lodash';
22
import { get } from 'typesafe-get';
3+
import { action, observable } from 'mobx';
34

45
import type {
56
OpenAPIObject,
@@ -14,7 +15,7 @@ import Ajv from 'ajv';
1415
import addFormats from 'ajv-formats';
1516

1617
import {
17-
HttpExchange,
18+
ViewableHttpExchange,
1819
ExchangeMessage,
1920
HtkRequest,
2021
HtkResponse,
@@ -286,9 +287,7 @@ function stripTags(input: string | undefined): string | undefined {
286287
}
287288

288289
export class OpenApiExchange implements ApiExchange {
289-
constructor(api: OpenApiMetadata, exchange: HttpExchange) {
290-
this.isBuiltInApi = api.spec.info['x-httptoolkit-builtin-api'] === true;
291-
290+
constructor(api: OpenApiMetadata, exchange: ViewableHttpExchange) {
292291
const { request } = exchange;
293292
this.service = new OpenApiService(api.spec);
294293

@@ -306,14 +305,14 @@ export class OpenApiExchange implements ApiExchange {
306305
private _spec: OpenAPIObject;
307306
private _opSpec: MatchedOperation;
308307

309-
public readonly isBuiltInApi: boolean;
310-
311308
public readonly service: ApiService;
312309
public readonly operation: ApiOperation;
313310
public readonly request: ApiRequest;
314311

312+
@observable.ref
315313
public response: ApiResponse | undefined;
316314

315+
@action
317316
updateWithResponse(response: HtkResponse | 'aborted' | undefined): void {
318317
if (response === 'aborted' || response === undefined) return;
319318
this.response = new OpenApiResponse(this._spec, this._opSpec, response);

src/model/http/api-detector.ts

+85
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { action, observable, runInAction, when } from 'mobx';
2+
3+
import { logError } from '../../errors';
4+
import { UnreachableCheck } from '../../util/error';
5+
6+
import { ViewableHttpExchange } from '../../types';
7+
8+
import { OpenApiExchange } from '../api/openapi';
9+
import { parseRpcApiExchange } from '../api/jsonrpc';
10+
import { ApiExchange, ApiMetadata } from '../api/api-interfaces';
11+
import { ApiStore } from '../api/api-store';
12+
13+
export class ApiDetector {
14+
15+
constructor(
16+
private exchange: ViewableHttpExchange,
17+
apiStore: ApiStore
18+
) {
19+
apiStore.getApi(exchange.request)
20+
.then(action((apiMetadata: ApiMetadata | undefined) => {
21+
this.apiMetadata = apiMetadata;
22+
})).catch(console.warn);
23+
}
24+
25+
@observable.ref
26+
apiMetadata: ApiMetadata | undefined = undefined;
27+
28+
_parsedApiPromise: Promise<ApiExchange | undefined> | undefined = undefined;
29+
30+
@observable.ref
31+
_parsedApi: ApiExchange | undefined = undefined;
32+
33+
/**
34+
* Reading this starts API parsing, if the API data is available. If this is observed when the API metadata
35+
* becomes available, it will trigger parsing as a side-effect.
36+
*/
37+
get parsedApi(): ApiExchange | undefined {
38+
if (!this.apiMetadata) return;
39+
40+
if (!this._parsedApi && !this._parsedApiPromise) {
41+
this._parsedApiPromise = (async () => {
42+
// We load the spec, but we don't try to parse API requests until we've received
43+
// the whole thing (because e.g. JSON-RPC requests aren't parseable without the body)
44+
await when(() => this.exchange.isCompletedRequest());
45+
46+
// API metadata must be set - we check beforehand, and it's never cleared after setting
47+
const apiMetadata = this.apiMetadata!;
48+
const request = this.exchange.request;
49+
50+
try {
51+
let apiExchange: ApiExchange | undefined;
52+
if (apiMetadata.type === 'openapi') {
53+
apiExchange = new OpenApiExchange(apiMetadata, this.exchange);
54+
} else if (apiMetadata.type === 'openrpc') {
55+
apiExchange = await parseRpcApiExchange(apiMetadata, this.exchange);
56+
} else {
57+
console.log('Unknown API metadata type for host', request.parsedUrl.hostname);
58+
console.log(apiMetadata);
59+
throw new UnreachableCheck(apiMetadata, m => m.type);
60+
}
61+
62+
runInAction(() => {
63+
this._parsedApi = apiExchange;
64+
});
65+
66+
if (!this.exchange.isCompletedExchange()) {
67+
when(() => this.exchange.isCompletedExchange()).then(async () => {
68+
if (this.exchange.response) {
69+
apiExchange!.updateWithResponse(this.exchange.response);
70+
}
71+
});
72+
}
73+
74+
return apiExchange;
75+
} catch (e) {
76+
logError(e);
77+
throw e;
78+
}
79+
})();
80+
}
81+
82+
return this._parsedApi;
83+
}
84+
85+
}

src/model/http/exchange.ts

+8-52
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,9 @@ import {
1414
MockttpBreakpointResponseResult,
1515
InputRuleEventDataMap
1616
} from "../../types";
17-
import { UnreachableCheck } from '../../util/error';
18-
import { lazyObservablePromise } from "../../util/observable";
1917
import { getHeaderValue } from '../../util/headers';
2018
import { ParsedUrl } from '../../util/url';
2119

22-
import { logError } from '../../errors';
2320

2421
import { MANUALLY_SENT_SOURCE, parseSource } from './sources';
2522
import { getContentType } from '../events/content-types';
@@ -29,9 +26,7 @@ import { HandlerClassKey, HtkRule, getRulePartKey } from '../rules/rules';
2926

3027
import { ApiStore } from '../api/api-store';
3128
import { ApiExchange } from '../api/api-interfaces';
32-
import { OpenApiExchange } from '../api/openapi';
33-
import { parseRpcApiExchange } from '../api/jsonrpc';
34-
import { ApiMetadata } from '../api/api-interfaces';
29+
import { ApiDetector } from './api-detector';
3530

3631
import { HttpBody } from './http-body';
3732
import {
@@ -189,7 +184,7 @@ export class HttpExchange extends HTKEventBase implements ViewableHttpExchange {
189184
.toLowerCase();
190185

191186
// Start loading the relevant Open API specs for this request, if any.
192-
this._apiMetadataPromise = apiStore.getApi(this.request);
187+
this._apiDetector = new ApiDetector(this, apiStore);
193188
}
194189

195190
public readonly id: string;
@@ -326,15 +321,6 @@ export class HttpExchange extends HTKEventBase implements ViewableHttpExchange {
326321
..._.map(response.headers, (value, key) => `${key}: ${value}`),
327322
..._.map(response.trailers, (value, key) => `${key}: ${value}`)
328323
].join('\n').toLowerCase();
329-
330-
// Wrap the API promise to also add this response data (but lazily)
331-
const requestApiPromise = this._apiPromise;
332-
this._apiPromise = lazyObservablePromise(() =>
333-
requestApiPromise.then((api) => {
334-
if (api) api.updateWithResponse(this.response!);
335-
return api;
336-
})
337-
);
338324
}
339325

340326
// Must only be called when the exchange will no longer be used. Ensures that large data is
@@ -356,43 +342,13 @@ export class HttpExchange extends HTKEventBase implements ViewableHttpExchange {
356342

357343
// API metadata:
358344

359-
// A convenient reference to the service-wide spec for this API - starts loading immediately
360-
private _apiMetadataPromise: Promise<ApiMetadata | undefined>;
361-
362-
// Parsed API info for this specific request, loaded & parsed lazily, only if it's used
363-
@observable.ref
364-
private _apiPromise = lazyObservablePromise(async (): Promise<ApiExchange | undefined> => {
365-
const apiMetadata = await this._apiMetadataPromise;
366-
367-
if (apiMetadata) {
368-
// We load the spec, but we don't try to parse API requests until we've received
369-
// the whole thing (because e.g. JSON-RPC requests aren't parseable without the body)
370-
await when(() => this.isCompletedRequest());
371-
372-
try {
373-
if (apiMetadata.type === 'openapi') {
374-
return new OpenApiExchange(apiMetadata, this);
375-
} else if (apiMetadata.type === 'openrpc') {
376-
return await parseRpcApiExchange(apiMetadata, this);
377-
} else {
378-
console.log('Unknown API metadata type for host', this.request.parsedUrl.hostname);
379-
console.log(apiMetadata);
380-
throw new UnreachableCheck(apiMetadata, m => m.type);
381-
}
382-
} catch (e) {
383-
logError(e);
384-
throw e;
385-
}
386-
} else {
387-
return undefined;
388-
}
389-
});
390-
391-
// Fixed value for the parsed API data - returns the data or undefined, observably.
345+
private _apiDetector: ApiDetector;
392346
get api() {
393-
if (this._apiPromise.state === 'fulfilled') {
394-
return this._apiPromise.value as ApiExchange | undefined;
395-
}
347+
return this._apiDetector.parsedApi;
348+
}
349+
350+
get apiSpec() {
351+
return this._apiDetector.apiMetadata;
396352
}
397353

398354
// Breakpoint data:

0 commit comments

Comments
 (0)