Skip to content

Commit 727d211

Browse files
committed
fix: use native fetch in production, normalize error payloads, lazy-load worker routes
- http.js: use native fetch for non-test envs to avoid axios adapter issues; add normalizeJsonPayload helper for consistent response parsing - retryer.js: normalize non-GraphQL message payloads (e.g. Bad credentials) into GraphQL-like error shape in both success and catch paths - repo.js: add missing imports and handle GraphQL errors from retryer response - worker.js: lazy-load route handlers so process.env is populated before module evaluation; replace switch with route-map lookup; guard process.env from being undefined - tests/pat-info.test.js: preserve NODE_ENV when resetting process.env in beforeAll so http.js continues to use axios mock adapter in tests
1 parent 1c4a4b5 commit 727d211

5 files changed

Lines changed: 158 additions & 25 deletions

File tree

src/common/http.js

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,80 @@
22

33
import axios from "axios";
44

5+
/**
6+
* Normalize response data to JSON object when possible.
7+
*
8+
* @param {unknown} payload The payload to normalize.
9+
* @returns {any} Normalized payload.
10+
*/
11+
const normalizeJsonPayload = (payload) => {
12+
if (typeof payload !== "string") {
13+
return payload;
14+
}
15+
16+
try {
17+
return JSON.parse(payload);
18+
} catch {
19+
return payload;
20+
}
21+
};
22+
523
/**
624
* Send GraphQL request to GitHub API.
725
*
826
* @param {import('axios').AxiosRequestConfig['data']} data Request data.
927
* @param {import('axios').AxiosRequestConfig['headers']} headers Request headers.
1028
* @returns {Promise<any>} Request response.
1129
*/
12-
const request = (data, headers) => {
30+
const request = async (data, headers) => {
31+
const requestHeaders = {
32+
Accept: "application/json",
33+
"Content-Type": "application/json",
34+
"User-Agent": "github-readme-stats",
35+
...headers,
36+
};
37+
38+
// Keep axios path only for tests where axios-mock-adapter is used.
39+
// For runtime, use fetch to avoid adapter differences across environments.
40+
if (process.env.NODE_ENV !== "test") {
41+
const response = await fetch("https://api.github.com/graphql", {
42+
method: "POST",
43+
headers: requestHeaders,
44+
body: JSON.stringify(data),
45+
});
46+
47+
const rawBody = await response.text();
48+
const parsedBody = normalizeJsonPayload(rawBody);
49+
50+
/** @type {any} */
51+
const axiosLikeResponse = {
52+
data: parsedBody,
53+
status: response.status,
54+
statusText: response.statusText,
55+
headers: Object.fromEntries(response.headers.entries()),
56+
};
57+
58+
// Preserve axios-like rejection semantics for non-2xx status.
59+
if (!response.ok) {
60+
const err = new Error(
61+
`Request failed with status code ${response.status}`,
62+
);
63+
// @ts-ignore
64+
err.response = axiosLikeResponse;
65+
throw err;
66+
}
67+
68+
return axiosLikeResponse;
69+
}
70+
1371
return axios({
1472
url: "https://api.github.com/graphql",
1573
method: "post",
16-
headers,
17-
data,
74+
headers: requestHeaders,
75+
data: JSON.stringify(data),
76+
}).then((response) => {
77+
response.data = normalizeJsonPayload(response.data);
78+
return response;
1879
});
1980
};
2081

src/common/retryer.js

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,29 @@ const retryer = async (fetcher, variables, retries = 0) => {
4646
retries,
4747
);
4848

49+
// normalize non-GraphQL payloads into GraphQL-like errors
50+
// e.g. { message: "Bad credentials" } or API gateway messages.
51+
if (
52+
response?.data &&
53+
!response.data.errors &&
54+
!response.data.data &&
55+
typeof response.data.message === "string"
56+
) {
57+
response = {
58+
...response,
59+
data: {
60+
errors: [
61+
{
62+
type: String(
63+
response.statusText || response.status || "HTTP_ERROR",
64+
),
65+
message: response.data.message,
66+
},
67+
],
68+
},
69+
};
70+
}
71+
4972
// react on both type and message-based rate-limit signals.
5073
// https://github.com/anuraghazra/github-readme-stats/issues/4425
5174
const errors = response?.data?.errors;
@@ -88,7 +111,23 @@ const retryer = async (fetcher, variables, retries = 0) => {
88111
return retryer(fetcher, variables, retries);
89112
}
90113

91-
// HTTP error with a response → return it for caller-side handling
114+
// HTTP error with a response → normalize shape for caller-side handling
115+
if (typeof e?.response?.data?.message === "string") {
116+
return {
117+
...e.response,
118+
data: {
119+
errors: [
120+
{
121+
type: String(
122+
e?.response?.statusText || e?.response?.status || "HTTP_ERROR",
123+
),
124+
message: e.response.data.message,
125+
},
126+
],
127+
},
128+
};
129+
}
130+
92131
return e.response;
93132
}
94133
};

src/fetchers/repo.js

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
import { MissingParamError } from "../common/error.js";
44
import { request } from "../common/http.js";
55
import { retryer } from "../common/retryer.js";
6+
import { logger } from "../common/log.js";
7+
import { CustomError } from "../common/error.js";
8+
import { wrapTextMultiline } from "../common/fmt.js";
69

710
/**
811
* Repo data fetcher.
@@ -79,6 +82,20 @@ const fetchRepo = async (username, reponame) => {
7982

8083
let res = await retryer(fetcher, { login: username, repo: reponame });
8184

85+
if (res.data.errors) {
86+
logger.error(res.data.errors);
87+
if (res.data.errors[0].message) {
88+
throw new CustomError(
89+
wrapTextMultiline(res.data.errors[0].message, 90, 1)[0],
90+
res.statusText,
91+
);
92+
}
93+
throw new CustomError(
94+
"Something went wrong while trying to retrieve the repo data using the GraphQL API.",
95+
CustomError.GRAPHQL_ERROR,
96+
);
97+
}
98+
8299
const data = res.data.data;
83100

84101
if (!data.user && !data.organization) {

src/worker.js

Lines changed: 35 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,34 @@
11
// @ts-check
22
import { workerAdapter } from "./common/adapter.js";
3-
import api from "../api/index.js";
4-
import pin from "../api/pin.js";
5-
import topLangs from "../api/top-langs.js";
6-
import wakatime from "../api/wakatime.js";
7-
import gist from "../api/gist.js";
3+
4+
/** @type {Promise<Record<string, Function>> | null} */
5+
let routesPromise = null;
6+
7+
/**
8+
* Lazily loads route handlers after env variables are injected.
9+
*
10+
* @returns {Promise<Record<string, Function>>} Route handler map.
11+
*/
12+
const loadRoutes = async () => {
13+
if (!routesPromise) {
14+
routesPromise = Promise.all([
15+
import("../api/index.js"),
16+
import("../api/pin.js"),
17+
import("../api/top-langs.js"),
18+
import("../api/wakatime.js"),
19+
import("../api/gist.js"),
20+
]).then(([api, pin, topLangs, wakatime, gist]) => ({
21+
"/api": api.default,
22+
"/api/index": api.default,
23+
"/api/pin": pin.default,
24+
"/api/top-langs": topLangs.default,
25+
"/api/wakatime": wakatime.default,
26+
"/api/gist": gist.default,
27+
}));
28+
}
29+
30+
return routesPromise;
31+
};
832

933
export default {
1034
/**
@@ -19,25 +43,16 @@ export default {
1943
async fetch(request, env, ctx) {
2044
// Polyfill process.env for existing code accessing env vars
2145
globalThis.process = globalThis.process || {};
22-
globalThis.process.env = { ...globalThis.process.env, ...env };
46+
globalThis.process.env = { ...(globalThis.process.env || {}), ...env };
2347

2448
const url = new URL(request.url);
2549
const path = url.pathname;
50+
const routes = await loadRoutes();
2651

27-
switch (path) {
28-
case "/api":
29-
case "/api/index":
30-
return workerAdapter(request, api);
31-
case "/api/pin":
32-
return workerAdapter(request, pin);
33-
case "/api/top-langs":
34-
return workerAdapter(request, topLangs);
35-
case "/api/wakatime":
36-
return workerAdapter(request, wakatime);
37-
case "/api/gist":
38-
return workerAdapter(request, gist);
39-
default:
40-
return new Response("Not Found", { status: 404 });
52+
if (!routes[path]) {
53+
return new Response("Not Found", { status: 404 });
4154
}
55+
56+
return workerAdapter(request, routes[path]);
4257
},
4358
};

tests/pat-info.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,8 @@ afterEach(() => {
7373
describe("Test /api/status/pat-info", () => {
7474
beforeAll(() => {
7575
// reset patenv first so that dotenv doesn't populate them with local envs
76-
process.env = {};
76+
// preserve NODE_ENV so http.js continues to use axios (not fetch) in tests
77+
process.env = { NODE_ENV: "test" };
7778
process.env.PAT_1 = "testPAT1";
7879
process.env.PAT_2 = "testPAT2";
7980
process.env.PAT_3 = "testPAT3";

0 commit comments

Comments
 (0)