Skip to content

Commit 51d5ab1

Browse files
committed
Memoise overlapping-parameter cardinality
* Replace direct parameter-pair matching with a dedicated memoised factory that caches cardinality per acceptable→available pair using reference-equality keys for faster repeated comparisons. * Add fast-path check for empty parameter lists to avoid unnecessary work. * Replace mutable local names with clearer identifiers and switch to a WeakMap-style WM for mapping acceptable→available. * Use the memoised cardinality function in both sorting and final ranking to ensure consistent and cheaper overlap calculations. * Small renames and code structure changes to make intent and caching explicit. * Use Readonly for parsed inputs and Readonly keys in the qMap to reflect intent and prevent accidental mutation. * Sort and compare using cached cardinalities rather than recomputing full overlapping-parameter lists, reducing allocation and CPU overhead in the common negotiation path.
1 parent 87c578a commit 51d5ab1

File tree

5 files changed

+124
-40
lines changed

5 files changed

+124
-40
lines changed

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@apeleghq/http-media-type-negotiator",
3-
"version": "1.0.7",
3+
"version": "1.0.8",
44
"description": "RFC-aware HTTP media type negotiator - parses and normalises Accept headers and media types (RFC 9110), supports q-value ranking, wildcard matching, and a permissive mode for real-world headers.",
55
"type": "module",
66
"main": "dist/index.cjs",

src/defaultStrategy.ts

Lines changed: 77 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -14,28 +14,65 @@
1414
*/
1515

1616
import { $subtype, $type } from './constants.js';
17+
import findOverlappingParams from './lib/findOverlappingParams.js';
1718
import type { IMediaTypeNegotiationStrategy } from './negotiateMediaTypeFactory.js';
18-
import type { TMediaType } from './parseMediaType.js';
19+
import type { TNormalisedMediaType } from './normaliseMediaType.js';
1920
import { wm } from './utils.js';
2021

2122
/**
22-
* Find parameters that overlap between two media types, excluding the `q`
23-
* parameter.
23+
* Factory that produces a memoised function to compute the number of
24+
* overlapping parameter names between two normalised media type tuples.
25+
*
26+
* The factory is generic over two tuple types representing normalised media
27+
* types: each tuple is expected to be the same shape as `TNormalisedMediaType`.
28+
*
29+
* Notes:
30+
* - This function uses reference equality for caching: the `acceptable` and
31+
* `available` arguments are used as keys by identity (===).
32+
* - Fast path: if either media type has no parameters (second tuple element
33+
* has length 0) the function returns 0 immediately.
2434
*
2535
* @internal
26-
* @param a - Candidate media type (typically from `Accept` header).
27-
* @param b - Available media type (server-provided).
28-
* @returns Array of matching `[name, value]` parameter pairs.
36+
* @template TA - readonly normalised media type for "acceptable"
37+
* @template TB - readonly normalised media type for "available"
38+
* @returns A memoised function that returns the number of overlapping
39+
* parameter names between `acceptable` and `available`.
2940
*/
30-
const findOverlappingParams = (a: TMediaType, b: TMediaType) => {
31-
return a[1].filter((aparam) => {
32-
return (
33-
aparam[0] !== 'q' &&
34-
b[1].some((bparam) => {
35-
return aparam[0] === bparam[0] && aparam[1] === bparam[1];
36-
})
37-
);
38-
});
41+
const findOverlappingParamsCardinalityFactory = <
42+
TA extends Readonly<TNormalisedMediaType>,
43+
TB extends Readonly<TNormalisedMediaType>,
44+
>() => {
45+
const cache: [acceptable: TA, [available: TB, cardinality: number][]][] =
46+
[];
47+
48+
return (acceptable: TA, available: TB): number => {
49+
// Fast path
50+
if (acceptable[1].length === 0 || available[1].length === 0) {
51+
return 0;
52+
}
53+
54+
let subcache: (typeof cache)[number][1] | undefined;
55+
for (let i = 0; i < cache.length; i++) {
56+
if (cache[i][0] === acceptable) {
57+
subcache = cache[i][1];
58+
for (let j = 0; j < subcache.length; j++) {
59+
if (subcache[j][0] === available) {
60+
return subcache[j][1];
61+
}
62+
}
63+
}
64+
}
65+
66+
if (!subcache) {
67+
subcache = [];
68+
cache.push([acceptable, subcache]);
69+
}
70+
71+
const cardinality = findOverlappingParams(acceptable, available).length;
72+
subcache.push([available, cardinality]);
73+
74+
return cardinality;
75+
};
3976
};
4077

4178
/**
@@ -78,46 +115,52 @@ const findOverlappingParams = (a: TMediaType, b: TMediaType) => {
78115
* `parsedAvailableTypes` array, or `null` if no suitable match is found.
79116
*/
80117
const defaultStrategy = ((availableMediaTypes, acceptableMediaTypes, qMap) => {
81-
const map = wm<
118+
const acceptableToAvailableMap = wm<
82119
(typeof acceptableMediaTypes)[number],
83120
(typeof availableMediaTypes)[number]
84121
>();
122+
const findOverlappingParamsCardinality =
123+
findOverlappingParamsCardinalityFactory();
85124

86125
// First pass: remove media types that aren't possible
87126
const overlappingTypes = acceptableMediaTypes.filter(
88127
(acceptableMediaType) => {
89-
const possible: (typeof availableMediaTypes)[number][] = [];
128+
const matchingAvailableLit: (typeof availableMediaTypes)[number][] =
129+
[];
90130
availableMediaTypes.forEach((availableMediaType) => {
91131
if (acceptableMediaType[0] === availableMediaType[0]) {
92-
possible.push(availableMediaType);
132+
matchingAvailableLit.push(availableMediaType);
93133
} else if (
94134
acceptableMediaType[$subtype] === '*' &&
95135
acceptableMediaType[$type] === availableMediaType[$type]
96136
) {
97-
possible.push(availableMediaType);
137+
matchingAvailableLit.push(availableMediaType);
98138
} else if (
99139
acceptableMediaType[$type] === '*' &&
100140
acceptableMediaType[$subtype] === '*'
101141
) {
102-
possible.push(availableMediaType);
142+
matchingAvailableLit.push(availableMediaType);
103143
}
104144
});
105145

106-
possible.sort((a, b) => {
107-
const overlappingA = findOverlappingParams(
146+
matchingAvailableLit.sort((a, b) => {
147+
const ca = findOverlappingParamsCardinality(
108148
acceptableMediaType,
109149
a,
110-
).length;
111-
const overlappingB = findOverlappingParams(
150+
);
151+
const cb = findOverlappingParamsCardinality(
112152
acceptableMediaType,
113153
b,
114-
).length;
154+
);
115155

116-
return overlappingB - overlappingA;
156+
return cb - ca;
117157
});
118158

119-
if (possible.length) {
120-
map.set(acceptableMediaType, possible[0]);
159+
if (matchingAvailableLit.length) {
160+
acceptableToAvailableMap.set(
161+
acceptableMediaType,
162+
matchingAvailableLit[0],
163+
);
121164
return true;
122165
}
123166

@@ -151,13 +194,13 @@ const defaultStrategy = ((availableMediaTypes, acceptableMediaTypes, qMap) => {
151194
return -1;
152195
}
153196

154-
const availableA = map.get(a)!;
155-
const availableB = map.get(b)!;
197+
const availableA = acceptableToAvailableMap.get(a)!;
198+
const availableB = acceptableToAvailableMap.get(b)!;
156199

157-
const overlappingA = findOverlappingParams(a, availableA).length;
158-
const overlappingB = findOverlappingParams(b, availableB).length;
200+
const ca = findOverlappingParamsCardinality(a, availableA);
201+
const cb = findOverlappingParamsCardinality(a, availableB);
159202

160-
const result = overlappingB - overlappingA;
203+
const result = cb - ca;
161204
if (result !== 0) {
162205
return result;
163206
}
@@ -170,7 +213,7 @@ const defaultStrategy = ((availableMediaTypes, acceptableMediaTypes, qMap) => {
170213
});
171214

172215
const highestRanked = overlappingTypes[0];
173-
const availableType = map.get(highestRanked)!;
216+
const availableType = acceptableToAvailableMap.get(highestRanked)!;
174217

175218
return availableType;
176219
}) satisfies IMediaTypeNegotiationStrategy;

src/lib/findOverlappingParams.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/* Copyright © 2025 Apeleg Limited. All rights reserved.
2+
*
3+
* Permission to use, copy, modify, and distribute this software for any
4+
* purpose with or without fee is hereby granted, provided that the above
5+
* copyright notice and this permission notice appear in all copies.
6+
*
7+
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8+
* REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
9+
* AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10+
* INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
11+
* LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
12+
* OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
13+
* PERFORMANCE OF THIS SOFTWARE.
14+
*/
15+
16+
import type { TMediaType } from '../parseMediaType.js';
17+
18+
/**
19+
* Find parameters that overlap between two media types, excluding the `q`
20+
* parameter.
21+
*
22+
* @internal
23+
* @param a - Candidate media type (typically from `Accept` header).
24+
* @param b - Available media type (server-provided).
25+
* @returns Array of matching `[name, value]` parameter pairs.
26+
*/
27+
const findOverlappingParams = (
28+
a: Readonly<TMediaType>,
29+
b: Readonly<TMediaType>,
30+
): TMediaType[1] => {
31+
return a[1].filter((aparam) => {
32+
return (
33+
aparam[0] !== 'q' &&
34+
b[1].some((bparam) => {
35+
return aparam[0] === bparam[0] && aparam[1] === bparam[1];
36+
})
37+
);
38+
});
39+
};
40+
41+
export default findOverlappingParams;

src/negotiateMediaTypeFactory.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ import { wm } from './utils.js';
5353
*/
5454
interface IMediaTypeNegotiationStrategy {
5555
(
56-
parsedAvailableTypes: readonly TNormalisedMediaType[],
57-
parsedAcceptableTypes: readonly TNormalisedMediaType[],
58-
qMap: Readonly<WeakMap<TMediaType, number>>,
56+
parsedAvailableTypes: readonly Readonly<TNormalisedMediaType>[],
57+
parsedAcceptableTypes: readonly Readonly<TNormalisedMediaType>[],
58+
qMap: Readonly<WeakMap<Readonly<TMediaType>, number>>,
5959
): TNormalisedMediaType | null;
6060
}
6161

0 commit comments

Comments
 (0)