keryx 0.29.9 → 0.29.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/actions/swagger.ts +23 -17
- package/config/observability.ts +2 -0
- package/config/server/mcp.ts +1 -0
- package/config/server/web.ts +1 -1
- package/package.json +1 -1
- package/servers/web.ts +19 -1
- package/util/config.ts +31 -37
- package/util/http.ts +24 -13
- package/util/mcpServer.ts +9 -1
- package/util/oauth.ts +3 -12
- package/util/webBasicAuth.ts +53 -0
- package/util/webCompression.ts +22 -36
- package/util/webRouting.ts +24 -34
- package/util/zodMixins.ts +5 -8
package/actions/swagger.ts
CHANGED
|
@@ -62,10 +62,10 @@ export class Swagger implements Action {
|
|
|
62
62
|
web = { route: "/swagger", method: HTTP_METHOD.GET };
|
|
63
63
|
|
|
64
64
|
async run() {
|
|
65
|
-
const paths: Record<string,
|
|
65
|
+
const paths: Record<string, Record<string, unknown>> = {};
|
|
66
66
|
const components: {
|
|
67
|
-
schemas: Record<string,
|
|
68
|
-
securitySchemes?: Record<string,
|
|
67
|
+
schemas: Record<string, unknown>;
|
|
68
|
+
securitySchemes?: Record<string, unknown>;
|
|
69
69
|
} = {
|
|
70
70
|
schemas: {},
|
|
71
71
|
securitySchemes: {
|
|
@@ -94,21 +94,23 @@ export class Swagger implements Action {
|
|
|
94
94
|
const description = action.description;
|
|
95
95
|
|
|
96
96
|
// Extract path parameters from the original route
|
|
97
|
-
const parameters:
|
|
97
|
+
const parameters: Array<Record<string, unknown>> = [];
|
|
98
98
|
const pathParamMatches = action.web.route.match(/:\w+/g) || [];
|
|
99
99
|
const pathParamNames = new Set<string>();
|
|
100
100
|
|
|
101
101
|
// Pre-compute Zod JSON Schema for enriching path param types
|
|
102
|
-
let zodProperties: Record<string,
|
|
102
|
+
let zodProperties: Record<string, Record<string, unknown>> = {};
|
|
103
103
|
let zodDescriptions: Record<string, string> = {};
|
|
104
104
|
if (action.inputs && typeof action.inputs.parse === "function") {
|
|
105
105
|
const jsonSchema = z.toJSONSchema(action.inputs, {
|
|
106
106
|
io: "input",
|
|
107
107
|
unrepresentable: "any",
|
|
108
|
-
}) as
|
|
109
|
-
zodProperties =
|
|
110
|
-
|
|
111
|
-
|
|
108
|
+
}) as Record<string, unknown>;
|
|
109
|
+
zodProperties =
|
|
110
|
+
(jsonSchema.properties as Record<string, Record<string, unknown>>) ??
|
|
111
|
+
{};
|
|
112
|
+
for (const [name, propSchema] of Object.entries(zodProperties)) {
|
|
113
|
+
if (typeof propSchema.description === "string") {
|
|
112
114
|
zodDescriptions[name] = propSchema.description;
|
|
113
115
|
}
|
|
114
116
|
}
|
|
@@ -135,16 +137,18 @@ export class Swagger implements Action {
|
|
|
135
137
|
const fullSchema = z.toJSONSchema(action.inputs!, {
|
|
136
138
|
io: "input",
|
|
137
139
|
unrepresentable: "any",
|
|
138
|
-
}) as
|
|
139
|
-
const requiredFields = new Set<string>(
|
|
140
|
-
|
|
140
|
+
}) as Record<string, unknown>;
|
|
141
|
+
const requiredFields = new Set<string>(
|
|
142
|
+
(fullSchema.required as string[] | undefined) ?? [],
|
|
143
|
+
);
|
|
144
|
+
for (const [name, propSchema] of Object.entries(zodProperties)) {
|
|
141
145
|
if (pathParamNames.has(name)) continue; // already a path param
|
|
142
146
|
parameters.push({
|
|
143
147
|
name,
|
|
144
148
|
in: "query",
|
|
145
149
|
required: requiredFields.has(name),
|
|
146
150
|
schema: propSchema,
|
|
147
|
-
...(propSchema.description
|
|
151
|
+
...(typeof propSchema.description === "string"
|
|
148
152
|
? { description: propSchema.description }
|
|
149
153
|
: {}),
|
|
150
154
|
});
|
|
@@ -152,7 +156,7 @@ export class Swagger implements Action {
|
|
|
152
156
|
}
|
|
153
157
|
|
|
154
158
|
// Build requestBody if Zod inputs exist and method supports body
|
|
155
|
-
let requestBody:
|
|
159
|
+
let requestBody: Record<string, unknown> | undefined = undefined;
|
|
156
160
|
if (
|
|
157
161
|
action.inputs &&
|
|
158
162
|
typeof action.inputs.parse === "function" &&
|
|
@@ -166,9 +170,9 @@ export class Swagger implements Action {
|
|
|
166
170
|
const jsonSchema = z.toJSONSchema(zodSchema, {
|
|
167
171
|
io: "input",
|
|
168
172
|
unrepresentable: "any",
|
|
169
|
-
})
|
|
173
|
+
}) as Record<string, unknown>;
|
|
170
174
|
// Remove $schema from component schemas (not needed in OpenAPI)
|
|
171
|
-
const { $schema, ...schemaWithout$schema } = jsonSchema
|
|
175
|
+
const { $schema, ...schemaWithout$schema } = jsonSchema;
|
|
172
176
|
components.schemas[schemaName] = schemaWithout$schema;
|
|
173
177
|
requestBody = {
|
|
174
178
|
required: true,
|
|
@@ -181,7 +185,9 @@ export class Swagger implements Action {
|
|
|
181
185
|
}
|
|
182
186
|
|
|
183
187
|
// Build responses - use generated schema if available
|
|
184
|
-
const responses = JSON.parse(
|
|
188
|
+
const responses: Record<string, unknown> = JSON.parse(
|
|
189
|
+
JSON.stringify(swaggerResponses),
|
|
190
|
+
);
|
|
185
191
|
|
|
186
192
|
if (action.web?.streaming) {
|
|
187
193
|
// Streaming endpoints return SSE
|
package/config/observability.ts
CHANGED
|
@@ -4,4 +4,6 @@ export const configObservability = {
|
|
|
4
4
|
enabled: await loadFromEnvIfSet("OTEL_METRICS_ENABLED", false),
|
|
5
5
|
metricsRoute: await loadFromEnvIfSet("OTEL_METRICS_ROUTE", "/metrics"),
|
|
6
6
|
serviceName: await loadFromEnvIfSet("OTEL_SERVICE_NAME", ""),
|
|
7
|
+
metricsAuthUsername: await loadFromEnvIfSet("OTEL_METRICS_AUTH_USERNAME", ""),
|
|
8
|
+
metricsAuthPassword: await loadFromEnvIfSet("OTEL_METRICS_AUTH_PASSWORD", ""),
|
|
7
9
|
};
|
package/config/server/mcp.ts
CHANGED
|
@@ -17,5 +17,6 @@ export const configServerMcp = {
|
|
|
17
17
|
"MCP_OAUTH_REFRESH_TTL",
|
|
18
18
|
60 * 60 * 24 * 30,
|
|
19
19
|
), // 30 days, in seconds
|
|
20
|
+
oauthTrustProxy: await loadFromEnvIfSet("MCP_OAUTH_TRUST_PROXY", false),
|
|
20
21
|
markdownDepthLimit: await loadFromEnvIfSet("MCP_MARKDOWN_DEPTH_LIMIT", 5),
|
|
21
22
|
};
|
package/config/server/web.ts
CHANGED
|
@@ -70,7 +70,7 @@ export const configServerWeb = {
|
|
|
70
70
|
compression: {
|
|
71
71
|
enabled: await loadFromEnvIfSet("WEB_COMPRESSION_ENABLED", true),
|
|
72
72
|
threshold: await loadFromEnvIfSet("WEB_COMPRESSION_THRESHOLD", 1024),
|
|
73
|
-
encodings: ["
|
|
73
|
+
encodings: ["gzip"] as "gzip"[],
|
|
74
74
|
},
|
|
75
75
|
correlationId: {
|
|
76
76
|
header: await loadFromEnvIfSet("WEB_CORRELATION_ID_HEADER", "X-Request-Id"),
|
package/package.json
CHANGED
package/servers/web.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { parse } from "node:url";
|
|
2
3
|
import type { ServerWebSocket } from "bun";
|
|
3
|
-
import { randomUUID } from "crypto";
|
|
4
4
|
import { api, logger } from "../api";
|
|
5
5
|
import { type HTTP_METHOD } from "../classes/Action";
|
|
6
6
|
import { Connection } from "../classes/Connection";
|
|
@@ -11,6 +11,7 @@ import { config } from "../config";
|
|
|
11
11
|
import type { PubSubMessage } from "../initializers/pubsub";
|
|
12
12
|
import { ansi } from "../util/ansi";
|
|
13
13
|
import { isOriginAllowed } from "../util/http";
|
|
14
|
+
import { verifyBasicAuth } from "../util/webBasicAuth";
|
|
14
15
|
import { compressResponse } from "../util/webCompression";
|
|
15
16
|
import {
|
|
16
17
|
buildError,
|
|
@@ -325,6 +326,23 @@ export class WebServer extends Server<ReturnType<typeof Bun.serve>> {
|
|
|
325
326
|
config.observability.enabled &&
|
|
326
327
|
parsedUrl.pathname === config.observability.metricsRoute
|
|
327
328
|
) {
|
|
329
|
+
if (
|
|
330
|
+
!verifyBasicAuth(
|
|
331
|
+
req,
|
|
332
|
+
config.observability.metricsAuthUsername,
|
|
333
|
+
config.observability.metricsAuthPassword,
|
|
334
|
+
)
|
|
335
|
+
) {
|
|
336
|
+
return {
|
|
337
|
+
response: new Response("Unauthorized", {
|
|
338
|
+
status: 401,
|
|
339
|
+
headers: {
|
|
340
|
+
"WWW-Authenticate": 'Basic realm="Metrics"',
|
|
341
|
+
"Content-Type": "text/plain",
|
|
342
|
+
},
|
|
343
|
+
}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
328
346
|
const body = await api.observability.collectMetrics();
|
|
329
347
|
return {
|
|
330
348
|
response: new Response(body || "", {
|
package/util/config.ts
CHANGED
|
@@ -1,62 +1,56 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*/
|
|
5
|
-
export function deepMerge<T extends Record<string, any>>(
|
|
1
|
+
type MergeMode = "overwrite" | "defaults";
|
|
2
|
+
|
|
3
|
+
function mergeWith<T extends Record<string, unknown>>(
|
|
6
4
|
target: T,
|
|
7
|
-
source: Record<string,
|
|
5
|
+
source: Record<string, unknown>,
|
|
6
|
+
mode: MergeMode,
|
|
8
7
|
): T {
|
|
8
|
+
const writable = target as Record<string, unknown>;
|
|
9
9
|
for (const key of Object.keys(source)) {
|
|
10
10
|
const targetVal = target[key];
|
|
11
11
|
const sourceVal = source[key];
|
|
12
|
-
|
|
13
|
-
if (
|
|
12
|
+
const bothPlainObjects =
|
|
14
13
|
targetVal &&
|
|
15
14
|
sourceVal &&
|
|
16
15
|
typeof targetVal === "object" &&
|
|
17
16
|
typeof sourceVal === "object" &&
|
|
18
17
|
!Array.isArray(targetVal) &&
|
|
19
|
-
!Array.isArray(sourceVal)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
18
|
+
!Array.isArray(sourceVal);
|
|
19
|
+
|
|
20
|
+
if (bothPlainObjects) {
|
|
21
|
+
mergeWith(
|
|
22
|
+
targetVal as Record<string, unknown>,
|
|
23
|
+
sourceVal as Record<string, unknown>,
|
|
24
|
+
mode,
|
|
25
|
+
);
|
|
26
|
+
} else if (mode === "overwrite" || !(key in target)) {
|
|
27
|
+
writable[key] = sourceVal;
|
|
24
28
|
}
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
return target;
|
|
28
32
|
}
|
|
29
33
|
|
|
34
|
+
/**
|
|
35
|
+
Deep-merges source into target, mutating target in place.
|
|
36
|
+
Only plain objects are recursively merged; arrays and primitives are overwritten.
|
|
37
|
+
*/
|
|
38
|
+
export function deepMerge<T extends Record<string, unknown>>(
|
|
39
|
+
target: T,
|
|
40
|
+
source: Record<string, unknown>,
|
|
41
|
+
): T {
|
|
42
|
+
return mergeWith(target, source, "overwrite");
|
|
43
|
+
}
|
|
44
|
+
|
|
30
45
|
/**
|
|
31
46
|
* Like `deepMerge`, but only sets values that don't already exist in target.
|
|
32
47
|
* Useful for applying plugin config defaults without overwriting user-set values.
|
|
33
48
|
*/
|
|
34
|
-
export function deepMergeDefaults<T extends Record<string,
|
|
49
|
+
export function deepMergeDefaults<T extends Record<string, unknown>>(
|
|
35
50
|
target: T,
|
|
36
|
-
source: Record<string,
|
|
51
|
+
source: Record<string, unknown>,
|
|
37
52
|
): T {
|
|
38
|
-
|
|
39
|
-
if (!(key in target)) {
|
|
40
|
-
(target as any)[key] = source[key];
|
|
41
|
-
} else {
|
|
42
|
-
const targetVal = target[key];
|
|
43
|
-
const sourceVal = source[key];
|
|
44
|
-
|
|
45
|
-
if (
|
|
46
|
-
targetVal &&
|
|
47
|
-
sourceVal &&
|
|
48
|
-
typeof targetVal === "object" &&
|
|
49
|
-
typeof sourceVal === "object" &&
|
|
50
|
-
!Array.isArray(targetVal) &&
|
|
51
|
-
!Array.isArray(sourceVal)
|
|
52
|
-
) {
|
|
53
|
-
deepMergeDefaults(targetVal, sourceVal);
|
|
54
|
-
}
|
|
55
|
-
// If key exists in target and isn't a nested object, keep the target value
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
return target;
|
|
53
|
+
return mergeWith(target, source, "defaults");
|
|
60
54
|
}
|
|
61
55
|
|
|
62
56
|
/**
|
package/util/http.ts
CHANGED
|
@@ -35,10 +35,20 @@ export function buildCorsHeaders(
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
|
-
* Derive the external-facing origin for a request.
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
38
|
+
* Derive the external-facing origin for a request. Used to construct OAuth
|
|
39
|
+
* metadata URLs (issuer, endpoints) and the MCP `WWW-Authenticate` resource
|
|
40
|
+
* metadata URL.
|
|
41
|
+
*
|
|
42
|
+
* Resolution order:
|
|
43
|
+
* 1. `applicationUrl` config (when set to a non-localhost value).
|
|
44
|
+
* 2. `X-Forwarded-Proto` / `X-Forwarded-Host` (or `Host`) headers — only when
|
|
45
|
+
* `config.server.mcp.oauthTrustProxy` is enabled. These headers are
|
|
46
|
+
* spoofable by any client when the server is reachable directly, so
|
|
47
|
+
* trusting them unconditionally would let an attacker poison OAuth
|
|
48
|
+
* metadata and MCP `WWW-Authenticate` URLs. Operators must opt in via
|
|
49
|
+
* `MCP_OAUTH_TRUST_PROXY=true` after confirming a reverse proxy strips
|
|
50
|
+
* client-supplied forwarded headers.
|
|
51
|
+
* 3. The parsed request-URL origin.
|
|
42
52
|
*/
|
|
43
53
|
export function getExternalOrigin(req: Request, url: URL): string {
|
|
44
54
|
// Prefer explicitly configured APPLICATION_URL (for proxy/tunnel scenarios
|
|
@@ -48,17 +58,18 @@ export function getExternalOrigin(req: Request, url: URL): string {
|
|
|
48
58
|
return new URL(appUrl).origin;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
61
|
+
if (config.server.mcp.oauthTrustProxy) {
|
|
62
|
+
const forwardedProto = req.headers.get("x-forwarded-proto");
|
|
63
|
+
const forwardedHost =
|
|
64
|
+
req.headers.get("x-forwarded-host") || req.headers.get("host");
|
|
55
65
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
if (forwardedProto && forwardedHost) {
|
|
67
|
+
return `${forwardedProto}://${forwardedHost}`;
|
|
68
|
+
}
|
|
59
69
|
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
if (forwardedHost) {
|
|
71
|
+
return `${url.protocol}//${forwardedHost}`;
|
|
72
|
+
}
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
return url.origin;
|
package/util/mcpServer.ts
CHANGED
|
@@ -3,6 +3,11 @@ import {
|
|
|
3
3
|
ResourceTemplate,
|
|
4
4
|
} from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import type { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
|
6
|
+
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
7
|
+
import type {
|
|
8
|
+
ServerNotification,
|
|
9
|
+
ServerRequest,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
6
11
|
import { randomUUID } from "crypto";
|
|
7
12
|
import * as z4mini from "zod/v4-mini";
|
|
8
13
|
import { api, logger } from "../api";
|
|
@@ -159,7 +164,10 @@ function registerTools(mcpServer: McpServer) {
|
|
|
159
164
|
mcpServer.registerTool(
|
|
160
165
|
toolName,
|
|
161
166
|
toolConfig,
|
|
162
|
-
async (
|
|
167
|
+
async (
|
|
168
|
+
args: Record<string, unknown>,
|
|
169
|
+
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
|
170
|
+
) => {
|
|
163
171
|
const mcpSessionId = extra.sessionId || "";
|
|
164
172
|
const connection = await createMcpConnection(extra);
|
|
165
173
|
|
package/util/oauth.ts
CHANGED
|
@@ -40,23 +40,14 @@ export function validateRedirectUri(uri: string): {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
* Compare two redirect URIs
|
|
44
|
-
*
|
|
43
|
+
* Compare two redirect URIs with exact string matching, as required by
|
|
44
|
+
* RFC 6749 §3.1.2.3 and RFC 8252 §8.4.
|
|
45
45
|
*/
|
|
46
46
|
export function redirectUrisMatch(
|
|
47
47
|
registeredUri: string,
|
|
48
48
|
requestedUri: string,
|
|
49
49
|
): boolean {
|
|
50
|
-
|
|
51
|
-
const registered = new URL(registeredUri);
|
|
52
|
-
const requested = new URL(requestedUri);
|
|
53
|
-
return (
|
|
54
|
-
registered.origin === requested.origin &&
|
|
55
|
-
registered.pathname === requested.pathname
|
|
56
|
-
);
|
|
57
|
-
} catch {
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
50
|
+
return registeredUri === requestedUri;
|
|
60
51
|
}
|
|
61
52
|
|
|
62
53
|
/** Encode a byte array as a URL-safe base64 string (no padding). Used for PKCE code challenges. */
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { timingSafeEqual } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Pads to a common length so we don't leak the expected length via early-return.
|
|
4
|
+
function timingSafeStringEqual(a: string, b: string): boolean {
|
|
5
|
+
const aBuf = Buffer.from(a, "utf8");
|
|
6
|
+
const bBuf = Buffer.from(b, "utf8");
|
|
7
|
+
const len = Math.max(aBuf.length, bBuf.length, 1);
|
|
8
|
+
const aPadded = Buffer.alloc(len);
|
|
9
|
+
const bPadded = Buffer.alloc(len);
|
|
10
|
+
aBuf.copy(aPadded);
|
|
11
|
+
bBuf.copy(bPadded);
|
|
12
|
+
return timingSafeEqual(aPadded, bPadded) && aBuf.length === bBuf.length;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Verifies an HTTP Basic auth `Authorization` header against expected credentials
|
|
17
|
+
* using a constant-time string compare.
|
|
18
|
+
*
|
|
19
|
+
* @param req - The incoming `Request`. Read for its `Authorization` header.
|
|
20
|
+
* @param expectedUsername - The username to match. If empty, auth is treated as
|
|
21
|
+
* disabled and the function returns `true`.
|
|
22
|
+
* @param expectedPassword - The password to match. If empty, auth is treated as
|
|
23
|
+
* disabled and the function returns `true`.
|
|
24
|
+
* @returns `true` when auth is disabled (either credential empty) or when the
|
|
25
|
+
* header carries valid `Basic <base64>` credentials matching both expected
|
|
26
|
+
* values. `false` for any malformed, missing, or wrong header.
|
|
27
|
+
*/
|
|
28
|
+
export function verifyBasicAuth(
|
|
29
|
+
req: Request,
|
|
30
|
+
expectedUsername: string,
|
|
31
|
+
expectedPassword: string,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (!expectedUsername || !expectedPassword) return true;
|
|
34
|
+
|
|
35
|
+
const header = req.headers.get("Authorization");
|
|
36
|
+
if (!header?.startsWith("Basic ")) return false;
|
|
37
|
+
|
|
38
|
+
let decoded: string;
|
|
39
|
+
try {
|
|
40
|
+
decoded = atob(header.slice(6).trim());
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const idx = decoded.indexOf(":");
|
|
46
|
+
if (idx === -1) return false;
|
|
47
|
+
const user = decoded.slice(0, idx);
|
|
48
|
+
const pass = decoded.slice(idx + 1);
|
|
49
|
+
|
|
50
|
+
const userOk = timingSafeStringEqual(user, expectedUsername);
|
|
51
|
+
const passOk = timingSafeStringEqual(pass, expectedPassword);
|
|
52
|
+
return userOk && passOk;
|
|
53
|
+
}
|
package/util/webCompression.ts
CHANGED
|
@@ -37,7 +37,7 @@ function parseAcceptEncoding(header: string): Set<string> {
|
|
|
37
37
|
/**
|
|
38
38
|
* Pick the best encoding based on server preference order and client support.
|
|
39
39
|
*/
|
|
40
|
-
function selectEncoding(clientEncodings: Set<string>): "
|
|
40
|
+
function selectEncoding(clientEncodings: Set<string>): "gzip" | null {
|
|
41
41
|
for (const encoding of config.server.web.compression.encodings) {
|
|
42
42
|
if (clientEncodings.has(encoding)) return encoding;
|
|
43
43
|
}
|
|
@@ -53,6 +53,24 @@ function isIncompressible(contentType: string | null): boolean {
|
|
|
53
53
|
return INCOMPRESSIBLE_TYPES.has(mimeType);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Pipe a body through a gzip `CompressionStream` and build a new `Response` carrying the
|
|
58
|
+
* compression headers (`Content-Encoding`, appended `Vary`, removed `Content-Length`).
|
|
59
|
+
*/
|
|
60
|
+
function compressBody(body: ReadableStream, response: Response): Response {
|
|
61
|
+
const compressionStream = new CompressionStream("gzip");
|
|
62
|
+
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
63
|
+
const stream = body.pipeThrough(compressionStream);
|
|
64
|
+
|
|
65
|
+
const headers = new Headers(response.headers);
|
|
66
|
+
headers.set("Content-Encoding", "gzip");
|
|
67
|
+
headers.append("Vary", "Accept-Encoding");
|
|
68
|
+
headers.delete("Content-Length");
|
|
69
|
+
|
|
70
|
+
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
71
|
+
return new Response(stream, { status: response.status, headers });
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
/**
|
|
57
75
|
* Conditionally compress an HTTP response based on the client's `Accept-Encoding` header,
|
|
58
76
|
* the response content type, and the configured compression threshold.
|
|
@@ -86,8 +104,7 @@ export async function compressResponse(
|
|
|
86
104
|
if (!acceptEncoding) return response;
|
|
87
105
|
|
|
88
106
|
const clientEncodings = parseAcceptEncoding(acceptEncoding);
|
|
89
|
-
|
|
90
|
-
if (!encoding) return response;
|
|
107
|
+
if (!selectEncoding(clientEncodings)) return response;
|
|
91
108
|
|
|
92
109
|
// Skip incompressible content types
|
|
93
110
|
if (isIncompressible(response.headers.get("Content-Type"))) return response;
|
|
@@ -112,40 +129,9 @@ export async function compressResponse(
|
|
|
112
129
|
});
|
|
113
130
|
}
|
|
114
131
|
|
|
115
|
-
|
|
116
|
-
const format: Bun.CompressionFormat = encoding === "br" ? "brotli" : "gzip";
|
|
117
|
-
// @ts-ignore Bun supports "brotli" as CompressionFormat but DOM lib does not
|
|
118
|
-
const compressionStream = new CompressionStream(format);
|
|
119
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
120
|
-
const stream = new Blob([body]).stream().pipeThrough(compressionStream);
|
|
121
|
-
|
|
122
|
-
const headers = new Headers(response.headers);
|
|
123
|
-
headers.set("Content-Encoding", encoding);
|
|
124
|
-
headers.append("Vary", "Accept-Encoding");
|
|
125
|
-
headers.delete("Content-Length");
|
|
126
|
-
|
|
127
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
128
|
-
return new Response(stream, {
|
|
129
|
-
status: response.status,
|
|
130
|
-
headers,
|
|
131
|
-
});
|
|
132
|
+
return compressBody(new Blob([body]).stream(), response);
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
// Content-Length is present and above threshold — stream-compress
|
|
135
|
-
|
|
136
|
-
// @ts-ignore Bun supports "brotli" as CompressionFormat but DOM lib does not
|
|
137
|
-
const compressionStream = new CompressionStream(format);
|
|
138
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
139
|
-
const stream = response.body.pipeThrough(compressionStream);
|
|
140
|
-
|
|
141
|
-
const headers = new Headers(response.headers);
|
|
142
|
-
headers.set("Content-Encoding", encoding);
|
|
143
|
-
headers.append("Vary", "Accept-Encoding");
|
|
144
|
-
headers.delete("Content-Length");
|
|
145
|
-
|
|
146
|
-
// @ts-ignore Bun's ReadableStream type is incompatible with Node/DOM ReadableStream
|
|
147
|
-
return new Response(stream, {
|
|
148
|
-
status: response.status,
|
|
149
|
-
headers,
|
|
150
|
-
});
|
|
136
|
+
return compressBody(response.body, response);
|
|
151
137
|
}
|
package/util/webRouting.ts
CHANGED
|
@@ -112,6 +112,28 @@ async function readBodyWithLimit(req: Request): Promise<string> {
|
|
|
112
112
|
return new TextDecoder().decode(merged);
|
|
113
113
|
}
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Merge a value into the params object under `key`, appending to any existing
|
|
117
|
+
* value rather than replacing it. If `key` is not set, assigns `value` as-is.
|
|
118
|
+
* If it is set, produces an array containing the existing value(s) followed by
|
|
119
|
+
* the incoming value(s). Used to fold body, form-data, and query string
|
|
120
|
+
* sources into a single params object while preserving repeated keys.
|
|
121
|
+
*/
|
|
122
|
+
function appendParam(
|
|
123
|
+
params: Record<string, unknown>,
|
|
124
|
+
key: string,
|
|
125
|
+
value: unknown,
|
|
126
|
+
): void {
|
|
127
|
+
if (params[key] === undefined) {
|
|
128
|
+
params[key] = value;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const incoming = Array.isArray(value) ? value : [value];
|
|
132
|
+
params[key] = Array.isArray(params[key])
|
|
133
|
+
? [...(params[key] as unknown[]), ...incoming]
|
|
134
|
+
: [params[key], ...incoming];
|
|
135
|
+
}
|
|
136
|
+
|
|
115
137
|
/**
|
|
116
138
|
* Parse request parameters from path params, request body (JSON or form-data),
|
|
117
139
|
* and query string into a single plain object.
|
|
@@ -179,44 +201,12 @@ export async function parseRequestParams(
|
|
|
179
201
|
}
|
|
180
202
|
|
|
181
203
|
const f = await req.formData();
|
|
182
|
-
f.forEach((value, key) =>
|
|
183
|
-
if (params[key] !== undefined) {
|
|
184
|
-
if (Array.isArray(params[key])) {
|
|
185
|
-
(params[key] as unknown[]).push(value);
|
|
186
|
-
} else {
|
|
187
|
-
params[key] = [params[key], value];
|
|
188
|
-
}
|
|
189
|
-
} else {
|
|
190
|
-
params[key] = value;
|
|
191
|
-
}
|
|
192
|
-
});
|
|
204
|
+
f.forEach((value, key) => appendParam(params, key, value));
|
|
193
205
|
}
|
|
194
206
|
|
|
195
207
|
if (url.query) {
|
|
196
208
|
for (const [key, values] of Object.entries(url.query)) {
|
|
197
|
-
if (values !== undefined)
|
|
198
|
-
if (Array.isArray(values)) {
|
|
199
|
-
if (params[key] !== undefined) {
|
|
200
|
-
if (Array.isArray(params[key])) {
|
|
201
|
-
(params[key] as unknown[]).push(...values);
|
|
202
|
-
} else {
|
|
203
|
-
params[key] = [params[key], ...values];
|
|
204
|
-
}
|
|
205
|
-
} else {
|
|
206
|
-
params[key] = values;
|
|
207
|
-
}
|
|
208
|
-
} else {
|
|
209
|
-
if (params[key] !== undefined) {
|
|
210
|
-
if (Array.isArray(params[key])) {
|
|
211
|
-
(params[key] as unknown[]).push(values);
|
|
212
|
-
} else {
|
|
213
|
-
params[key] = [params[key], values];
|
|
214
|
-
}
|
|
215
|
-
} else {
|
|
216
|
-
params[key] = values;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
}
|
|
209
|
+
if (values !== undefined) appendParam(params, key, values);
|
|
220
210
|
}
|
|
221
211
|
}
|
|
222
212
|
|
package/util/zodMixins.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { eq } from "drizzle-orm";
|
|
1
|
+
import { type AnyColumn, eq, type Table } from "drizzle-orm";
|
|
2
2
|
import { z } from "zod/v4";
|
|
3
3
|
import { api } from "../api";
|
|
4
4
|
import { ErrorType, TypedError } from "../classes/TypedError";
|
|
@@ -79,9 +79,6 @@ export function paginationInputs(options?: {
|
|
|
79
79
|
});
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
// Type for Drizzle tables with an id column
|
|
83
|
-
type TableWithId = { id: any; $inferSelect: any };
|
|
84
|
-
|
|
85
82
|
/**
|
|
86
83
|
* Generic factory to create a Zod schema that accepts either an ID or a model object.
|
|
87
84
|
* If an ID is provided, it resolves to the full model via database lookup.
|
|
@@ -91,8 +88,8 @@ type TableWithId = { id: any; $inferSelect: any };
|
|
|
91
88
|
* @param isModel - Type guard function to check if value is already a model
|
|
92
89
|
* @param entityName - Human-readable name for error messages
|
|
93
90
|
*/
|
|
94
|
-
export function zIdOrModel<
|
|
95
|
-
table:
|
|
91
|
+
export function zIdOrModel<TModel>(
|
|
92
|
+
table: Table & { id: AnyColumn },
|
|
96
93
|
modelSchema: z.ZodType<TModel>,
|
|
97
94
|
isModel: (val: unknown) => val is TModel,
|
|
98
95
|
entityName: string,
|
|
@@ -105,8 +102,8 @@ export function zIdOrModel<TTable extends TableWithId, TModel>(
|
|
|
105
102
|
}
|
|
106
103
|
const [record] = await api.db.db
|
|
107
104
|
.select()
|
|
108
|
-
.from(table
|
|
109
|
-
.where(eq(
|
|
105
|
+
.from(table)
|
|
106
|
+
.where(eq(table.id, val))
|
|
110
107
|
.limit(1);
|
|
111
108
|
|
|
112
109
|
if (!record) {
|