@stacksjs/bun-router 0.0.22 → 0.0.24
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/dist/cli.js +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +156 -8
- package/dist/response/compression.d.ts +66 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
package/dist/index.d.ts
CHANGED
|
@@ -20,6 +20,7 @@ export * as Errors from './errors';
|
|
|
20
20
|
export * from './middleware';
|
|
21
21
|
export { enableRequestContext, getCurrentRequest, request, runWithRequest, setCurrentRequest } from './request/context';
|
|
22
22
|
export * from './session';
|
|
23
|
+
export * from './response/compression';
|
|
23
24
|
export * from './response/response-factory';
|
|
24
25
|
export * from './router';
|
|
25
26
|
export { MiddlewareGroupRegistry, middlewareGroups } from './router/middleware-groups';
|
package/dist/index.js
CHANGED
|
@@ -2047,6 +2047,97 @@ function request() {
|
|
|
2047
2047
|
}
|
|
2048
2048
|
return req;
|
|
2049
2049
|
}
|
|
2050
|
+
// src/response/compression.ts
|
|
2051
|
+
var DEFAULT_COMPRESSION = {
|
|
2052
|
+
enabled: true,
|
|
2053
|
+
level: 6,
|
|
2054
|
+
threshold: 1024
|
|
2055
|
+
};
|
|
2056
|
+
function isCompressible(contentType) {
|
|
2057
|
+
if (!contentType)
|
|
2058
|
+
return false;
|
|
2059
|
+
const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
2060
|
+
if (type.startsWith("text/"))
|
|
2061
|
+
return true;
|
|
2062
|
+
return [
|
|
2063
|
+
"application/json",
|
|
2064
|
+
"application/ld+json",
|
|
2065
|
+
"application/manifest+json",
|
|
2066
|
+
"application/javascript",
|
|
2067
|
+
"application/x-javascript",
|
|
2068
|
+
"application/xml",
|
|
2069
|
+
"application/xhtml+xml",
|
|
2070
|
+
"application/rss+xml",
|
|
2071
|
+
"application/atom+xml",
|
|
2072
|
+
"application/graphql",
|
|
2073
|
+
"application/x-ndjson",
|
|
2074
|
+
"image/svg+xml"
|
|
2075
|
+
].includes(type);
|
|
2076
|
+
}
|
|
2077
|
+
function negotiateEncoding(header) {
|
|
2078
|
+
if (!header)
|
|
2079
|
+
return null;
|
|
2080
|
+
const offers = new Map;
|
|
2081
|
+
for (const part of header.split(",")) {
|
|
2082
|
+
const [name = "", ...parameters] = part.trim().split(";");
|
|
2083
|
+
const quality = parameters.map((parameter) => /^\s*q=([\d.]+)\s*$/i.exec(parameter)).find(Boolean);
|
|
2084
|
+
offers.set(name.trim().toLowerCase(), quality ? Number(quality[1]) : 1);
|
|
2085
|
+
}
|
|
2086
|
+
const wanted = (name) => (offers.get(name) ?? offers.get("*") ?? 0) > 0;
|
|
2087
|
+
if (wanted("gzip"))
|
|
2088
|
+
return "gzip";
|
|
2089
|
+
if (wanted("deflate"))
|
|
2090
|
+
return "deflate";
|
|
2091
|
+
return null;
|
|
2092
|
+
}
|
|
2093
|
+
function shouldCompress(response, encoding, threshold) {
|
|
2094
|
+
if (!encoding)
|
|
2095
|
+
return false;
|
|
2096
|
+
if (response.status === 204 || response.status === 304 || response.status === 206)
|
|
2097
|
+
return false;
|
|
2098
|
+
if (response.headers.has("content-encoding"))
|
|
2099
|
+
return false;
|
|
2100
|
+
if (!isCompressible(response.headers.get("content-type")))
|
|
2101
|
+
return false;
|
|
2102
|
+
const length = Number(response.headers.get("content-length") ?? Number.NaN);
|
|
2103
|
+
if (!Number.isFinite(length))
|
|
2104
|
+
return false;
|
|
2105
|
+
return length >= threshold;
|
|
2106
|
+
}
|
|
2107
|
+
async function compressResponse(response, request2, options = {}) {
|
|
2108
|
+
const settings = { ...DEFAULT_COMPRESSION, ...options };
|
|
2109
|
+
if (!settings.enabled)
|
|
2110
|
+
return response;
|
|
2111
|
+
const encoding = negotiateEncoding(request2.headers.get("accept-encoding"));
|
|
2112
|
+
if (!shouldCompress(response, encoding, settings.threshold)) {
|
|
2113
|
+
if (isCompressible(response.headers.get("content-type")) && !response.headers.has("content-encoding"))
|
|
2114
|
+
appendVary(response.headers, "Accept-Encoding");
|
|
2115
|
+
return response;
|
|
2116
|
+
}
|
|
2117
|
+
const body = new Uint8Array(await response.arrayBuffer());
|
|
2118
|
+
const compressed = encoding === "gzip" ? Bun.gzipSync(body, { level: settings.level }) : Bun.deflateSync(body, { level: settings.level });
|
|
2119
|
+
if (compressed.byteLength >= body.byteLength) {
|
|
2120
|
+
const headers2 = new Headers(response.headers);
|
|
2121
|
+
appendVary(headers2, "Accept-Encoding");
|
|
2122
|
+
return new Response(body, { status: response.status, statusText: response.statusText, headers: headers2 });
|
|
2123
|
+
}
|
|
2124
|
+
const headers = new Headers(response.headers);
|
|
2125
|
+
headers.set("Content-Encoding", encoding);
|
|
2126
|
+
headers.set("Content-Length", String(compressed.byteLength));
|
|
2127
|
+
appendVary(headers, "Accept-Encoding");
|
|
2128
|
+
return new Response(compressed, { status: response.status, statusText: response.statusText, headers });
|
|
2129
|
+
}
|
|
2130
|
+
function appendVary(headers, value) {
|
|
2131
|
+
const existing = headers.get("vary");
|
|
2132
|
+
if (!existing) {
|
|
2133
|
+
headers.set("Vary", value);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
const parts = existing.split(",").map((part) => part.trim().toLowerCase());
|
|
2137
|
+
if (parts.includes("*") || parts.includes(value.toLowerCase()))
|
|
2138
|
+
return;
|
|
2139
|
+
headers.set("Vary", `${existing}, ${value}`);
|
|
2140
|
+
}
|
|
2050
2141
|
// src/response/response-factory.ts
|
|
2051
2142
|
var response = {
|
|
2052
2143
|
json: (data, options = {}) => {
|
|
@@ -3074,7 +3165,19 @@ function findPrebuiltView(stxFilePath, viewsDir) {
|
|
|
3074
3165
|
}
|
|
3075
3166
|
return null;
|
|
3076
3167
|
}
|
|
3077
|
-
|
|
3168
|
+
function parseCookies(header) {
|
|
3169
|
+
const jar = {};
|
|
3170
|
+
for (const part of String(header ?? "").split(";")) {
|
|
3171
|
+
const cut = part.indexOf("=");
|
|
3172
|
+
if (cut < 0)
|
|
3173
|
+
continue;
|
|
3174
|
+
try {
|
|
3175
|
+
jar[part.slice(0, cut).trim()] = decodeURIComponent(part.slice(cut + 1).trim());
|
|
3176
|
+
} catch {}
|
|
3177
|
+
}
|
|
3178
|
+
return jar;
|
|
3179
|
+
}
|
|
3180
|
+
async function renderStxFile(filePath, viewsDir, data, routingConfig, request2) {
|
|
3078
3181
|
const stxModule = "@stacksjs/stx";
|
|
3079
3182
|
const stx = await import(stxModule);
|
|
3080
3183
|
if (!stx.processDirectives || !stx.extractVariables) {
|
|
@@ -3085,11 +3188,42 @@ async function renderStxFile(filePath, viewsDir, data, routingConfig) {
|
|
|
3085
3188
|
const scriptContent = scriptMatch ? scriptMatch[1] : "";
|
|
3086
3189
|
let templateContent = scriptMatch ? content.replace(/<script\s+server\s*>[\s\S]*?<\/script>/i, "") : content;
|
|
3087
3190
|
templateContent = templateContent.replace(/<script\s+client\s*>/gi, "<script>");
|
|
3191
|
+
const asked = { status: 200, headers: {} };
|
|
3192
|
+
const headerBag = data.headers ?? {};
|
|
3193
|
+
const cookieHeader = String(headerBag.cookie ?? headerBag.Cookie ?? "");
|
|
3194
|
+
const requestUrl = String(data.url ?? "");
|
|
3195
|
+
const search = requestUrl.includes("?") ? requestUrl.slice(requestUrl.indexOf("?")) : "";
|
|
3196
|
+
const serveContext = {
|
|
3197
|
+
url: requestUrl,
|
|
3198
|
+
path: String(data.path ?? ""),
|
|
3199
|
+
search,
|
|
3200
|
+
host: String(headerBag.host ?? ""),
|
|
3201
|
+
cookieHeader,
|
|
3202
|
+
cookies: parseCookies(cookieHeader),
|
|
3203
|
+
ip: "",
|
|
3204
|
+
locale: null,
|
|
3205
|
+
params: data.params ?? {},
|
|
3206
|
+
method: String(data.method ?? "GET"),
|
|
3207
|
+
request: request2
|
|
3208
|
+
};
|
|
3088
3209
|
const context = {
|
|
3089
3210
|
__filename: filePath,
|
|
3090
3211
|
__dirname: dirname(filePath),
|
|
3091
3212
|
props: data,
|
|
3092
|
-
...data
|
|
3213
|
+
...data,
|
|
3214
|
+
__stxServeContext: serveContext,
|
|
3215
|
+
setResponseStatus: (status) => {
|
|
3216
|
+
if (Number.isInteger(status) && status >= 100 && status <= 599)
|
|
3217
|
+
asked.status = status;
|
|
3218
|
+
},
|
|
3219
|
+
notFound: (status = 404) => {
|
|
3220
|
+
asked.status = Number.isInteger(status) && status >= 400 && status <= 599 ? status : 404;
|
|
3221
|
+
},
|
|
3222
|
+
setResponseHeader: (name, value) => {
|
|
3223
|
+
if (name)
|
|
3224
|
+
asked.headers[String(name)] = String(value);
|
|
3225
|
+
},
|
|
3226
|
+
definePageMeta: () => {}
|
|
3093
3227
|
};
|
|
3094
3228
|
if (scriptContent) {
|
|
3095
3229
|
await stx.extractVariables(scriptContent, context, filePath);
|
|
@@ -3103,7 +3237,14 @@ async function renderStxFile(filePath, viewsDir, data, routingConfig) {
|
|
|
3103
3237
|
layoutsDir: resolvedLayoutsDir,
|
|
3104
3238
|
partialsDir: resolvedPartialsDir
|
|
3105
3239
|
};
|
|
3106
|
-
|
|
3240
|
+
const html = await stx.processDirectives(templateContent, context, filePath, config2, new Set);
|
|
3241
|
+
const recordedStatus = Number(context.__stxResponseStatus);
|
|
3242
|
+
const recordedHeaders = context.__stxResponseHeaders;
|
|
3243
|
+
return {
|
|
3244
|
+
html,
|
|
3245
|
+
status: Number.isInteger(recordedStatus) && recordedStatus >= 100 && recordedStatus <= 599 ? recordedStatus : asked.status,
|
|
3246
|
+
headers: { ...asked.headers, ...recordedHeaders ?? {} }
|
|
3247
|
+
};
|
|
3107
3248
|
}
|
|
3108
3249
|
function createViewHandler(filePath, viewsDir, config2, queryPreservationConfig) {
|
|
3109
3250
|
return async (req) => {
|
|
@@ -3133,11 +3274,11 @@ function createViewHandler(filePath, viewsDir, config2, queryPreservationConfig)
|
|
|
3133
3274
|
});
|
|
3134
3275
|
}
|
|
3135
3276
|
if (filePath.endsWith(".stx")) {
|
|
3136
|
-
|
|
3137
|
-
html = applyQueryPreservation(html);
|
|
3277
|
+
const rendered = await renderStxFile(filePath, viewsDir, data, config2, req);
|
|
3278
|
+
const html = applyQueryPreservation(rendered.html);
|
|
3138
3279
|
return new Response(html, {
|
|
3139
|
-
status:
|
|
3140
|
-
headers: { "Content-Type": "text/html; charset=utf-8" }
|
|
3280
|
+
status: rendered.status,
|
|
3281
|
+
headers: { "Content-Type": "text/html; charset=utf-8", ...rendered.headers }
|
|
3141
3282
|
});
|
|
3142
3283
|
}
|
|
3143
3284
|
let content = await Bun.file(filePath).text();
|
|
@@ -9513,7 +9654,8 @@ function registerServerHandling(RouterClass) {
|
|
|
9513
9654
|
handleRequest: {
|
|
9514
9655
|
async value(req) {
|
|
9515
9656
|
const self = this;
|
|
9516
|
-
|
|
9657
|
+
const response2 = await runWithRequest(req, () => self.handleRequestImpl(req));
|
|
9658
|
+
return await compressResponse(response2, req, self.config?.compression ?? {});
|
|
9517
9659
|
},
|
|
9518
9660
|
writable: true,
|
|
9519
9661
|
configurable: true
|
|
@@ -13614,6 +13756,7 @@ export {
|
|
|
13614
13756
|
spyOnMiddleware,
|
|
13615
13757
|
singleFileUpload,
|
|
13616
13758
|
signRequest,
|
|
13759
|
+
shouldCompress,
|
|
13617
13760
|
setCurrentRequest,
|
|
13618
13761
|
setContainer,
|
|
13619
13762
|
session,
|
|
@@ -13645,6 +13788,7 @@ export {
|
|
|
13645
13788
|
parseThrottleString,
|
|
13646
13789
|
oauth2Auth,
|
|
13647
13790
|
normalizePath,
|
|
13791
|
+
negotiateEncoding,
|
|
13648
13792
|
multipleFileUpload,
|
|
13649
13793
|
modelMocks,
|
|
13650
13794
|
mockMiddleware,
|
|
@@ -13656,6 +13800,7 @@ export {
|
|
|
13656
13800
|
jsonBody,
|
|
13657
13801
|
joinPaths,
|
|
13658
13802
|
isRouteHandler,
|
|
13803
|
+
isCompressible,
|
|
13659
13804
|
isActionClass,
|
|
13660
13805
|
inputValidation,
|
|
13661
13806
|
injectQueryPreservationScript,
|
|
@@ -13713,6 +13858,7 @@ export {
|
|
|
13713
13858
|
contentSecurityPolicy,
|
|
13714
13859
|
constraintHelpers,
|
|
13715
13860
|
config,
|
|
13861
|
+
compressResponse,
|
|
13716
13862
|
clearNamedRoutes,
|
|
13717
13863
|
buildUrlWithPreservedParams,
|
|
13718
13864
|
benchmarkTests,
|
|
@@ -13723,6 +13869,7 @@ export {
|
|
|
13723
13869
|
authHelpers,
|
|
13724
13870
|
auth,
|
|
13725
13871
|
applyRequestEnhancements,
|
|
13872
|
+
appendVary,
|
|
13726
13873
|
apiKeyAuth,
|
|
13727
13874
|
WebSocketTester,
|
|
13728
13875
|
Validator,
|
|
@@ -13804,6 +13951,7 @@ export {
|
|
|
13804
13951
|
DefaultServiceProviderManager,
|
|
13805
13952
|
DatabaseSessionStore,
|
|
13806
13953
|
DatabaseRules,
|
|
13954
|
+
DEFAULT_COMPRESSION,
|
|
13807
13955
|
DDoSProtection,
|
|
13808
13956
|
Csrf,
|
|
13809
13957
|
Cors,
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compressing what goes back, which nothing was doing.
|
|
3
|
+
*
|
|
4
|
+
* Static files have been served gzipped for a long time; every response a
|
|
5
|
+
* *route* produced went out whole. A server-rendered page is the shape that
|
|
6
|
+
* costs: the landing page of the application this was found in is 253 KB of
|
|
7
|
+
* HTML and compresses to about a tenth of that, on every request, for every
|
|
8
|
+
* visitor - and the same is true of any JSON list long enough to matter.
|
|
9
|
+
*
|
|
10
|
+
* Three rules, and the middle one is the important one:
|
|
11
|
+
*
|
|
12
|
+
* - **Only when the client asked.** `Accept-Encoding` decides, and `Vary` says
|
|
13
|
+
* so, because a cache that hands a gzipped body to a client that did not ask
|
|
14
|
+
* is a cache that breaks that client.
|
|
15
|
+
* - **Only when the length is known.** A response with no `Content-Length` is a
|
|
16
|
+
* stream, and buffering one to compress it would turn a progressive render
|
|
17
|
+
* into a wait - the diff manifest that streams a hundred files as they parse
|
|
18
|
+
* would arrive all at once, at the end. Streams are left alone.
|
|
19
|
+
* - **Only when it is worth it.** Below a kilobyte the header costs more than
|
|
20
|
+
* the saving, and already-compressed bytes (an image, a woff2, a zip) get
|
|
21
|
+
* bigger rather than smaller.
|
|
22
|
+
*/
|
|
23
|
+
/** zlib's own range, so a level Bun would reject is a type error rather than a throw. */
|
|
24
|
+
export type CompressionLevel = -1 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9;
|
|
25
|
+
export interface CompressionOptions {
|
|
26
|
+
/** Off turns the whole thing off, for a proxy that already compresses. */
|
|
27
|
+
enabled?: boolean;
|
|
28
|
+
/** zlib level. 6 is the usual balance; 9 costs noticeably more CPU for a few percent. */
|
|
29
|
+
level?: CompressionLevel;
|
|
30
|
+
/** Bodies below this many bytes are sent as they are. */
|
|
31
|
+
threshold?: number;
|
|
32
|
+
}
|
|
33
|
+
export declare const DEFAULT_COMPRESSION: Required<CompressionOptions>;
|
|
34
|
+
/**
|
|
35
|
+
* Content types worth compressing.
|
|
36
|
+
*
|
|
37
|
+
* A prefix list rather than a regex over everything: `image/svg+xml` is text
|
|
38
|
+
* and compresses well, `image/png` is already compressed and grows. When in
|
|
39
|
+
* doubt the answer is no, because sending something slightly larger is a
|
|
40
|
+
* wasted CPU cycle and a wasted byte, while failing to compress is only a
|
|
41
|
+
* missed saving.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isCompressible(contentType: string | null): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* The encoding to use, from what the client offered.
|
|
46
|
+
*
|
|
47
|
+
* gzip before deflate, and `q=0` respected - a client that says
|
|
48
|
+
* `gzip;q=0` means it, and sending gzip anyway is how you get an unreadable
|
|
49
|
+
* page in the one browser that asked.
|
|
50
|
+
*
|
|
51
|
+
* Brotli is not offered even though Bun can produce it: `Bun.gzipSync` is
|
|
52
|
+
* synchronous and brotli is not, and a compression step that awaits inside the
|
|
53
|
+
* response path is a different change from this one.
|
|
54
|
+
*/
|
|
55
|
+
export declare function negotiateEncoding(header: string | null): 'gzip' | 'deflate' | null;
|
|
56
|
+
/** Whether this response should be compressed at all, and why not when it should not. */
|
|
57
|
+
export declare function shouldCompress(response: Response, encoding: string | null, threshold: number): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* The response, compressed when that is the right thing to do.
|
|
60
|
+
*
|
|
61
|
+
* Returns the response it was given when it is not, so a caller can apply this
|
|
62
|
+
* unconditionally at the end of the pipeline.
|
|
63
|
+
*/
|
|
64
|
+
export declare function compressResponse(response: Response, request: Request, options?: CompressionOptions): Promise<Response>;
|
|
65
|
+
/** Add to `Vary` without dropping what is already there. */
|
|
66
|
+
export declare function appendVary(headers: Headers, value: string): void;
|