qwenproxy-cli 1.6.4 → 1.6.6
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/clean-cache.js +84 -11
- package/dist/delete-chats.js +183 -45
- package/dist/index.js +267 -77
- package/dist/login.js +92 -16
- package/dist/reset-cooldowns.js +87 -12
- package/package.json +1 -1
- package/src/core/mutex.ts +4 -0
- package/src/core/version.ts +1 -1
- package/src/routes/anthropic/translate.ts +15 -7
- package/src/routes/chat/helpers.ts +4 -2
- package/src/routes/chat/streaming.ts +66 -31
- package/src/routes/responses/adapter.ts +4 -1
- package/src/services/context-meter.ts +1 -1
- package/src/services/playwright.ts +57 -20
- package/src/services/qwen-headers.ts +3 -2
- package/src/services/qwen.ts +87 -30
- package/src/tools/tool-call-heuristics.ts +88 -0
- package/src/utils/timezone.ts +56 -0
package/src/services/qwen.ts
CHANGED
|
@@ -31,6 +31,7 @@ import { withAccountPage, assertAntiBotHeaders, onBrowserContextCreated, isPlayw
|
|
|
31
31
|
import { recoverBaxiaCaptcha } from "./captcha-coordinator.ts";
|
|
32
32
|
import { startBaxiaCaptchaWatcher } from "./captcha-solver.ts";
|
|
33
33
|
import { isAccountBusy } from "../core/account-concurrency.ts";
|
|
34
|
+
import { formatAsciiTimezone, sanitizeHeaderForFetch } from "../utils/timezone.ts";
|
|
34
35
|
|
|
35
36
|
// Re-exported from extracted modules for backward compatibility
|
|
36
37
|
export {
|
|
@@ -611,18 +612,21 @@ export function buildCapturedQwenHeaders(
|
|
|
611
612
|
// the first's whole generation). FIFO handoff on release keeps the old
|
|
612
613
|
// leak-recovery semantics (release is idempotent; a dropped stream still blocks
|
|
613
614
|
// its slot until cancel/idle-timeout frees it — same as before).
|
|
615
|
+
export const STREAM_SLOT_MAX_HOLD_MS = 30 * 60 * 1000; // 30 minutes
|
|
616
|
+
|
|
614
617
|
interface AccountStreamSlots {
|
|
615
|
-
|
|
616
|
-
queue: Array<() => void>;
|
|
618
|
+
activeSlots: Map<number, number>; // slotId -> startedAt
|
|
619
|
+
queue: Array<(releaseFn: () => void) => void>;
|
|
617
620
|
}
|
|
618
621
|
const accountStreamMutexes = new Map<string, AccountStreamSlots>();
|
|
622
|
+
let nextStreamSlotId = 1;
|
|
619
623
|
|
|
620
|
-
function getAccountStreamMutex(
|
|
624
|
+
export function getAccountStreamMutex(
|
|
621
625
|
accountId: string,
|
|
622
626
|
): AccountStreamSlots {
|
|
623
627
|
let slots = accountStreamMutexes.get(accountId);
|
|
624
628
|
if (!slots) {
|
|
625
|
-
slots = {
|
|
629
|
+
slots = { activeSlots: new Map(), queue: [] };
|
|
626
630
|
accountStreamMutexes.set(accountId, slots);
|
|
627
631
|
}
|
|
628
632
|
return slots;
|
|
@@ -632,29 +636,78 @@ function streamSlotCapacity(): number {
|
|
|
632
636
|
return Math.max(1, config.concurrency.maxStreamsPerAccount);
|
|
633
637
|
}
|
|
634
638
|
|
|
635
|
-
function createStreamSlotRelease(slots: AccountStreamSlots): () => void {
|
|
639
|
+
function createStreamSlotRelease(slots: AccountStreamSlots, slotId: number): () => void {
|
|
636
640
|
let released = false;
|
|
637
641
|
return () => {
|
|
638
642
|
if (released) return;
|
|
639
643
|
released = true;
|
|
640
|
-
slots.
|
|
644
|
+
slots.activeSlots.delete(slotId);
|
|
641
645
|
const next = slots.queue.shift();
|
|
642
646
|
if (next) {
|
|
643
647
|
// Hand the slot directly to the waiter (FIFO), same as the old mutex.
|
|
644
|
-
|
|
645
|
-
|
|
648
|
+
const nextId = nextStreamSlotId++;
|
|
649
|
+
slots.activeSlots.set(nextId, Date.now());
|
|
650
|
+
next(createStreamSlotRelease(slots, nextId));
|
|
646
651
|
}
|
|
647
652
|
};
|
|
648
653
|
}
|
|
649
654
|
|
|
650
|
-
|
|
655
|
+
export function sweepStaleAccountStreamSlots(maxHoldMs: number = STREAM_SLOT_MAX_HOLD_MS): number {
|
|
656
|
+
const now = Date.now();
|
|
657
|
+
let sweptCount = 0;
|
|
658
|
+
for (const [accountId, slots] of accountStreamMutexes.entries()) {
|
|
659
|
+
for (const [slotId, startedAt] of Array.from(slots.activeSlots.entries())) {
|
|
660
|
+
if (now - startedAt >= maxHoldMs) {
|
|
661
|
+
logger.warn(
|
|
662
|
+
`[Qwen] Force-releasing stale stream slot | account=${accountId} | slotId=${slotId} | heldFor=${now - startedAt}ms | maxHold=${maxHoldMs}ms`,
|
|
663
|
+
);
|
|
664
|
+
slots.activeSlots.delete(slotId);
|
|
665
|
+
sweptCount++;
|
|
666
|
+
const next = slots.queue.shift();
|
|
667
|
+
if (next) {
|
|
668
|
+
const nextId = nextStreamSlotId++;
|
|
669
|
+
slots.activeSlots.set(nextId, now);
|
|
670
|
+
next(createStreamSlotRelease(slots, nextId));
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return sweptCount;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let staleSlotSweeperTimer: NodeJS.Timeout | null = null;
|
|
679
|
+
|
|
680
|
+
export function ensureStaleSlotSweeperStarted(): void {
|
|
681
|
+
if (staleSlotSweeperTimer) return;
|
|
682
|
+
staleSlotSweeperTimer = setInterval(() => {
|
|
683
|
+
try {
|
|
684
|
+
sweepStaleAccountStreamSlots();
|
|
685
|
+
} catch (err) {
|
|
686
|
+
logger.error("[Qwen] Error in stale stream slot sweeper", {
|
|
687
|
+
error: err instanceof Error ? err.message : String(err),
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
}, 60_000);
|
|
691
|
+
staleSlotSweeperTimer.unref();
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function stopStaleSlotSweeperForTests(): void {
|
|
695
|
+
if (staleSlotSweeperTimer) {
|
|
696
|
+
clearInterval(staleSlotSweeperTimer);
|
|
697
|
+
staleSlotSweeperTimer = null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export async function acquireAccountStreamLock(accountId: string): Promise<() => void> {
|
|
702
|
+
ensureStaleSlotSweeperStarted();
|
|
651
703
|
const slots = getAccountStreamMutex(accountId);
|
|
652
|
-
if (slots.
|
|
653
|
-
|
|
654
|
-
|
|
704
|
+
if (slots.activeSlots.size < streamSlotCapacity()) {
|
|
705
|
+
const slotId = nextStreamSlotId++;
|
|
706
|
+
slots.activeSlots.set(slotId, Date.now());
|
|
707
|
+
return Promise.resolve(createStreamSlotRelease(slots, slotId));
|
|
655
708
|
}
|
|
656
709
|
return new Promise<() => void>((resolve) => {
|
|
657
|
-
slots.queue.push(() => resolve(
|
|
710
|
+
slots.queue.push((releaseFn) => resolve(releaseFn));
|
|
658
711
|
});
|
|
659
712
|
}
|
|
660
713
|
|
|
@@ -830,7 +883,7 @@ async function withQwenBrowserPage<T>(
|
|
|
830
883
|
* adds Cookie, User-Agent, Origin, Referer, and sec-* headers, so we only
|
|
831
884
|
* pass the anti-bot tokens and metadata that the browser cannot infer.
|
|
832
885
|
*/
|
|
833
|
-
function getBrowserFetchHeaders(
|
|
886
|
+
export function getBrowserFetchHeaders(
|
|
834
887
|
headers: Record<string, string>,
|
|
835
888
|
): Record<string, string> {
|
|
836
889
|
const browserAllowedHeaders = new Set([
|
|
@@ -847,11 +900,13 @@ function getBrowserFetchHeaders(
|
|
|
847
900
|
"x-accel-buffering",
|
|
848
901
|
]);
|
|
849
902
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
903
|
+
const result: Record<string, string> = {};
|
|
904
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
905
|
+
if (browserAllowedHeaders.has(name.toLowerCase())) {
|
|
906
|
+
result[name] = sanitizeHeaderForFetch(String(value ?? ""));
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
return result;
|
|
855
910
|
}
|
|
856
911
|
|
|
857
912
|
interface BrowserTextResponse {
|
|
@@ -906,13 +961,14 @@ export async function requestQwenTextInBrowser(
|
|
|
906
961
|
|
|
907
962
|
const evaluateRequest = (page: Page) =>
|
|
908
963
|
page.evaluate(
|
|
909
|
-
async ({ url, method, headers, body, referrer, timeoutMs }: {
|
|
964
|
+
async ({ url, method, headers, body, referrer, timeoutMs, tz }: {
|
|
910
965
|
url: string;
|
|
911
966
|
method: "GET" | "POST" | "DELETE";
|
|
912
967
|
headers: Record<string, string>;
|
|
913
968
|
body?: string;
|
|
914
969
|
referrer?: string;
|
|
915
970
|
timeoutMs: number;
|
|
971
|
+
tz: string;
|
|
916
972
|
}): Promise<BrowserTextResponse> => {
|
|
917
973
|
const controller = new AbortController();
|
|
918
974
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
@@ -939,7 +995,7 @@ export async function requestQwenTextInBrowser(
|
|
|
939
995
|
source: "web",
|
|
940
996
|
version: "0.3.11",
|
|
941
997
|
"x-request-origin": "https://chat.qwen.ai",
|
|
942
|
-
timezone:
|
|
998
|
+
timezone: tz,
|
|
943
999
|
authorization: `Bearer ${rTok}`,
|
|
944
1000
|
},
|
|
945
1001
|
signal: AbortSignal.timeout(5000),
|
|
@@ -985,6 +1041,7 @@ export async function requestQwenTextInBrowser(
|
|
|
985
1041
|
body,
|
|
986
1042
|
referrer: options.referrer,
|
|
987
1043
|
timeoutMs: options.timeoutMs ?? Math.min(config.timeouts.page, 20_000),
|
|
1044
|
+
tz: formatAsciiTimezone(),
|
|
988
1045
|
},
|
|
989
1046
|
);
|
|
990
1047
|
const recoverOnTimeout = !options.noMutexRecovery;
|
|
@@ -1931,7 +1988,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1931
1988
|
async (page) => {
|
|
1932
1989
|
const requestId = crypto.randomUUID();
|
|
1933
1990
|
const response = await page.evaluate(
|
|
1934
|
-
async ({ payload, timeoutMs, requestId }: { payload: any; timeoutMs: number; requestId: string }) => {
|
|
1991
|
+
async ({ payload, timeoutMs, requestId, tz }: { payload: any; timeoutMs: number; requestId: string; tz: string }) => {
|
|
1935
1992
|
const controller = new AbortController();
|
|
1936
1993
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1937
1994
|
try {
|
|
@@ -1943,7 +2000,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1943
2000
|
accept: "application/json, text/plain, */*",
|
|
1944
2001
|
"content-type": "application/json",
|
|
1945
2002
|
"x-request-id": requestId,
|
|
1946
|
-
timezone:
|
|
2003
|
+
timezone: tz,
|
|
1947
2004
|
source: "web",
|
|
1948
2005
|
},
|
|
1949
2006
|
body: JSON.stringify(payload),
|
|
@@ -1955,7 +2012,7 @@ export async function disableNativeTools(accountId?: string): Promise<void> {
|
|
|
1955
2012
|
clearTimeout(timeoutId);
|
|
1956
2013
|
}
|
|
1957
2014
|
},
|
|
1958
|
-
{ payload, timeoutMs: config.timeouts.http, requestId },
|
|
2015
|
+
{ payload, timeoutMs: config.timeouts.http, requestId, tz: formatAsciiTimezone() },
|
|
1959
2016
|
);
|
|
1960
2017
|
return response;
|
|
1961
2018
|
},
|
|
@@ -2241,7 +2298,7 @@ export async function deleteSingleQwenChat(
|
|
|
2241
2298
|
const requestHeaders: Record<string, string> = {
|
|
2242
2299
|
source: "web",
|
|
2243
2300
|
version: "0.2.89",
|
|
2244
|
-
timezone:
|
|
2301
|
+
timezone: formatAsciiTimezone(),
|
|
2245
2302
|
"x-request-id": crypto.randomUUID(),
|
|
2246
2303
|
Referer: qwenUrl(`/c/${encodeURIComponent(chatId)}`),
|
|
2247
2304
|
};
|
|
@@ -2294,7 +2351,7 @@ export async function fetchRemoteQwenChats(
|
|
|
2294
2351
|
|
|
2295
2352
|
const requestHeaders: Record<string, string> = {
|
|
2296
2353
|
version: "0.2.89",
|
|
2297
|
-
timezone:
|
|
2354
|
+
timezone: formatAsciiTimezone(),
|
|
2298
2355
|
"x-request-id": crypto.randomUUID(),
|
|
2299
2356
|
Referer: qwenUrl("/settings/chats"),
|
|
2300
2357
|
};
|
|
@@ -2356,7 +2413,7 @@ export async function fetchQwenModels(
|
|
|
2356
2413
|
async (page) => {
|
|
2357
2414
|
const requestId = crypto.randomUUID();
|
|
2358
2415
|
const response = await page.evaluate(
|
|
2359
|
-
async ({ timeoutMs, requestId }: { timeoutMs: number; requestId: string }) => {
|
|
2416
|
+
async ({ timeoutMs, requestId, tz }: { timeoutMs: number; requestId: string; tz: string }) => {
|
|
2360
2417
|
const controller = new AbortController();
|
|
2361
2418
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
2362
2419
|
try {
|
|
@@ -2365,7 +2422,7 @@ export async function fetchQwenModels(
|
|
|
2365
2422
|
headers: {
|
|
2366
2423
|
accept: "application/json, text/plain, */*",
|
|
2367
2424
|
"x-request-id": requestId,
|
|
2368
|
-
timezone:
|
|
2425
|
+
timezone: tz,
|
|
2369
2426
|
source: "web",
|
|
2370
2427
|
},
|
|
2371
2428
|
signal: controller.signal,
|
|
@@ -2375,7 +2432,7 @@ export async function fetchQwenModels(
|
|
|
2375
2432
|
clearTimeout(timeoutId);
|
|
2376
2433
|
}
|
|
2377
2434
|
},
|
|
2378
|
-
{ timeoutMs: config.timeouts.http, requestId },
|
|
2435
|
+
{ timeoutMs: config.timeouts.http, requestId, tz: formatAsciiTimezone() },
|
|
2379
2436
|
);
|
|
2380
2437
|
return response;
|
|
2381
2438
|
},
|
|
@@ -2421,7 +2478,7 @@ export async function fetchQwenModels(
|
|
|
2421
2478
|
bxUa,
|
|
2422
2479
|
bxUmidtoken,
|
|
2423
2480
|
extra: {
|
|
2424
|
-
timezone:
|
|
2481
|
+
timezone: formatAsciiTimezone(),
|
|
2425
2482
|
},
|
|
2426
2483
|
}),
|
|
2427
2484
|
undefined,
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tool-call-heuristics.ts — Heuristics for deciding whether a turn that produced
|
|
3
|
+
* NO (successful) tool call should be auto-retried.
|
|
4
|
+
*
|
|
5
|
+
* The existing retry path only fires when a `<tool_call>` block was emitted
|
|
6
|
+
* but could not be parsed (malformed / undeclared). It does NOT cover the case
|
|
7
|
+
* where the model emits no tool-call block at all and instead answers with
|
|
8
|
+
* prose that references tools as if they were missing, e.g.:
|
|
9
|
+
*
|
|
10
|
+
* "Tool bash does not exists."
|
|
11
|
+
* "Tool read does not exists."
|
|
12
|
+
*
|
|
13
|
+
* In that case the client sees `finish_reason: "stop"` with no `tool_calls`,
|
|
14
|
+
* ends its turn, and the loop stalls until a human nudges it ("continue").
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type MissingToolCallReason = "malformed" | "no_call" | "json_in_text" | null;
|
|
18
|
+
|
|
19
|
+
export interface MissingToolCallInput {
|
|
20
|
+
/** Number of tool definitions declared on the request. */
|
|
21
|
+
declaredToolCount: number;
|
|
22
|
+
/** Tool calls that parsed AND resolved to a declared tool. */
|
|
23
|
+
successfulToolCallCount: number;
|
|
24
|
+
/** `<tool_call>` blocks captured but rejected (bad JSON / undeclared name). */
|
|
25
|
+
malformedToolCallCount: number;
|
|
26
|
+
/** Total tool-call blocks the parser saw (malformed + capped + valid). */
|
|
27
|
+
emittedToolCallCount: number;
|
|
28
|
+
/** Assistant-visible text for the turn (excluding the tool-call blocks). */
|
|
29
|
+
text: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MissingToolCallDecision {
|
|
33
|
+
retry: boolean;
|
|
34
|
+
reason: MissingToolCallReason;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Matches a model reply that talks about a tool as if it did not exist, which
|
|
39
|
+
* is the signature of the "lost tool definitions under long context" failure.
|
|
40
|
+
* Kept deliberately narrow to avoid retrying legitimate prose.
|
|
41
|
+
*/
|
|
42
|
+
export function isToolHesitationText(text: string): boolean {
|
|
43
|
+
if (!text) return false;
|
|
44
|
+
return /\btools?\b[^.\n]{0,24}?\b(?:does\s*n[o']?t|do\s*n[o']?t|does\s*not|do\s*not)\b[^.\n]{0,12}?\bexist/i.test(
|
|
45
|
+
text,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Detects when the model dumped a raw tool-call JSON payload as prose instead
|
|
51
|
+
* of emitting it as a structured tool call.
|
|
52
|
+
*/
|
|
53
|
+
export function isRawToolCallJsonInText(text: string): boolean {
|
|
54
|
+
if (!text) return false;
|
|
55
|
+
const hasToolCallJson =
|
|
56
|
+
/\{\s*"name"\s*:\s*"[a-zA-Z_][a-zA-Z0-9_]*"\s*,\s*"arguments"\s*:\s*\{/i.test(text);
|
|
57
|
+
const hasStrayCloseTag = /<\/tool_call>/.test(text);
|
|
58
|
+
return hasToolCallJson || hasStrayCloseTag;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function decideMissingToolCallRetry(
|
|
62
|
+
input: MissingToolCallInput,
|
|
63
|
+
): MissingToolCallDecision {
|
|
64
|
+
const {
|
|
65
|
+
declaredToolCount,
|
|
66
|
+
successfulToolCallCount,
|
|
67
|
+
malformedToolCallCount,
|
|
68
|
+
emittedToolCallCount,
|
|
69
|
+
text,
|
|
70
|
+
} = input;
|
|
71
|
+
|
|
72
|
+
// No tools were declared: a text answer is the expected outcome.
|
|
73
|
+
if (declaredToolCount <= 0) return { retry: false, reason: null };
|
|
74
|
+
|
|
75
|
+
// A usable call was produced: nothing to recover.
|
|
76
|
+
if (successfulToolCallCount > 0) return { retry: false, reason: null };
|
|
77
|
+
|
|
78
|
+
// Existing behaviour: a block was emitted but it was rejected.
|
|
79
|
+
if (malformedToolCallCount > 0) return { retry: true, reason: "malformed" };
|
|
80
|
+
|
|
81
|
+
// New behaviour: no `<tool_call>` block was emitted at all, yet the model
|
|
82
|
+
// talked about tools as missing (the "does not exists" case).
|
|
83
|
+
if (emittedToolCallCount === 0 && isToolHesitationText(text)) {
|
|
84
|
+
return { retry: true, reason: "no_call" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { retry: false, reason: null };
|
|
88
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* timezone.ts — Locale-safe ASCII timezone header formatting and sanitization.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees that HTTP headers passed into browser `window.fetch()` contain
|
|
5
|
+
* exclusively ISO-8859-1 code points (<= 255), preventing:
|
|
6
|
+
* `TypeError: Failed to read the 'headers' property from 'RequestInit': String contains non ISO-8859-1 code point`
|
|
7
|
+
* on systems configured with non-English locales (e.g. Russian, CJK, Arabic).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
11
|
+
const MONTHS = [
|
|
12
|
+
"Jan",
|
|
13
|
+
"Feb",
|
|
14
|
+
"Mar",
|
|
15
|
+
"Apr",
|
|
16
|
+
"May",
|
|
17
|
+
"Jun",
|
|
18
|
+
"Jul",
|
|
19
|
+
"Aug",
|
|
20
|
+
"Sep",
|
|
21
|
+
"Oct",
|
|
22
|
+
"Nov",
|
|
23
|
+
"Dec",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Format a Date into standard English browser date string matching Chrome,
|
|
28
|
+
* guaranteed pure ASCII (<= 127).
|
|
29
|
+
* Example: "Tue Oct 20 2026 14:35:10 GMT-0300"
|
|
30
|
+
*/
|
|
31
|
+
export function formatAsciiTimezone(date: Date = new Date()): string {
|
|
32
|
+
const dayName = DAYS[date.getDay()];
|
|
33
|
+
const monthName = MONTHS[date.getMonth()];
|
|
34
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
35
|
+
const year = date.getFullYear();
|
|
36
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
37
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
38
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
39
|
+
|
|
40
|
+
const offsetMinutes = -date.getTimezoneOffset();
|
|
41
|
+
const sign = offsetMinutes >= 0 ? "+" : "-";
|
|
42
|
+
const absOffset = Math.abs(offsetMinutes);
|
|
43
|
+
const offsetHours = String(Math.floor(absOffset / 60)).padStart(2, "0");
|
|
44
|
+
const offsetMins = String(absOffset % 60).padStart(2, "0");
|
|
45
|
+
const tzOffset = `GMT${sign}${offsetHours}${offsetMins}`;
|
|
46
|
+
|
|
47
|
+
return `${dayName} ${monthName} ${day} ${year} ${hours}:${minutes}:${seconds} ${tzOffset}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Sanitizes header values for browser fetch, stripping any code points > 255.
|
|
52
|
+
*/
|
|
53
|
+
export function sanitizeHeaderForFetch(val: string): string {
|
|
54
|
+
if (typeof val !== "string") return "";
|
|
55
|
+
return val.replace(/[^\x00-\xFF]/g, "");
|
|
56
|
+
}
|