pagesight 0.17.0 → 0.18.0
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/README.md +17 -83
- package/docs/credentials.md +54 -0
- package/docs/diagnostics.md +85 -0
- package/docs/snapshots.md +123 -0
- package/docs/usage.md +130 -0
- package/package.json +26 -29
- package/src/api/bing.ts +83 -0
- package/src/api/compare-snapshots.ts +377 -0
- package/src/api/discover.ts +39 -0
- package/src/api/doctor.ts +35 -0
- package/src/api/evidence-schema.ts +78 -0
- package/src/api/evidence.ts +99 -0
- package/src/api/execute.ts +147 -0
- package/src/api/index.ts +5 -0
- package/src/api/reports.ts +90 -0
- package/src/api/schema.ts +207 -0
- package/src/api/snapshot.ts +183 -0
- package/src/api/ui-findings.ts +68 -0
- package/src/cli.ts +182 -0
- package/src/http.ts +39 -0
- package/src/index.ts +8 -25
- package/src/mcp-server.ts +27 -0
- package/src/mcp.ts +5 -0
- package/src/providers/bing.ts +48 -0
- package/src/{lib → providers}/crux.ts +4 -9
- package/src/providers/ga.ts +114 -0
- package/src/providers/google-tokens.ts +86 -0
- package/src/providers/gsc-auth.ts +93 -0
- package/src/{lib → providers}/gsc.ts +27 -24
- package/src/{lib/psi.ts → providers/pagespeed.ts} +3 -14
- package/src/shared/dates.ts +32 -0
- package/src/shared/http.ts +63 -0
- package/src/tools/ai.ts +19 -27
- package/src/tools/audit.ts +18 -98
- package/src/tools/observe.ts +28 -0
- package/src/tools/page/analyze.ts +194 -0
- package/src/tools/page/batch.ts +163 -0
- package/src/tools/page/contrast.ts +128 -0
- package/src/tools/page/links.ts +200 -0
- package/src/tools/page/metadata.ts +225 -0
- package/src/tools/page/structured-data.ts +288 -0
- package/src/tools/page/tool.ts +48 -0
- package/src/tools/search/actions.ts +56 -0
- package/src/tools/search/analytics.ts +266 -0
- package/src/tools/search/coverage.ts +247 -0
- package/src/tools/search/gaps.ts +129 -0
- package/src/tools/search/inspection.ts +160 -0
- package/src/tools/search/result.ts +3 -0
- package/src/tools/search/sample.ts +110 -0
- package/src/tools/search/schema.ts +62 -0
- package/src/{lib/sitemap.ts → tools/search/sitemap-sampling.ts} +1 -45
- package/src/tools/search/sites.ts +86 -0
- package/src/tools/search/tool.ts +11 -0
- package/src/tools/setup.ts +1 -1
- package/src/tools/speed/analyze.ts +191 -0
- package/src/tools/speed/batch.ts +265 -0
- package/src/tools/speed/crux.ts +176 -0
- package/src/tools/speed/pagespeed.ts +273 -0
- package/src/tools/speed/schema.ts +39 -0
- package/src/tools/speed/tool.ts +11 -0
- package/src/web/fetch.ts +31 -0
- package/src/web/images.ts +62 -0
- package/src/web/page-observation.ts +69 -0
- package/src/{lib → web}/robots.ts +20 -12
- package/src/web/sitemap-inventory.ts +64 -0
- package/src/web/sitemap-parser.ts +59 -0
- package/src/lib/auth.ts +0 -187
- package/src/tools/page.ts +0 -1241
- package/src/tools/search.ts +0 -1118
- package/src/tools/speed.ts +0 -956
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* robots.txt parser
|
|
2
|
+
* robots.txt parser based on RFC 9309
|
|
3
3
|
* https://www.rfc-editor.org/rfc/rfc9309
|
|
4
4
|
*
|
|
5
5
|
* AI crawler registry sourced from:
|
|
@@ -152,17 +152,24 @@ export function parseRobotsTxt(raw: string): RobotsTxt {
|
|
|
152
152
|
return { groups, sitemaps, raw, errors };
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
-
// --- Matching
|
|
155
|
+
// --- Matching ---
|
|
156
|
+
|
|
157
|
+
// RFC 9309 §2.2.2: decode only unreserved ASCII; preserve reserved escapes on both sides.
|
|
158
|
+
function normalizePath(path: string): string {
|
|
159
|
+
return path.replace(/%[0-9a-f]{2}|\P{ASCII}/giu, (part) => {
|
|
160
|
+
if (part.startsWith("%")) {
|
|
161
|
+
const decoded = String.fromCharCode(Number.parseInt(part.slice(1), 16));
|
|
162
|
+
return /^[a-z0-9._~-]$/i.test(decoded) ? decoded : part.toUpperCase();
|
|
163
|
+
}
|
|
164
|
+
return encodeURIComponent(part.toWellFormed());
|
|
165
|
+
});
|
|
166
|
+
}
|
|
156
167
|
|
|
157
168
|
function pathMatches(pattern: string, path: string): boolean {
|
|
158
169
|
if (!pattern) return false;
|
|
159
170
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
path = decodeURIComponent(path);
|
|
163
|
-
} catch {
|
|
164
|
-
// malformed encoding, use as-is
|
|
165
|
-
}
|
|
171
|
+
path = normalizePath(path);
|
|
172
|
+
pattern = normalizePath(pattern);
|
|
166
173
|
|
|
167
174
|
let regex = "^";
|
|
168
175
|
for (let i = 0; i < pattern.length; i++) {
|
|
@@ -231,7 +238,7 @@ export function isAllowed(
|
|
|
231
238
|
|
|
232
239
|
for (const rule of matchedRules) {
|
|
233
240
|
if (pathMatches(rule.path, path)) {
|
|
234
|
-
const ruleLength = rule.path.length;
|
|
241
|
+
const ruleLength = normalizePath(rule.path).length;
|
|
235
242
|
if (ruleLength > bestLength || (ruleLength === bestLength && rule.type === "allow")) {
|
|
236
243
|
bestRule = rule;
|
|
237
244
|
bestLength = ruleLength;
|
|
@@ -278,14 +285,15 @@ export async function fetchRobotsTxt(origin: string): Promise<{ robotsTxt: Robot
|
|
|
278
285
|
redirect: "follow",
|
|
279
286
|
});
|
|
280
287
|
|
|
281
|
-
// RFC 9309
|
|
282
|
-
//
|
|
288
|
+
// RFC 9309 §§2.3.1.3–4 distinguish unavailable (4xx) from unreachable (5xx).
|
|
289
|
+
// Pagesight treats 429 conservatively; Google also excludes it from the allow-on-4xx rule.
|
|
290
|
+
// https://developers.google.com/search/docs/crawling-indexing/robots/robots_txt#handling-http-status-codes
|
|
283
291
|
if (res.status >= 500 || res.status === 429) {
|
|
284
292
|
const disallowAll: RobotsTxt = {
|
|
285
293
|
groups: [{ userAgents: ["*"], rules: [{ type: "disallow", path: "/" }] }],
|
|
286
294
|
sitemaps: [],
|
|
287
295
|
raw: "",
|
|
288
|
-
errors: [`Server returned ${res.status} — treating as full disallow
|
|
296
|
+
errors: [`Server returned ${res.status} — treating as full disallow`],
|
|
289
297
|
};
|
|
290
298
|
return { robotsTxt: disallowAll, statusCode: res.status };
|
|
291
299
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { RequestError } from "../shared/http.js";
|
|
2
|
+
import { parseInventorySitemap } from "./sitemap-parser.js";
|
|
3
|
+
import { fetchText } from "./fetch.js";
|
|
4
|
+
|
|
5
|
+
export async function observeSitemap(url: string) {
|
|
6
|
+
const origin = new URL(url).origin;
|
|
7
|
+
const queue = [url];
|
|
8
|
+
const scheduled = new Set(queue);
|
|
9
|
+
let omittedChildReferences = 0;
|
|
10
|
+
const visited = new Set<string>();
|
|
11
|
+
const urls = new Set<string>();
|
|
12
|
+
const documents: Array<{ url: string; status: number; sha256: string; bytes: number }> = [];
|
|
13
|
+
const errors: Array<{ url: string; code: string }> = [];
|
|
14
|
+
const futureLastmods = new Set<string>();
|
|
15
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
16
|
+
let bytes = 0;
|
|
17
|
+
while (queue.length && visited.size < 5 && bytes < 8_000_000) {
|
|
18
|
+
const next = queue.shift();
|
|
19
|
+
if (!next || visited.has(next)) continue;
|
|
20
|
+
visited.add(next);
|
|
21
|
+
try {
|
|
22
|
+
if (new URL(next).origin !== origin)
|
|
23
|
+
throw new RequestError("Child sitemap is outside the site origin", null, "outside_origin");
|
|
24
|
+
const page = await fetchText(next, 8_000_000 - bytes, origin);
|
|
25
|
+
if (!page.response.ok) throw new RequestError("Sitemap fetch failed", page.response.status);
|
|
26
|
+
bytes += Buffer.byteLength(page.body);
|
|
27
|
+
const parsed = parseInventorySitemap(page.body);
|
|
28
|
+
documents.push({
|
|
29
|
+
url: page.finalUrl,
|
|
30
|
+
status: page.response.status,
|
|
31
|
+
bytes: Buffer.byteLength(page.body),
|
|
32
|
+
sha256: Bun.CryptoHasher.hash("sha256", page.body, "hex"),
|
|
33
|
+
});
|
|
34
|
+
for (const child of parsed.children) {
|
|
35
|
+
if (scheduled.has(child)) continue;
|
|
36
|
+
if (scheduled.size >= 5) {
|
|
37
|
+
omittedChildReferences++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
scheduled.add(child);
|
|
41
|
+
queue.push(child);
|
|
42
|
+
}
|
|
43
|
+
for (const item of parsed.urls) urls.add(item);
|
|
44
|
+
for (const lastmod of parsed.lastmods) if (lastmod.slice(0, 10) > today) futureLastmods.add(lastmod);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
errors.push({ url: next, code: error instanceof RequestError ? error.code : "fetch_failed" });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
sitemap: url,
|
|
51
|
+
complete: queue.length === 0 && errors.length === 0 && omittedChildReferences === 0,
|
|
52
|
+
documents,
|
|
53
|
+
urls: [...urls],
|
|
54
|
+
urlCount: urls.size,
|
|
55
|
+
bytes,
|
|
56
|
+
unvisited: queue,
|
|
57
|
+
omittedChildReferences,
|
|
58
|
+
errors,
|
|
59
|
+
futureLastmods: [...futureLastmods],
|
|
60
|
+
warnings: [
|
|
61
|
+
"Sitemap membership is not indexing evidence. Inventory is limited to five same-origin XML documents and 8 MB.",
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { SaxesParser, type SaxesTagNS } from "saxes";
|
|
2
|
+
import { RequestError } from "../shared/http.js";
|
|
3
|
+
|
|
4
|
+
export function parseInventorySitemap(xml: string) {
|
|
5
|
+
const parser = new SaxesParser({ xmlns: true });
|
|
6
|
+
const stack: SaxesTagNS[] = [];
|
|
7
|
+
const urls: string[] = [];
|
|
8
|
+
const children: string[] = [];
|
|
9
|
+
const lastmods: string[] = [];
|
|
10
|
+
let root = "";
|
|
11
|
+
let text = "";
|
|
12
|
+
let locations = 0;
|
|
13
|
+
const invalid = () => {
|
|
14
|
+
throw new RequestError("Expected well-formed sitemap XML", null, "invalid_sitemap");
|
|
15
|
+
};
|
|
16
|
+
parser.on("error", invalid);
|
|
17
|
+
parser.on("doctype", invalid);
|
|
18
|
+
parser.on("opentag", (tag) => {
|
|
19
|
+
stack.push(tag);
|
|
20
|
+
if (stack.length === 1) {
|
|
21
|
+
root = tag.local;
|
|
22
|
+
if (tag.prefix)
|
|
23
|
+
throw new RequestError(
|
|
24
|
+
"Prefixed sitemap XML is not supported by this inventory reader",
|
|
25
|
+
null,
|
|
26
|
+
"unsupported_sitemap",
|
|
27
|
+
);
|
|
28
|
+
if (
|
|
29
|
+
!["urlset", "sitemapindex"].includes(root) ||
|
|
30
|
+
(tag.uri && tag.uri !== "http://www.sitemaps.org/schemas/sitemap/0.9")
|
|
31
|
+
)
|
|
32
|
+
invalid();
|
|
33
|
+
}
|
|
34
|
+
if (stack.length === 2) {
|
|
35
|
+
if (tag.local !== (root === "urlset" ? "url" : "sitemap") || tag.uri !== stack[0].uri) invalid();
|
|
36
|
+
locations = 0;
|
|
37
|
+
}
|
|
38
|
+
if (stack.length === 3) text = "";
|
|
39
|
+
if (stack.length > 3 && ["loc", "lastmod"].includes(stack[2].local) && stack[2].uri === stack[0].uri) invalid();
|
|
40
|
+
});
|
|
41
|
+
const append = (value: string) => {
|
|
42
|
+
if (stack.length === 3) text += value;
|
|
43
|
+
};
|
|
44
|
+
parser.on("text", append);
|
|
45
|
+
parser.on("cdata", append);
|
|
46
|
+
parser.on("closetag", (tag) => {
|
|
47
|
+
if (stack.length === 3 && tag.uri === stack[0].uri) {
|
|
48
|
+
if (tag.local === "loc") {
|
|
49
|
+
if (++locations !== 1 || !text.trim()) invalid();
|
|
50
|
+
(root === "urlset" ? urls : children).push(text.trim());
|
|
51
|
+
}
|
|
52
|
+
if (tag.local === "lastmod") lastmods.push(text.trim());
|
|
53
|
+
}
|
|
54
|
+
if (stack.length === 2 && locations !== 1) invalid();
|
|
55
|
+
stack.pop();
|
|
56
|
+
});
|
|
57
|
+
parser.write(xml).close();
|
|
58
|
+
return { urls, children, lastmods };
|
|
59
|
+
}
|
package/src/lib/auth.ts
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
2
|
-
|
|
3
|
-
const SCOPES = ["https://www.googleapis.com/auth/webmasters.readonly"];
|
|
4
|
-
const TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
5
|
-
|
|
6
|
-
interface TokenResponse {
|
|
7
|
-
access_token: string;
|
|
8
|
-
expires_in: number;
|
|
9
|
-
token_type: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
let cachedToken: { token: string; expiresAt: number } | null = null;
|
|
13
|
-
|
|
14
|
-
// --- Service Account Auth ---
|
|
15
|
-
|
|
16
|
-
async function getServiceAccountToken(keyPath: string): Promise<string> {
|
|
17
|
-
const keyFile = JSON.parse(await Bun.file(keyPath).text());
|
|
18
|
-
if (!keyFile.client_email || !keyFile.private_key) {
|
|
19
|
-
throw new Error("Service account key file missing client_email or private_key");
|
|
20
|
-
}
|
|
21
|
-
const now = Math.floor(Date.now() / 1000);
|
|
22
|
-
|
|
23
|
-
const header = toBase64Url(JSON.stringify({ alg: "RS256", typ: "JWT" }));
|
|
24
|
-
const payload = toBase64Url(
|
|
25
|
-
JSON.stringify({
|
|
26
|
-
iss: keyFile.client_email,
|
|
27
|
-
scope: SCOPES.join(" "),
|
|
28
|
-
aud: TOKEN_URL,
|
|
29
|
-
iat: now,
|
|
30
|
-
exp: now + 3600,
|
|
31
|
-
}),
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const signingInput = `${header}.${payload}`;
|
|
35
|
-
const key = await crypto.subtle.importKey(
|
|
36
|
-
"pkcs8",
|
|
37
|
-
pemToBuffer(keyFile.private_key),
|
|
38
|
-
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
|
|
39
|
-
false,
|
|
40
|
-
["sign"],
|
|
41
|
-
);
|
|
42
|
-
const signature = await crypto.subtle.sign("RSASSA-PKCS1-v1_5", key, new TextEncoder().encode(signingInput));
|
|
43
|
-
const jwt = `${signingInput}.${bufferToBase64Url(signature)}`;
|
|
44
|
-
|
|
45
|
-
const res = await fetch(TOKEN_URL, {
|
|
46
|
-
method: "POST",
|
|
47
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
48
|
-
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
if (!res.ok) {
|
|
52
|
-
const err = await res.text();
|
|
53
|
-
throw new Error(`Service account token exchange failed: ${err}`);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const data: TokenResponse = await res.json();
|
|
57
|
-
return data.access_token;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function pemToBuffer(pem: string): ArrayBuffer {
|
|
61
|
-
const b64 = pem
|
|
62
|
-
.replace(/-----BEGIN PRIVATE KEY-----/g, "")
|
|
63
|
-
.replace(/-----END PRIVATE KEY-----/g, "")
|
|
64
|
-
.replace(/\s/g, "");
|
|
65
|
-
const binary = atob(b64);
|
|
66
|
-
const buf = new Uint8Array(binary.length);
|
|
67
|
-
for (let i = 0; i < binary.length; i++) buf[i] = binary.charCodeAt(i);
|
|
68
|
-
return buf.buffer;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
function toBase64Url(input: string): string {
|
|
72
|
-
return btoa(input).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function bufferToBase64Url(buf: ArrayBuffer): string {
|
|
76
|
-
const bytes = new Uint8Array(buf);
|
|
77
|
-
let binary = "";
|
|
78
|
-
for (const b of bytes) binary += String.fromCharCode(b);
|
|
79
|
-
return toBase64Url(binary);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// --- OAuth Refresh Token Auth ---
|
|
83
|
-
|
|
84
|
-
async function getOAuthToken(clientId: string, clientSecret: string, refreshToken: string): Promise<string> {
|
|
85
|
-
const res = await fetch(TOKEN_URL, {
|
|
86
|
-
method: "POST",
|
|
87
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
88
|
-
body: new URLSearchParams({
|
|
89
|
-
grant_type: "refresh_token",
|
|
90
|
-
client_id: clientId,
|
|
91
|
-
client_secret: clientSecret,
|
|
92
|
-
refresh_token: refreshToken,
|
|
93
|
-
}).toString(),
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
if (!res.ok) {
|
|
97
|
-
const err = await res.text();
|
|
98
|
-
throw new Error(`OAuth token refresh failed: ${err}`);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
const data: TokenResponse = await res.json();
|
|
102
|
-
return data.access_token;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// --- Public API ---
|
|
106
|
-
|
|
107
|
-
export async function getAccessToken(): Promise<string> {
|
|
108
|
-
// Return cached token if still valid
|
|
109
|
-
if (cachedToken && Date.now() < cachedToken.expiresAt) {
|
|
110
|
-
return cachedToken.token;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Try service account first
|
|
114
|
-
const saKeyPath = process.env.GSC_SERVICE_ACCOUNT_KEY;
|
|
115
|
-
if (saKeyPath && existsSync(saKeyPath)) {
|
|
116
|
-
const token = await getServiceAccountToken(saKeyPath);
|
|
117
|
-
cachedToken = { token, expiresAt: Date.now() + 3500_000 }; // ~58 min
|
|
118
|
-
return token;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Try OAuth refresh token
|
|
122
|
-
const clientId = process.env.GSC_CLIENT_ID;
|
|
123
|
-
const clientSecret = process.env.GSC_CLIENT_SECRET;
|
|
124
|
-
const refreshToken = process.env.GSC_REFRESH_TOKEN;
|
|
125
|
-
|
|
126
|
-
if (clientId && clientSecret && refreshToken) {
|
|
127
|
-
const token = await getOAuthToken(clientId, clientSecret, refreshToken);
|
|
128
|
-
cachedToken = { token, expiresAt: Date.now() + 3500_000 };
|
|
129
|
-
return token;
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
throw new Error(
|
|
133
|
-
"No GSC credentials configured. Set either:\n" +
|
|
134
|
-
" - GSC_SERVICE_ACCOUNT_KEY (path to service account JSON)\n" +
|
|
135
|
-
" - GSC_CLIENT_ID + GSC_CLIENT_SECRET + GSC_REFRESH_TOKEN (OAuth 2.0)",
|
|
136
|
-
);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function clearTokenCache(): void {
|
|
140
|
-
cachedToken = null;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
export function getAuthMethod(): string {
|
|
144
|
-
if (process.env.GSC_SERVICE_ACCOUNT_KEY) return "service_account";
|
|
145
|
-
if (process.env.GSC_REFRESH_TOKEN) return "oauth";
|
|
146
|
-
return "none";
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// --- OAuth Setup Helper ---
|
|
150
|
-
|
|
151
|
-
export function getOAuthSetupUrl(clientId: string): string {
|
|
152
|
-
const params = new URLSearchParams({
|
|
153
|
-
client_id: clientId,
|
|
154
|
-
redirect_uri: "http://localhost",
|
|
155
|
-
response_type: "code",
|
|
156
|
-
scope: SCOPES.join(" "),
|
|
157
|
-
access_type: "offline",
|
|
158
|
-
prompt: "consent",
|
|
159
|
-
});
|
|
160
|
-
return `https://accounts.google.com/o/oauth2/auth?${params}`;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
export async function exchangeCodeForToken(
|
|
164
|
-
clientId: string,
|
|
165
|
-
clientSecret: string,
|
|
166
|
-
code: string,
|
|
167
|
-
): Promise<{ refreshToken: string; accessToken: string }> {
|
|
168
|
-
const res = await fetch(TOKEN_URL, {
|
|
169
|
-
method: "POST",
|
|
170
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
171
|
-
body: new URLSearchParams({
|
|
172
|
-
grant_type: "authorization_code",
|
|
173
|
-
client_id: clientId,
|
|
174
|
-
client_secret: clientSecret,
|
|
175
|
-
code,
|
|
176
|
-
redirect_uri: "http://localhost",
|
|
177
|
-
}).toString(),
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
if (!res.ok) {
|
|
181
|
-
const err = await res.text();
|
|
182
|
-
throw new Error(`Code exchange failed: ${err}`);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const data = (await res.json()) as Record<string, string>;
|
|
186
|
-
return { refreshToken: data.refresh_token, accessToken: data.access_token };
|
|
187
|
-
}
|