Skip to content

Commit cdda6ee

Browse files
committed
Reduce API CPU time to fit Cloudflare Workers 10ms limit
- Cache mysql2 pool at module level instead of React cache() (which doesn't work in Pages Router), avoiding expensive pool re-creation per request - Replace Node.js crypto polyfills with native Web Crypto API (crypto.subtle for HMAC, crypto.getRandomValues for random bytes) - Batch POST /api/saves inserts with Promise.all instead of sequential awaits - Optimize getUID() to skip DB lookup for anonymous users (no token cookie) and return user object to avoid duplicate queries in /api/me - Remove unused @planetscale/database dependency https://claude.ai/code/session_01K4SCzhd53zgSyEEoNPF59B
1 parent 0c37627 commit cdda6ee

8 files changed

Lines changed: 125 additions & 74 deletions

File tree

bun.lock

Lines changed: 0 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
"@marsidev/react-turnstile": "^1.4.2",
2525
"@next/bundle-analyzer": "^16.1.6",
2626
"@opennextjs/cloudflare": "^1.17.1",
27-
"@planetscale/database": "^1.19.0",
2827
"@radix-ui/react-accordion": "^1.2.12",
2928
"@radix-ui/react-avatar": "^1.1.11",
3029
"@radix-ui/react-checkbox": "^1.3.3",

src/db/index.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import { getCloudflareContext } from "@opennextjs/cloudflare";
22
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
33
import { createPool } from "mysql2/promise";
4-
import { cache } from "react";
54
import * as schema from "./schema";
65

76
export type Db = MySql2Database<typeof schema>;
87

9-
export const getDb = cache(() => {
8+
let _db: Db | undefined;
9+
10+
export function getDb(): Db {
11+
if (_db) return _db;
12+
1013
const { env } = getCloudflareContext();
11-
return drizzle(
14+
_db = drizzle(
1215
createPool({
1316
host: env.HYPERDRIVE.host,
1417
user: env.HYPERDRIVE.user,
@@ -18,12 +21,10 @@ export const getDb = cache(() => {
1821
disableEval: true,
1922
connectionLimit: 1,
2023
}),
21-
{
22-
schema,
23-
mode: "default",
24-
},
24+
{ schema, mode: "default" },
2525
);
26-
});
26+
return _db;
27+
}
2728

2829
export async function withDb<T>(callback: (db: Db) => Promise<T>): Promise<T> {
2930
return callback(getDb());

src/pages/api/me.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,20 @@ import { getUID } from "./saves";
66

77
async function get(req: NextApiRequest, res: NextApiResponse) {
88
return withDb(async (db) => {
9-
const uid = await getUID(req, res, db);
9+
const { uid, user } = await getUID(req, res, db);
1010
if (!uid) return res.status(401).end();
1111

12-
const [user] = await db
12+
// If getUID already fetched the user (authenticated), reuse it
13+
if (user) return res.json(user);
14+
15+
// Otherwise look up by uid (anonymous user, may not exist)
16+
const [dbUser] = await db
1317
.select()
1418
.from(schema.users)
1519
.where(eq(schema.users.id, uid))
1620
.limit(1);
1721

18-
return res.json(user);
22+
return res.json(dbUser);
1923
});
2024
}
2125

src/pages/api/oauth/callback.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import * as schema from "$drizzle/schema";
22
import { withDb } from "@/db";
33
import { getRequestOrigin, getServerCookieDomain } from "@/lib/cookies";
44
import { getCookie, setCookie } from "cookies-next";
5-
import crypto from "crypto";
65
import { eq } from "drizzle-orm";
76
import type { NextApiRequest, NextApiResponse } from "next";
87
import { createToken } from "../saves";
@@ -110,8 +109,13 @@ export default async function handler(
110109
.where(eq(schema.users.id, uid))
111110
.limit(1);
112111

112+
const randomBytes = new Uint8Array(16);
113+
crypto.getRandomValues(randomBytes);
113114
let cookieSecret =
114-
user?.cookie_secret ?? crypto.randomBytes(16).toString("hex");
115+
user?.cookie_secret ??
116+
Array.from(randomBytes)
117+
.map((b) => b.toString(16).padStart(2, "0"))
118+
.join("");
115119

116120
if (!user) {
117121
let [discordUser] = await db
@@ -184,7 +188,7 @@ export default async function handler(
184188
maxAge: 60 * 60 * 24 * 365,
185189
});
186190

187-
const token = createToken(user.id, cookieSecret, 60 * 60 * 24 * 365);
191+
const token = await createToken(user.id, cookieSecret, 60 * 60 * 24 * 365);
188192
setCookie("token", token.token, {
189193
req,
190194
res,

src/pages/api/oauth/index.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { setCookie } from "cookies-next";
22
import { getRequestOrigin, getServerCookieDomain } from "@/lib/cookies";
3-
import crypto from "crypto";
43
import type { NextApiRequest, NextApiResponse } from "next";
54

65
type Data = Record<string, any>;
@@ -16,7 +15,11 @@ export default function handler(
1615
}
1716

1817
const redirectUri = `${origin}/api/oauth/callback`;
19-
const state = crypto.randomBytes(4).toString("hex");
18+
const bytes = new Uint8Array(4);
19+
crypto.getRandomValues(bytes);
20+
const state = Array.from(bytes)
21+
.map((b) => b.toString(16).padStart(2, "0"))
22+
.join("");
2023
setCookie("oauth_state", state, {
2124
req,
2225
res,

src/pages/api/saves/[playerId].ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async function patch(req: NextApiRequest, res: NextApiResponse) {
4242
const playerId = req.query.playerId as string | undefined;
4343
if (!playerId) return res.status(400).end();
4444

45-
const uid = await getUID(req, res, db);
45+
const { uid } = await getUID(req, res, db);
4646
const player = parseRequestBody<Player>(req.body);
4747
if (!player) return res.status(400).end();
4848

src/pages/api/saves/index.ts

Lines changed: 96 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { withDb } from "$db";
33
import * as schema from "$drizzle/schema";
44
import { getServerCookieDomain } from "@/lib/cookies";
55
import { getCookie, setCookie } from "cookies-next";
6-
import crypto from "crypto";
76
import { and, eq } from "drizzle-orm";
87
import { NextApiRequest, NextApiResponse } from "next";
98

@@ -49,80 +48,116 @@ export interface Player {
4948
animals?: object;
5049
}
5150

51+
function randomHex(byteCount: number): string {
52+
const bytes = new Uint8Array(byteCount);
53+
crypto.getRandomValues(bytes);
54+
return Array.from(bytes)
55+
.map((b) => b.toString(16).padStart(2, "0"))
56+
.join("");
57+
}
58+
59+
async function hmacSign(key: string, data: string): Promise<string> {
60+
const encoder = new TextEncoder();
61+
const cryptoKey = await crypto.subtle.importKey(
62+
"raw",
63+
encoder.encode(key),
64+
{ name: "HMAC", hash: "SHA-256" },
65+
false,
66+
["sign"],
67+
);
68+
const signature = await crypto.subtle.sign(
69+
"HMAC",
70+
cryptoKey,
71+
encoder.encode(data),
72+
);
73+
return Array.from(new Uint8Array(signature))
74+
.map((b) => b.toString(16).padStart(2, "0"))
75+
.join("");
76+
}
77+
78+
async function hmacVerify(
79+
key: string,
80+
data: string,
81+
signature: string,
82+
): Promise<boolean> {
83+
const expected = await hmacSign(key, data);
84+
return expected === signature;
85+
}
86+
87+
export interface AuthResult {
88+
uid: string;
89+
user?: SqlUser;
90+
}
91+
5292
export async function getUID(
5393
req: NextApiRequest,
5494
res: NextApiResponse<Data>,
5595
db: Db,
56-
): Promise<string> {
96+
): Promise<AuthResult> {
5797
let uid = getCookie("uid", { req, res });
5898
if (uid && typeof uid === "string") {
59-
// uids can be anonymous, so we need to check if the user exists
99+
// Check if user has a token cookie - if not, they're anonymous
100+
const token = getCookie("token", { req, res });
101+
if (!token) {
102+
return { uid };
103+
}
60104

105+
// User has a token, so verify it against the DB
61106
const [user] = await db
62107
.select()
63108
.from(schema.users)
64109
.where(eq(schema.users.id, uid))
65110
.limit(1);
66111

67112
if (user) {
68-
// user exists, so we check if the user is authenticated
69-
// verify that the user has a stored token
70-
let token = getCookie("token", { req, res });
71-
if (!token) {
72-
res.status(400);
73-
throw new Error("User is not authenticated (1)");
74-
}
75-
// verify that the token is valid
76-
const { valid, userId } = verifyToken(
113+
const { valid, userId } = await verifyToken(
77114
token as string,
78115
user.cookie_secret,
79116
);
80117
if (!valid || userId !== uid) {
81118
res.status(400);
82-
throw new Error(`User is not authenticated (valid token: ${valid})`);
119+
throw new Error(
120+
`User is not authenticated (valid token: ${valid})`,
121+
);
83122
}
123+
return { uid, user: user as SqlUser };
84124
}
85-
// everything is ok, so we return the uid
86-
return uid as string;
125+
126+
// uid cookie exists but user not in DB - treat as anonymous
127+
return { uid };
87128
} else {
88-
console.log("Generating new UID...");
89129
// no uid, so we create an anonymous one
90-
uid = crypto.randomBytes(16).toString("hex");
130+
uid = randomHex(16);
91131
setCookie("uid", uid, {
92132
req,
93133
res,
94134
maxAge: 60 * 60 * 24 * 365,
95135
domain: getServerCookieDomain(req),
96136
});
97137
}
98-
return uid;
138+
return { uid };
99139
}
100140

101-
// magic functions dreamt up by me, i think they're secure lol, i use them a lot - Leah
102-
export const createToken = (userId: string, key: string, validFor: number) => {
141+
export const createToken = async (
142+
userId: string,
143+
key: string,
144+
validFor: number,
145+
) => {
103146
const expires = Math.floor(new Date().getTime() / 1000 + validFor);
104-
const salt = crypto.randomBytes(8).toString("hex");
105-
const payload = Buffer.from(`${expires}.${userId}.${salt}`, "utf8").toString(
106-
"base64",
107-
);
108-
const signature = crypto
109-
.createHmac("sha256", key)
110-
.update(payload)
111-
.digest("hex");
147+
const salt = randomHex(8);
148+
const payload = btoa(`${expires}.${userId}.${salt}`);
149+
const signature = await hmacSign(key, payload);
112150
return { token: `${payload}.${signature}`, expires };
113151
};
114152

115-
export const verifyToken = (token: string, key: string) => {
153+
export const verifyToken = async (token: string, key: string) => {
116154
const [payload, signature] = token.split(".");
117-
const decoded = Buffer.from(payload, "base64").toString("utf8");
155+
const decoded = atob(payload);
118156
const [expires, userId] = decoded.split(".");
119-
const expectedSignature = crypto
120-
.createHmac("sha256", key)
121-
.update(payload)
122-
.digest("hex");
157+
const valid = await hmacVerify(key, payload, signature);
123158
return {
124159
valid:
125-
signature === expectedSignature &&
160+
valid &&
126161
parseInt(expires) > Math.floor(new Date().getTime() / 1000),
127162
userId,
128163
};
@@ -139,7 +174,7 @@ async function get(req: NextApiRequest, res: NextApiResponse) {
139174
"no-store, no-cache, must-revalidate, max-age=0",
140175
);
141176

142-
const uid = await getUID(req, res, db);
177+
const { uid } = await getUID(req, res, db);
143178
const players = await getPlayersByUid(db, uid);
144179

145180
res.json(players);
@@ -153,26 +188,29 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
153188
"no-store, no-cache, must-revalidate, max-age=0",
154189
);
155190

156-
const uid = await getUID(req, res, db);
191+
const { uid } = await getUID(req, res, db);
157192
const players = parseRequestBody<Player[]>(req.body);
158193

159194
try {
160-
for (const player of players) {
161-
if (!player._id) continue;
162-
163-
await db
164-
.insert(schema.saves)
165-
.values({
166-
_id: player._id,
167-
user_id: uid,
168-
...player,
169-
})
170-
.onDuplicateKeyUpdate({
171-
set: {
172-
user_id: uid,
173-
...player,
174-
},
175-
});
195+
const validPlayers = players.filter((p) => p._id);
196+
if (validPlayers.length > 0) {
197+
await Promise.all(
198+
validPlayers.map((player) =>
199+
db
200+
.insert(schema.saves)
201+
.values({
202+
_id: player._id!,
203+
user_id: uid,
204+
...player,
205+
})
206+
.onDuplicateKeyUpdate({
207+
set: {
208+
user_id: uid,
209+
...player,
210+
},
211+
}),
212+
),
213+
);
176214
}
177215

178216
const savedPlayers = await getPlayersByUid(db, uid);
@@ -186,7 +224,7 @@ async function post(req: NextApiRequest, res: NextApiResponse) {
186224

187225
async function _delete(req: NextApiRequest, res: NextApiResponse) {
188226
return withDb(async (db) => {
189-
const uid = await getUID(req, res, db);
227+
const { uid } = await getUID(req, res, db);
190228
const body = req.body
191229
? parseRequestBody<{ type?: string; _id?: string }>(req.body)
192230
: undefined;
@@ -201,7 +239,10 @@ async function _delete(req: NextApiRequest, res: NextApiResponse) {
201239
await db
202240
.delete(schema.saves)
203241
.where(
204-
and(eq(schema.saves.user_id, uid), eq(schema.saves._id, playerId)),
242+
and(
243+
eq(schema.saves.user_id, uid),
244+
eq(schema.saves._id, playerId),
245+
),
205246
);
206247
} else if (type === "account") {
207248
await db.delete(schema.saves).where(eq(schema.saves.user_id, uid));

0 commit comments

Comments
 (0)