@stacksjs/stx 0.2.170 → 0.2.172
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 +1263 -1168
- package/dist/client-script-bundler.js +2 -1
- package/dist/client-script.d.ts +1 -12
- package/dist/client-script.js +2 -149
- package/dist/codemod.d.ts +1 -24
- package/dist/codemod.js +61 -9
- package/dist/compression.d.ts +11 -4
- package/dist/compression.js +52 -13
- package/dist/craft.js +489 -415
- package/dist/expressions.d.ts +0 -21
- package/dist/expressions.js +12 -1
- package/dist/find-body-tag.js +29 -17
- package/dist/index.d.ts +8 -0
- package/dist/index.js +2 -0
- package/dist/page-action.d.ts +83 -0
- package/dist/page-action.js +64 -0
- package/dist/production-builder.js +1 -0
- package/dist/production-server.js +10 -3
- package/dist/pwa.js +398 -324
- package/dist/reactive-tracking.d.ts +81 -0
- package/dist/reactive-tracking.js +49 -0
- package/dist/reactivity.js +29 -23
- package/dist/render.js +5 -18
- package/dist/runtime-injection.js +25 -39
- package/dist/script-validation.d.ts +0 -7
- package/dist/script-validation.js +6 -4
- package/dist/signals-api.d.ts +18 -0
- package/dist/signals-api.js +26 -41
- package/dist/signals.js +93 -15
- package/dist/ssg.d.ts +8 -0
- package/dist/ssg.js +515 -441
- package/dist/state-management.js +2 -2
- package/dist/strip-literals.d.ts +40 -0
- package/dist/strip-literals.js +149 -0
- package/dist/stx-virtual-ts.d.ts +103 -1
- package/dist/stx-virtual-ts.js +124 -7
- package/dist/template-compiler.js +4 -2
- package/dist/template-hydrator.d.ts +1 -1
- package/dist/template-hydrator.js +13 -1
- package/dist/typecheck.d.ts +2 -14
- package/dist/typecheck.js +133 -11
- package/dist/types/config-types.d.ts +10 -3
- package/dist/variable-extractor.js +1 -1
- package/package.json +5 -5
|
@@ -30,7 +30,8 @@ export function resolveBundlerFallback() {
|
|
|
30
30
|
if (_fallbackOverride)
|
|
31
31
|
return _fallbackOverride;
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
const strict = config.strict;
|
|
34
|
+
return typeof strict === "object" && strict !== null ? strict.bundlerFallback ?? "warn" : "warn";
|
|
34
35
|
} catch {
|
|
35
36
|
return "warn";
|
|
36
37
|
}
|
package/dist/client-script.d.ts
CHANGED
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
* @module client-script
|
|
33
33
|
*/
|
|
34
34
|
import type { ParsedEvent } from './events';
|
|
35
|
+
export { findInterpolationEnd, stripCommentsAndLiterals } from './strip-literals';
|
|
35
36
|
export interface VendorCssImport {
|
|
36
37
|
/** The original specifier as written in the import — kept verbatim for the data attribute. */
|
|
37
38
|
source: string;
|
|
@@ -117,18 +118,6 @@ export interface BrowserCoreAutoImportResult {
|
|
|
117
118
|
* helpers together with the component, exactly like an explicit user import.
|
|
118
119
|
*/
|
|
119
120
|
export declare function injectBrowserCoreAutoImports(code: string): BrowserCoreAutoImportResult;
|
|
120
|
-
/**
|
|
121
|
-
* Blank out comments and string literals, preserving length and line structure
|
|
122
|
-
* so offsets still line up, so that identifier detection only ever sees code.
|
|
123
|
-
*
|
|
124
|
-
* Template-literal INTERPOLATIONS are kept, because `${...}` is code, not text.
|
|
125
|
-
* Blanking them hid every identifier that appears only inside one — a helper
|
|
126
|
-
* called as `${formatDate(d)}` looked unused to the auto-import scanner, and a
|
|
127
|
-
* server value read as `` `/api/users/${userId}` `` looked unreferenced to the
|
|
128
|
-
* data bridge, which would have withheld it and left the client throwing on an
|
|
129
|
-
* undefined name.
|
|
130
|
-
*/
|
|
131
|
-
export declare function stripCommentsAndLiterals(code: string): string;
|
|
132
121
|
export interface ClientScriptOptions {
|
|
133
122
|
/** Event bindings collected from template @event attributes */
|
|
134
123
|
eventBindings?: ParsedEvent[];
|
package/dist/client-script.js
CHANGED
|
@@ -2,6 +2,8 @@ import path from "node:path";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import { asInvocableStatement } from "./events.js";
|
|
4
4
|
import { transformStoreImports } from "./store-imports.js";
|
|
5
|
+
import { findInterpolationEnd, stripCommentsAndLiterals } from "./strip-literals.js";
|
|
6
|
+
export { findInterpolationEnd, stripCommentsAndLiterals } from "./strip-literals.js";
|
|
5
7
|
import { shouldTranspileTypeScript, transpileTypeScript } from "./utils.js";
|
|
6
8
|
import { importOnce } from "./lazy-module.js";
|
|
7
9
|
import { STX_RUNTIME_GLOBALS, usesReactiveRuntime } from "./runtime-globals.js";
|
|
@@ -263,155 +265,6 @@ const JS_BUILTINS = new Set([
|
|
|
263
265
|
"Modal",
|
|
264
266
|
"Intl"
|
|
265
267
|
]);
|
|
266
|
-
function skipQuoted(code, start) {
|
|
267
|
-
const quote = code[start];
|
|
268
|
-
let i = start + 1;
|
|
269
|
-
while (i < code.length) {
|
|
270
|
-
if (code[i] === "\\") {
|
|
271
|
-
i += 2;
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
if (code[i] === quote)
|
|
275
|
-
return i + 1;
|
|
276
|
-
i++;
|
|
277
|
-
}
|
|
278
|
-
return code.length;
|
|
279
|
-
}
|
|
280
|
-
function skipTemplate(code, start) {
|
|
281
|
-
let i = start + 1;
|
|
282
|
-
while (i < code.length) {
|
|
283
|
-
if (code[i] === "\\") {
|
|
284
|
-
i += 2;
|
|
285
|
-
continue;
|
|
286
|
-
}
|
|
287
|
-
if (code[i] === "`")
|
|
288
|
-
return i + 1;
|
|
289
|
-
if (code[i] === "$" && code[i + 1] === "{") {
|
|
290
|
-
i = findInterpolationEnd(code, i + 2);
|
|
291
|
-
if (i < code.length)
|
|
292
|
-
i++;
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
i++;
|
|
296
|
-
}
|
|
297
|
-
return code.length;
|
|
298
|
-
}
|
|
299
|
-
function findInterpolationEnd(code, start) {
|
|
300
|
-
let depth = 1, i = start;
|
|
301
|
-
while (i < code.length) {
|
|
302
|
-
const ch = code[i];
|
|
303
|
-
if (ch === "\\") {
|
|
304
|
-
i += 2;
|
|
305
|
-
continue;
|
|
306
|
-
}
|
|
307
|
-
if (ch === '"' || ch === "'") {
|
|
308
|
-
i = skipQuoted(code, i);
|
|
309
|
-
continue;
|
|
310
|
-
}
|
|
311
|
-
if (ch === "`") {
|
|
312
|
-
i = skipTemplate(code, i);
|
|
313
|
-
continue;
|
|
314
|
-
}
|
|
315
|
-
if (ch === "/" && code[i + 1] === "/") {
|
|
316
|
-
while (i < code.length && code[i] !== `
|
|
317
|
-
`)
|
|
318
|
-
i++;
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
if (ch === "/" && code[i + 1] === "*") {
|
|
322
|
-
i += 2;
|
|
323
|
-
while (i < code.length && code.slice(i, i + 2) !== "*/")
|
|
324
|
-
i++;
|
|
325
|
-
i += 2;
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
if (ch === "{") {
|
|
329
|
-
depth++;
|
|
330
|
-
i++;
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
|
-
if (ch === "}") {
|
|
334
|
-
depth--;
|
|
335
|
-
if (depth === 0)
|
|
336
|
-
return i;
|
|
337
|
-
i++;
|
|
338
|
-
continue;
|
|
339
|
-
}
|
|
340
|
-
i++;
|
|
341
|
-
}
|
|
342
|
-
return code.length;
|
|
343
|
-
}
|
|
344
|
-
export function stripCommentsAndLiterals(code) {
|
|
345
|
-
let out = "", i = 0;
|
|
346
|
-
while (i < code.length) {
|
|
347
|
-
const two = code.slice(i, i + 2);
|
|
348
|
-
if (two === "//") {
|
|
349
|
-
while (i < code.length && code[i] !== `
|
|
350
|
-
`)
|
|
351
|
-
out += " ", i++;
|
|
352
|
-
continue;
|
|
353
|
-
}
|
|
354
|
-
if (two === "/*") {
|
|
355
|
-
while (i < code.length && code.slice(i, i + 2) !== "*/")
|
|
356
|
-
out += code[i] === `
|
|
357
|
-
` ? `
|
|
358
|
-
` : " ", i++;
|
|
359
|
-
out += " ";
|
|
360
|
-
i += 2;
|
|
361
|
-
continue;
|
|
362
|
-
}
|
|
363
|
-
const ch = code[i];
|
|
364
|
-
if (ch === '"' || ch === "'") {
|
|
365
|
-
const quote = ch;
|
|
366
|
-
out += " ";
|
|
367
|
-
i++;
|
|
368
|
-
while (i < code.length && code[i] !== quote) {
|
|
369
|
-
if (code[i] === "\\") {
|
|
370
|
-
out += " ";
|
|
371
|
-
i += 2;
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
out += code[i] === `
|
|
375
|
-
` ? `
|
|
376
|
-
` : " ";
|
|
377
|
-
i++;
|
|
378
|
-
}
|
|
379
|
-
out += " ";
|
|
380
|
-
i++;
|
|
381
|
-
continue;
|
|
382
|
-
}
|
|
383
|
-
if (ch === "`") {
|
|
384
|
-
out += " ";
|
|
385
|
-
i++;
|
|
386
|
-
while (i < code.length && code[i] !== "`") {
|
|
387
|
-
if (code[i] === "\\") {
|
|
388
|
-
out += " ";
|
|
389
|
-
i += 2;
|
|
390
|
-
continue;
|
|
391
|
-
}
|
|
392
|
-
if (code[i] === "$" && code[i + 1] === "{") {
|
|
393
|
-
const bodyStart = i + 2, bodyEnd = findInterpolationEnd(code, bodyStart);
|
|
394
|
-
out += " ";
|
|
395
|
-
out += stripCommentsAndLiterals(code.slice(bodyStart, bodyEnd));
|
|
396
|
-
if (bodyEnd < code.length)
|
|
397
|
-
out += " ";
|
|
398
|
-
i = bodyEnd + 1;
|
|
399
|
-
continue;
|
|
400
|
-
}
|
|
401
|
-
out += code[i] === `
|
|
402
|
-
` ? `
|
|
403
|
-
` : " ";
|
|
404
|
-
i++;
|
|
405
|
-
}
|
|
406
|
-
out += " ";
|
|
407
|
-
i++;
|
|
408
|
-
continue;
|
|
409
|
-
}
|
|
410
|
-
out += ch;
|
|
411
|
-
i++;
|
|
412
|
-
}
|
|
413
|
-
return out;
|
|
414
|
-
}
|
|
415
268
|
function detectModelUsage(code) {
|
|
416
269
|
const models = new Set, modelPattern = /\b([A-Z][a-zA-Z0-9]*)\s*\.\s*(all|find|first|get|where|orderBy|orderByDesc|limit|select|pluck|create|update|delete)\s*\(/g;
|
|
417
270
|
let match;
|
package/dist/codemod.d.ts
CHANGED
|
@@ -18,31 +18,8 @@ export declare interface CodemodOptions {
|
|
|
18
18
|
rules?: CodemodRule[]
|
|
19
19
|
file?: string
|
|
20
20
|
}
|
|
21
|
-
/**
|
|
22
|
-
* Codemods for adopting stx primitives an app hand-rolled around (#1843).
|
|
23
|
-
*
|
|
24
|
-
* Two production apps built on stx reached past it into vanilla JS for things
|
|
25
|
-
* it already ships. The audit found the cause was discovery, not absence: one
|
|
26
|
-
* app called bare `confirm()` in five places and used `title=` thirteen times
|
|
27
|
-
* while `stxConfirm` and `x-tooltip` were both delivered and used zero times.
|
|
28
|
-
* A native fallback that WORKS is the hardest kind of gap to notice — nothing
|
|
29
|
-
* fails, so nothing prompts you to look.
|
|
30
|
-
*
|
|
31
|
-
* #1843 proposes both rewrites as "mechanical and safe". Neither is, and the
|
|
32
|
-
* difference matters enough that this tool reports by default and only edits
|
|
33
|
-
* when asked:
|
|
34
|
-
*
|
|
35
|
-
* - `confirm()` returns a boolean synchronously; `stxConfirm()` returns a
|
|
36
|
-
* Promise. `if (confirm(m))` rewritten to `if (stxConfirm(m))` is ALWAYS
|
|
37
|
-
* TRUE, because a Promise is truthy. A blind rewrite turns every "Delete
|
|
38
|
-
* this?" into an unconditional yes.
|
|
39
|
-
*
|
|
40
|
-
* - `title=` is announced by screen readers. `x-tooltip` sets no `role` and
|
|
41
|
-
* no `aria-*` — it is a visual tooltip driven by mouseover/focusin.
|
|
42
|
-
* Replacing one with the other removes the accessible description. So the
|
|
43
|
-
* tooltip rule ADDS `x-tooltip` and keeps `title`.
|
|
44
|
-
*/
|
|
45
21
|
export type CodemodRule = | 'confirm'
|
|
22
|
+
| 'alert'
|
|
46
23
|
| 'tooltip'
|
|
47
24
|
| 'route-params'
|
|
48
25
|
| 'search-params'
|
package/dist/codemod.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripCommentsAndLiterals } from "./strip-literals.js";
|
|
1
2
|
function contextAfter(source, index, length = 240) {
|
|
2
3
|
return source.slice(index, index + length);
|
|
3
4
|
}
|
|
@@ -8,11 +9,25 @@ const REPORT_RULES = [
|
|
|
8
9
|
primitive: "useRoute() / useRouteParams() / useRouteParam(name)",
|
|
9
10
|
why: "parsing the URL by hand re-implements the router and does not update on SPA navigation"
|
|
10
11
|
},
|
|
12
|
+
{
|
|
13
|
+
rule: "alert",
|
|
14
|
+
pattern: /(?<![.#\w$])(?:(?:window|globalThis)\s*\.\s*)?alert\s*\(/g,
|
|
15
|
+
primitive: "stxAlert(message)",
|
|
16
|
+
why: "themed, focus-trapped and non-blocking \u2014 but `await` it if the code after it " + "depended on the native dialog blocking until dismissal",
|
|
17
|
+
reject: (source) => /\b(?:async\s+)?function\s+alert\s*\(/.test(source) || /\b(?:const|let|var)\s+alert\s*=/.test(source)
|
|
18
|
+
},
|
|
11
19
|
{
|
|
12
20
|
rule: "search-params",
|
|
13
21
|
pattern: /new\s+URLSearchParams\s*\(/g,
|
|
14
22
|
primitive: "useSearchParams()",
|
|
15
|
-
why: "reactive, and writes back through the router instead of history.replaceState"
|
|
23
|
+
why: "reactive, and writes back through the router instead of history.replaceState",
|
|
24
|
+
reject: (source, index) => {
|
|
25
|
+
const open = source.indexOf("(", index);
|
|
26
|
+
if (open === -1)
|
|
27
|
+
return !0;
|
|
28
|
+
const arg = source.slice(open + 1, source.indexOf(")", open) + 1);
|
|
29
|
+
return !/\b(?:location|search|href|url)\b/i.test(arg);
|
|
30
|
+
}
|
|
16
31
|
},
|
|
17
32
|
{
|
|
18
33
|
rule: "navigate",
|
|
@@ -87,10 +102,15 @@ function isInsideAsyncFunction(source, index) {
|
|
|
87
102
|
const window = source.slice(Math.max(0, boundary - 120), boundary);
|
|
88
103
|
return /\basync\b[^;{}]*$/.test(window);
|
|
89
104
|
}
|
|
105
|
+
function declaresOwnConfirm(source) {
|
|
106
|
+
return /\b(?:async\s+)?function\s+confirm\s*\(/.test(source) || /\b(?:const|let|var)\s+confirm\s*=/.test(source) || /\bimport\s*\{[^}]*\bconfirm\b[^}]*\}/.test(source);
|
|
107
|
+
}
|
|
90
108
|
function applyConfirmRule(source, file, findings) {
|
|
91
|
-
|
|
109
|
+
if (declaresOwnConfirm(source))
|
|
110
|
+
return source;
|
|
111
|
+
const pattern = /(^|[^\w$.])(?:(?:window|globalThis)\s*\.\s*)?confirm\s*\(/g, searchable = searchableSource(source);
|
|
92
112
|
let out = "", last = 0, match;
|
|
93
|
-
while ((match = pattern.exec(
|
|
113
|
+
while ((match = pattern.exec(searchable)) !== null) {
|
|
94
114
|
const callAt = match.index + match[1].length, line = lineOf(source, callAt), snippet = snippetAt(source, callAt);
|
|
95
115
|
if (/stxConfirm\s*\($/.test(source.slice(0, callAt + 8)))
|
|
96
116
|
continue;
|
|
@@ -105,16 +125,27 @@ function applyConfirmRule(source, file, findings) {
|
|
|
105
125
|
});
|
|
106
126
|
if (safe) {
|
|
107
127
|
out += source.slice(last, callAt) + "await stxConfirm(";
|
|
108
|
-
last = callAt +
|
|
128
|
+
last = callAt + (match[0].length - match[1].length);
|
|
109
129
|
}
|
|
110
130
|
}
|
|
111
131
|
return out + source.slice(last);
|
|
112
132
|
}
|
|
113
133
|
function applyTooltipRule(source, file, findings) {
|
|
114
|
-
const pattern = /(<[a-z][\w-]*\b[^>]*?)\stitle=(["'])(.*?)\2/gis;
|
|
134
|
+
const pattern = /(<[a-z][\w-]*\b[^>]*?)\stitle=(["'])(.*?)\2/gis, searchable = markupSearchable(source);
|
|
115
135
|
let out = "", last = 0, match;
|
|
116
|
-
while ((match = pattern.exec(
|
|
117
|
-
const [whole, openTag, quote, value] = match, line = lineOf(source, match.index), snippet = snippetAt(source, match.index);
|
|
136
|
+
while ((match = pattern.exec(searchable)) !== null) {
|
|
137
|
+
const [whole, openTag, quote, value] = match, line = lineOf(source, match.index), snippet = snippetAt(source, match.index), tagName = openTag.match(/^<([A-Za-z][\w-]*)/)?.[1] ?? "";
|
|
138
|
+
if (/^[A-Z]/.test(tagName)) {
|
|
139
|
+
findings.push({
|
|
140
|
+
file,
|
|
141
|
+
line,
|
|
142
|
+
rule: "tooltip",
|
|
143
|
+
applied: !1,
|
|
144
|
+
snippet,
|
|
145
|
+
reason: `\`title\` on <${tagName}> is a component prop, not an HTML attribute \u2014 ` + "adding x-tooltip here would put a tooltip on a component and may duplicate text it already renders."
|
|
146
|
+
});
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
118
149
|
if (/\bx-tooltip[=\s]/.test(openTag) || value.trim() === "") {
|
|
119
150
|
findings.push({
|
|
120
151
|
file,
|
|
@@ -132,7 +163,19 @@ function applyTooltipRule(source, file, findings) {
|
|
|
132
163
|
}
|
|
133
164
|
return out + source.slice(last);
|
|
134
165
|
}
|
|
166
|
+
function markupSearchable(source) {
|
|
167
|
+
return source.replace(/<!--[\s\S]*?-->/g, (match) => match.replace(/[^\n]/g, " ")).replace(/\{\{--[\s\S]*?--\}\}/g, (match) => match.replace(/[^\n]/g, " "));
|
|
168
|
+
}
|
|
169
|
+
function blankJsComments(source) {
|
|
170
|
+
const blank = (match) => match.replace(/[^\n]/g, " ");
|
|
171
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, blank).replace(/\/\/[^\n]*/g, blank);
|
|
172
|
+
}
|
|
173
|
+
function searchableSource(source, keepStrings = !1) {
|
|
174
|
+
const markupOnly = markupSearchable(source);
|
|
175
|
+
return (keepStrings ? blankJsComments(markupOnly) : stripCommentsAndLiterals(markupOnly)).replace(/<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi, (match, attrs) => /\bclient\b/i.test(attrs) ? match : match.replace(/[^\n]/g, " "));
|
|
176
|
+
}
|
|
135
177
|
function applyReportRules(source, file, rules, findings) {
|
|
178
|
+
const codeView = searchableSource(source), stringView = searchableSource(source, !0);
|
|
136
179
|
for (const entry of REPORT_RULES) {
|
|
137
180
|
if (!rules.includes(entry.rule))
|
|
138
181
|
continue;
|
|
@@ -140,7 +183,9 @@ function applyReportRules(source, file, rules, findings) {
|
|
|
140
183
|
continue;
|
|
141
184
|
entry.pattern.lastIndex = 0;
|
|
142
185
|
let match;
|
|
143
|
-
|
|
186
|
+
const searchable = /['"]/.test(entry.pattern.source) ? stringView : codeView;
|
|
187
|
+
entry.pattern.lastIndex = 0;
|
|
188
|
+
while ((match = entry.pattern.exec(searchable)) !== null) {
|
|
144
189
|
if (entry.reject?.(source, match.index))
|
|
145
190
|
continue;
|
|
146
191
|
findings.push({
|
|
@@ -163,7 +208,14 @@ export function codemodSource(source, options = {}) {
|
|
|
163
208
|
if (rules.includes("tooltip"))
|
|
164
209
|
code = applyTooltipRule(code, file, findings);
|
|
165
210
|
findings.sort((a, b) => a.line - b.line);
|
|
166
|
-
|
|
211
|
+
const seen = new Set, unique = findings.filter((finding) => {
|
|
212
|
+
const key = `${finding.rule}:${finding.line}`;
|
|
213
|
+
if (seen.has(key))
|
|
214
|
+
return !1;
|
|
215
|
+
seen.add(key);
|
|
216
|
+
return !0;
|
|
217
|
+
});
|
|
218
|
+
return { code, findings: unique };
|
|
167
219
|
}
|
|
168
220
|
const REWRITABLE = new Set(["confirm", "tooltip"]);
|
|
169
221
|
export function formatCodemodFindings(findings, wrote) {
|
package/dist/compression.d.ts
CHANGED
|
@@ -3,10 +3,17 @@ export declare function isCompressible(contentType: string | null): boolean;
|
|
|
3
3
|
/**
|
|
4
4
|
* The best encoding both sides support, or `null` for none.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* that
|
|
6
|
+
* This used to be `accepted.includes('br')` on the raw header, on the argument
|
|
7
|
+
* that q-values are only ever sent by testing tools. The substring is the
|
|
8
|
+
* problem: `br;q=0` contains `br`, and it is the one string in the header that
|
|
9
|
+
* means *do not send me brotli*. Answering that with a brotli body is not a
|
|
10
|
+
* missed optimisation, it is a response the client cannot read — the failure
|
|
11
|
+
* this function's `Vary` sibling exists to prevent, arrived at from the other
|
|
12
|
+
* direction.
|
|
13
|
+
*
|
|
14
|
+
* Brotli is still preferred when both are genuinely on offer: ~20% smaller
|
|
15
|
+
* than gzip on HTML at comparable cost, and every browser without it predates
|
|
16
|
+
* 2017.
|
|
10
17
|
*/
|
|
11
18
|
export declare function negotiateEncoding(acceptEncoding: string | null): 'br' | 'gzip' | null;
|
|
12
19
|
/**
|
package/dist/compression.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import zlib from "node:zlib";
|
|
2
|
-
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
const brotliCompress = promisify(zlib.brotliCompress), gzipCompress = promisify(zlib.gzip), COMPRESSIBLE = [
|
|
3
4
|
"text/",
|
|
4
5
|
"application/json",
|
|
5
6
|
"application/javascript",
|
|
@@ -21,13 +22,32 @@ export function isCompressible(contentType) {
|
|
|
21
22
|
return !1;
|
|
22
23
|
return COMPRESSIBLE.some((prefix) => type.startsWith(prefix));
|
|
23
24
|
}
|
|
25
|
+
function parseAcceptEncoding(header) {
|
|
26
|
+
const weights = new Map;
|
|
27
|
+
for (const part of header.split(",")) {
|
|
28
|
+
const [rawName, ...params] = part.trim().split(";"), name = rawName.trim().toLowerCase();
|
|
29
|
+
if (!name)
|
|
30
|
+
continue;
|
|
31
|
+
let quality = 1;
|
|
32
|
+
for (const param of params) {
|
|
33
|
+
const match = /^\s*q\s*=\s*([\d.]+)\s*$/i.exec(param);
|
|
34
|
+
if (!match)
|
|
35
|
+
continue;
|
|
36
|
+
const parsed = Number.parseFloat(match[1]);
|
|
37
|
+
if (Number.isFinite(parsed))
|
|
38
|
+
quality = parsed;
|
|
39
|
+
}
|
|
40
|
+
weights.set(name, quality);
|
|
41
|
+
}
|
|
42
|
+
return weights;
|
|
43
|
+
}
|
|
24
44
|
export function negotiateEncoding(acceptEncoding) {
|
|
25
45
|
if (!acceptEncoding)
|
|
26
46
|
return null;
|
|
27
|
-
const
|
|
28
|
-
if (
|
|
47
|
+
const weights = parseAcceptEncoding(acceptEncoding), wildcard = weights.get("*"), quality = (coding) => weights.get(coding) ?? wildcard ?? 0;
|
|
48
|
+
if (quality("br") > 0)
|
|
29
49
|
return "br";
|
|
30
|
-
if (
|
|
50
|
+
if (quality("gzip") > 0)
|
|
31
51
|
return "gzip";
|
|
32
52
|
return null;
|
|
33
53
|
}
|
|
@@ -41,28 +61,47 @@ export async function compressResponse(request, response, options = {}) {
|
|
|
41
61
|
const encoding = negotiateEncoding(request.headers.get("accept-encoding"));
|
|
42
62
|
if (!encoding)
|
|
43
63
|
return response;
|
|
64
|
+
let body;
|
|
44
65
|
try {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
66
|
+
body = new Uint8Array(await response.arrayBuffer());
|
|
67
|
+
} catch {
|
|
68
|
+
return response;
|
|
69
|
+
}
|
|
70
|
+
const passthrough = () => new Response(body, {
|
|
71
|
+
status: response.status,
|
|
72
|
+
statusText: response.statusText,
|
|
73
|
+
headers: varyByAcceptEncoding(response.headers)
|
|
74
|
+
});
|
|
75
|
+
if (body.length < (options.threshold ?? MIN_BYTES))
|
|
76
|
+
return passthrough();
|
|
77
|
+
try {
|
|
78
|
+
const compressed = encoding === "br" ? await brotliCompress(body, {
|
|
49
79
|
params: {
|
|
50
80
|
[zlib.constants.BROTLI_PARAM_QUALITY]: options.quality ?? 5,
|
|
51
81
|
[zlib.constants.BROTLI_PARAM_SIZE_HINT]: body.length
|
|
52
82
|
}
|
|
53
|
-
}) :
|
|
83
|
+
}) : await gzipCompress(body);
|
|
54
84
|
if (compressed.length >= body.length)
|
|
55
|
-
return
|
|
56
|
-
const headers =
|
|
85
|
+
return passthrough();
|
|
86
|
+
const headers = varyByAcceptEncoding(response.headers);
|
|
57
87
|
headers.set("Content-Encoding", encoding);
|
|
58
88
|
headers.set("Content-Length", String(compressed.length));
|
|
59
|
-
headers.append("Vary", "Accept-Encoding");
|
|
60
89
|
return new Response(compressed, {
|
|
61
90
|
status: response.status,
|
|
62
91
|
statusText: response.statusText,
|
|
63
92
|
headers
|
|
64
93
|
});
|
|
65
94
|
} catch {
|
|
66
|
-
return
|
|
95
|
+
return passthrough();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function varyByAcceptEncoding(source) {
|
|
99
|
+
const headers = new Headers(source), existing = headers.get("Vary");
|
|
100
|
+
if (!existing) {
|
|
101
|
+
headers.set("Vary", "Accept-Encoding");
|
|
102
|
+
return headers;
|
|
67
103
|
}
|
|
104
|
+
if (!existing.split(",").some((field) => field.trim().toLowerCase() === "accept-encoding"))
|
|
105
|
+
headers.set("Vary", `${existing}, Accept-Encoding`);
|
|
106
|
+
return headers;
|
|
68
107
|
}
|