Skip to content

Commit ad02462

Browse files
committed
Upgrade types and tests
1 parent 47ad4b6 commit ad02462

File tree

8 files changed

+649
-582
lines changed

8 files changed

+649
-582
lines changed

workers/controller/src/index.ts

Lines changed: 142 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -2,167 +2,172 @@ import Cloudflare from "cloudflare";
22
import { z } from "zod";
33

44
interface CaptureBody {
5-
url: string;
6-
tags: string[];
7-
zone: string;
5+
url: string;
6+
tags: string[];
7+
zone: string;
88
}
99

1010
const Purge = z.object({
11-
tags: z.array(z.string()),
11+
tags: z.array(z.string()),
1212
});
1313

1414
const Capture = z.object({
15-
url: z.string().url(),
16-
tags: z.array(z.string()),
15+
url: z.string().url(),
16+
tags: z.array(z.string()),
1717
});
1818

1919
function* chunks<T>(arr: T[], n: number) {
20-
for (let i = 0; i < arr.length; i += n) {
21-
yield arr.slice(i, i + n);
22-
}
20+
for (let i = 0; i < arr.length; i += n) {
21+
yield arr.slice(i, i + n);
22+
}
2323
}
2424

2525
interface PurgeBody {
26-
tag: string;
27-
zone?: string | undefined;
26+
tag: string;
27+
zone?: string;
2828
}
2929

3030
function apiToken(request: Request, env: Env): string {
31-
const auth = request.headers.get("Authorization");
32-
if (!auth) {
33-
throw new Error("Missing Authorization header");
34-
}
35-
const [scheme, token] = auth.split(" ");
36-
if (scheme !== "Bearer") {
37-
throw new Error("Authorization scheme is not Bearer");
38-
}
39-
40-
// Needs at least `Cache Purge:Purge, Zone:Read" permissions.
41-
if (token !== env.API_TOKEN) {
42-
throw new Error("Provided token does not match the `API_TOKEN` secret.");
43-
}
44-
45-
return token;
31+
const auth = request.headers.get("Authorization");
32+
if (!auth) {
33+
throw new Error("Missing Authorization header");
34+
}
35+
const [scheme, token] = auth.split(" ");
36+
if (scheme !== "Bearer") {
37+
throw new Error("Authorization scheme is not Bearer");
38+
}
39+
40+
// Needs at least `Cache Purge:Purge, Zone:Read" permissions.
41+
if (token !== env.API_TOKEN) {
42+
throw new Error("Provided token does not match the `API_TOKEN` secret.");
43+
}
44+
45+
return token;
4646
}
4747

4848
async function handlePurgeRequest(
49-
request: Request,
50-
env: Env,
49+
request: Request,
50+
env: Env,
5151
): Promise<Response> {
52-
let token: string;
53-
try {
54-
token = apiToken(request, env);
55-
} catch (e) {
56-
return Response.json(
57-
{
58-
error: String(e),
59-
},
60-
{ status: 401 },
61-
);
62-
}
63-
64-
const client = new Cloudflare({
65-
apiToken: token,
66-
});
67-
68-
const { status } = await client.user.tokens.verify();
69-
70-
if (status !== "active") {
71-
return Response.json(
72-
{
73-
error: "Authentication token is not active.",
74-
},
75-
{ status: 401 },
76-
);
77-
}
78-
79-
const { tags } = Purge.parse(await request.json());
80-
console.debug("[Cache Purge Request] Purge Tags", tags);
81-
82-
// If no zone is present, then all zones will be purged.
83-
const zone = request.headers.get("CF-Worker") ?? undefined;
84-
85-
const messages = tags.map<MessageSendRequest<PurgeBody>>((tag) => ({
86-
body: {
87-
tag,
88-
zone,
89-
},
90-
contentType: "json",
91-
}));
92-
93-
// sendBatch only allows for a maximum of 100 messages.
94-
const promises: ReturnType<typeof env.CACHE_PURGE_TAG.sendBatch>[] = [];
95-
for (const messageChunks of chunks(messages, 100)) {
96-
console.debug(
97-
"[Cache Purge Request] Send Batch",
98-
messageChunks.map(({ body }) => body),
99-
);
100-
promises.push(env.CACHE_PURGE_TAG.sendBatch(messageChunks));
101-
}
102-
103-
await Promise.all(promises);
104-
105-
return new Response("", { status: 202 });
52+
let token: string;
53+
try {
54+
token = apiToken(request, env);
55+
} catch (e) {
56+
return Response.json(
57+
{
58+
error: String(e),
59+
},
60+
{ status: 401 },
61+
);
62+
}
63+
64+
const client = new Cloudflare({
65+
apiToken: token,
66+
});
67+
68+
const { status } = await client.user.tokens.verify();
69+
70+
if (status !== "active") {
71+
return Response.json(
72+
{
73+
error: "Authentication token is not active.",
74+
},
75+
{ status: 401 },
76+
);
77+
}
78+
79+
const { tags } = Purge.parse(await request.json());
80+
console.debug("[Cache Purge Request] Purge Tags", tags);
81+
82+
// If no zone is present, then all zones will be purged.
83+
const zone = request.headers.get("CF-Worker") ?? undefined;
84+
85+
const messages = tags.map<MessageSendRequest<PurgeBody>>((tag) => {
86+
const body: PurgeBody = {
87+
tag,
88+
};
89+
if (zone) {
90+
body.zone = zone;
91+
}
92+
return {
93+
body,
94+
contentType: "json",
95+
};
96+
});
97+
98+
// sendBatch only allows for a maximum of 100 messages.
99+
const promises: ReturnType<typeof env.CACHE_PURGE_TAG.sendBatch>[] = [];
100+
for (const messageChunks of chunks(messages, 100)) {
101+
console.debug(
102+
"[Cache Purge Request] Send Batch",
103+
messageChunks.map(({ body }) => body),
104+
);
105+
promises.push(env.CACHE_PURGE_TAG.sendBatch(messageChunks));
106+
}
107+
108+
await Promise.all(promises);
109+
110+
return new Response("", { status: 202 });
106111
}
107112

108113
async function handleCaptureRequest(
109-
request: Request,
110-
env: Env,
114+
request: Request,
115+
env: Env,
111116
): Promise<Response> {
112-
// Since this worker can be called over the internet, we must at least verify that the token matches the secret,
113-
// but we don't need to verify that it's usable right now.
114-
try {
115-
apiToken(request, env);
116-
} catch (e) {
117-
return Response.json(
118-
{
119-
error: String(e),
120-
},
121-
{ status: 401 },
122-
);
123-
}
124-
125-
// If there is no zone on the request,
126-
// then we wont know how to purge the response later.
127-
const zone = request.headers.get("CF-Worker");
128-
if (!zone) {
129-
return Response.json(
130-
{
131-
error: "Missing CF-Worker Header",
132-
},
133-
{ status: 400 },
134-
);
135-
}
136-
137-
const { url, tags } = Capture.parse(await request.json());
138-
139-
const capture: CaptureBody = {
140-
url,
141-
zone,
142-
tags,
143-
};
144-
145-
await env.CACHE_CAPTURE.send(capture, { contentType: "json" });
146-
147-
return new Response("", { status: 202 });
117+
// Since this worker can be called over the internet, we must at least verify that the token matches the secret,
118+
// but we don't need to verify that it's usable right now.
119+
try {
120+
apiToken(request, env);
121+
} catch (e) {
122+
return Response.json(
123+
{
124+
error: String(e),
125+
},
126+
{ status: 401 },
127+
);
128+
}
129+
130+
// If there is no zone on the request,
131+
// then we wont know how to purge the response later.
132+
const zone = request.headers.get("CF-Worker");
133+
if (!zone) {
134+
return Response.json(
135+
{
136+
error: "Missing CF-Worker Header",
137+
},
138+
{ status: 400 },
139+
);
140+
}
141+
142+
const { url, tags } = Capture.parse(await request.json());
143+
144+
const capture: CaptureBody = {
145+
url,
146+
zone,
147+
tags,
148+
};
149+
150+
await env.CACHE_CAPTURE.send(capture, { contentType: "json" });
151+
152+
return new Response("", { status: 202 });
148153
}
149154

150155
export default {
151-
async fetch(request, env) {
152-
// Remove any tracking params to increase the cache hit rate.
153-
const url = new URL(request.url);
154-
155-
if (request.method !== "POST") {
156-
return new Response("", { status: 404 });
157-
}
158-
159-
switch (url.pathname) {
160-
case "/purge":
161-
return handlePurgeRequest(request, env);
162-
case "/capture":
163-
return handleCaptureRequest(request, env);
164-
default:
165-
return new Response("", { status: 404 });
166-
}
167-
},
156+
async fetch(request, env) {
157+
// Remove any tracking params to increase the cache hit rate.
158+
const url = new URL(request.url);
159+
160+
if (request.method !== "POST") {
161+
return new Response("", { status: 404 });
162+
}
163+
164+
switch (url.pathname) {
165+
case "/purge":
166+
return handlePurgeRequest(request, env);
167+
case "/capture":
168+
return handleCaptureRequest(request, env);
169+
default:
170+
return new Response("", { status: 404 });
171+
}
172+
},
168173
} satisfies ExportedHandler<Env>;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { SELF, env } from "cloudflare:test";
2+
import { it, expect, vi } from "vitest";
3+
4+
it("adds cache tags to the capture queue", async () => {
5+
const sendSpy = vi.spyOn(env.CACHE_CAPTURE, "send").mockResolvedValue();
6+
7+
const response = await SELF.fetch(
8+
"https://cache-tag.example.workers.dev/capture",
9+
{
10+
method: "POST",
11+
body: JSON.stringify({
12+
url: "https://example.com",
13+
tags: ["test"],
14+
}),
15+
headers: {
16+
Authorization: `Bearer ${env.API_TOKEN}`,
17+
"CF-Worker": "example.com",
18+
},
19+
},
20+
);
21+
22+
expect(response.status).toBe(202);
23+
24+
expect(sendSpy).toBeCalledWith(
25+
{
26+
url: "https://example.com",
27+
zone: "example.com",
28+
tags: ["test"],
29+
},
30+
{ contentType: "json" },
31+
);
32+
});
33+
34+
it("returns a 401 when the wrong API Token is provided", async () => {
35+
const response = await SELF.fetch(
36+
"https://cache-tag.example.workers.dev/capture",
37+
{
38+
method: "POST",
39+
body: JSON.stringify({
40+
url: "https://example.com",
41+
tags: ["test"],
42+
}),
43+
headers: {
44+
Authorization: `Bearer wrong`,
45+
"CF-Worker": "example.com",
46+
},
47+
},
48+
);
49+
50+
expect(response.status).toBe(401);
51+
});
52+
53+
it("returns a 400 when the CF-Worker header is missing", async () => {
54+
const response = await SELF.fetch(
55+
"https://cache-tag.example.workers.dev/capture",
56+
{
57+
method: "POST",
58+
body: JSON.stringify({
59+
url: "https://example.com",
60+
tags: ["test"],
61+
}),
62+
headers: {
63+
Authorization: `Bearer ${env.API_TOKEN}`,
64+
},
65+
},
66+
);
67+
68+
expect(response.status).toBe(400);
69+
});

0 commit comments

Comments
 (0)