Skip to content

Commit e799ada

Browse files
committed
✨ First version.
1 parent 3928c7e commit e799ada

File tree

5 files changed

+619
-0
lines changed

5 files changed

+619
-0
lines changed

lib/handler.ts

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
import { sha1, sha256, sha384, sha512 } from 'crypto-hash';
2+
import { BadRequest, Forbidden } from 'http-errors';
3+
import andThen from 'ramda/src/andThen';
4+
import converge from 'ramda/src/converge';
5+
import partialRight from 'ramda/src/partialRight';
6+
import pipeWith from 'ramda/src/pipeWith';
7+
import status from 'statuses';
8+
import {
9+
fetchProgress,
10+
GetLeetcodeProgressOptions,
11+
LeetcodeProgress,
12+
ProgressType,
13+
renderProgress,
14+
} from './leetcode';
15+
16+
declare global {
17+
interface CacheStorage {
18+
default: Cache;
19+
}
20+
}
21+
22+
const HASH_ALGORITHMS = { sha1, sha256, sha384, sha512 };
23+
24+
export type RequestHandler = (event: FetchEvent) => Promise<Response | void>;
25+
26+
export type HashAlgorithm = keyof typeof HASH_ALGORITHMS;
27+
28+
export interface NormalizeURLOptions {
29+
progressType?: ProgressType;
30+
}
31+
32+
export interface ProcessHeadersOptions {
33+
cors?: boolean;
34+
cacheTTL?: number;
35+
}
36+
37+
export interface ComputeEtagOptions {
38+
hashAlgorithm?: HashAlgorithm;
39+
}
40+
41+
export interface PrepareOptions extends NormalizeURLOptions {
42+
userlist?: Set<string>;
43+
}
44+
45+
export interface HandleGetOptions
46+
extends GetLeetcodeProgressOptions,
47+
NormalizeURLOptions,
48+
ProcessHeadersOptions,
49+
ComputeEtagOptions,
50+
PrepareOptions {
51+
userlist?: Set<string>;
52+
cacheName?: string;
53+
}
54+
55+
export interface HandlerContext {
56+
url: string;
57+
username: string;
58+
progressType: ProgressType;
59+
}
60+
61+
export type CacheMatch = (
62+
url: string,
63+
headers: Headers,
64+
) => Promise<Response | undefined>;
65+
export type CachePut = (response: Response) => Promise<void>;
66+
67+
/**
68+
* @todo Hotfix the typing of R.pipeWith which currently
69+
* can only accept unary functions.
70+
*
71+
* Affected keywords:
72+
* - ProcessResponse
73+
* - UnaryFetchProgress
74+
* - processResponse
75+
*/
76+
export type ProcessResponse = (
77+
username: string,
78+
progressType: ProgressType,
79+
) => Promise<Response>;
80+
81+
export type UnaryFetchProgress = (
82+
username: string,
83+
) => Promise<LeetcodeProgress>;
84+
85+
/**
86+
* Leetcode: The username must contain only letters, numbers, hyphens
87+
* and underscores.
88+
*/
89+
export function validateUsername(username: unknown): username is string {
90+
return typeof username === 'string' && /[a-zA-Z-_]+/.test(username);
91+
}
92+
93+
export function validateProgressType(
94+
progressType: unknown,
95+
): progressType is ProgressType {
96+
return progressType === 'global' || progressType === 'session';
97+
}
98+
99+
export function normalizeURL(
100+
url: string,
101+
{ progressType = 'global' }: NormalizeURLOptions = {},
102+
): URL {
103+
const urlObj = new URL(url);
104+
urlObj.hash = '';
105+
if (!urlObj.searchParams.has('progress-type')) {
106+
urlObj.searchParams.set('progress-type', progressType);
107+
}
108+
return urlObj;
109+
}
110+
111+
export function prepare(
112+
request: Request,
113+
{ progressType, userlist }: PrepareOptions = {},
114+
): HandlerContext {
115+
const url = normalizeURL(request.url, { progressType });
116+
const username = url.searchParams.get('username');
117+
const progType = url.searchParams.get('progress-type');
118+
if (!validateUsername(username) || !validateProgressType(progType)) {
119+
throw new BadRequest();
120+
}
121+
if (userlist && !userlist.has(username)) {
122+
throw new Forbidden();
123+
}
124+
return { url: url.toString(), username, progressType: progType };
125+
}
126+
127+
export function useCache(
128+
cache: Cache | Promise<Cache>,
129+
): [CacheMatch, CachePut] {
130+
let request: Request;
131+
async function match(url: string, headers: Headers) {
132+
const _cache = await cache;
133+
request = new Request(url, { headers });
134+
const cacheResponse = await _cache.match(request);
135+
if (cacheResponse) {
136+
return cacheResponse;
137+
}
138+
}
139+
async function put(response: Response) {
140+
const _cache = await cache;
141+
await _cache.put(request, response);
142+
}
143+
return [match, put];
144+
}
145+
146+
export function createResponse(content: string): Response {
147+
return new Response(content, {
148+
status: 200,
149+
statusText: status(200) as string,
150+
headers: {
151+
'Content-Length': String(content.length),
152+
},
153+
});
154+
}
155+
156+
export async function computeEtag(
157+
content: string,
158+
{ hashAlgorithm = 'sha1' }: { hashAlgorithm?: HashAlgorithm } = {},
159+
): Promise<string> {
160+
return hashAlgorithm
161+
? await HASH_ALGORITHMS[hashAlgorithm](content, { outputFormat: 'hex' })
162+
: '';
163+
}
164+
165+
export async function processEtagHeader(
166+
response: Response,
167+
etagPromise: Promise<string>,
168+
): Promise<Response> {
169+
const etag = await etagPromise;
170+
if (etag) {
171+
response.headers.append('ETag', etag);
172+
}
173+
return response;
174+
}
175+
176+
export function processHeaders(
177+
response: Response,
178+
{ cors, cacheTTL = 300000 }: ProcessHeadersOptions = {},
179+
): Response {
180+
const maxage = Math.round(cacheTTL / 1000);
181+
const now = new Date();
182+
const expires = new Date(now.getTime() + cacheTTL);
183+
184+
response.headers.append('Content-Type', 'image/svg+xml');
185+
response.headers.append('Cache-Control', `public, max-age=${maxage}`);
186+
response.headers.append('Last-Modified', now.toUTCString());
187+
response.headers.append('Expires', expires.toUTCString());
188+
189+
if (cors) {
190+
response.headers.append('Access-Control-Allow-Origin', '*');
191+
}
192+
193+
return response;
194+
}
195+
196+
export function createGetHandler({
197+
cacheName,
198+
cacheTTL,
199+
cors,
200+
hashAlgorithm,
201+
leetcodeGraphqlUrl,
202+
progressType,
203+
userlist,
204+
fetch,
205+
}: HandleGetOptions = {}): RequestHandler {
206+
const boundPrepare = partialRight(prepare, [{ userlist, progressType }]);
207+
const processResponse = pipeWith(andThen)([
208+
partialRight(fetchProgress, [
209+
{ leetcodeGraphqlUrl, fetch },
210+
]) as UnaryFetchProgress,
211+
renderProgress,
212+
converge(processEtagHeader, [
213+
createResponse,
214+
partialRight(computeEtag, [{ hashAlgorithm }]),
215+
]),
216+
partialRight(processHeaders, [{ cors, cacheTTL }]),
217+
]) as unknown as ProcessResponse;
218+
219+
return async function handleGet(event: FetchEvent): Promise<Response> {
220+
const cache =
221+
cacheName === 'default'
222+
? caches.default
223+
: cacheName
224+
? caches.open(cacheName)
225+
: undefined;
226+
227+
const [matchCache, putCache] = cache ? useCache(cache) : [];
228+
const { url, username, progressType } = boundPrepare(event.request);
229+
let response = await matchCache?.(url, event.request.headers);
230+
if (response) return response;
231+
response = await processResponse(username, progressType);
232+
if (putCache) event.waitUntil(putCache(response.clone()));
233+
return response;
234+
};
235+
}

lib/index.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { isHttpError } from 'http-errors';
2+
import status from 'statuses';
3+
import { createGetHandler, HandleGetOptions, RequestHandler } from './handler';
4+
5+
export function handleOptions(event: FetchEvent): Response {
6+
const headers = event.request.headers;
7+
if (
8+
headers.has('Origin') &&
9+
headers.has('Access-Control-Request-Method') &&
10+
headers.has('Access-Control-Request-Headers') !== null
11+
) {
12+
return new Response(null, {
13+
headers: {
14+
'Access-Control-Allow-Origin': '*',
15+
'Access-Control-Allow-Methods': 'GET,HEAD,OPTIONS',
16+
'Access-Control-Max-Age': '86400',
17+
'Access-Control-Allow-Headers': headers.get(
18+
'Access-Control-Request-Headers',
19+
)!,
20+
},
21+
});
22+
} else {
23+
return new Response(null, {
24+
headers: {
25+
Allow: 'GET, HEAD, OPTIONS',
26+
},
27+
});
28+
}
29+
}
30+
31+
export function handleClientError(err: Error): Response {
32+
if (isHttpError(err) && err.expose) {
33+
return new Response(err.message, {
34+
status: err.status,
35+
statusText: status(err.status) as string,
36+
headers: err.headers,
37+
});
38+
}
39+
throw err;
40+
}
41+
42+
/**
43+
* @example
44+
* const handler = createHandler()
45+
*
46+
* addEventListener('fetch', (event) => {
47+
* event.respondWith(handler(event) ?? new Response('', { status: 405 }));
48+
* });
49+
*/
50+
export default function createHandler(
51+
options: HandleGetOptions = {},
52+
): RequestHandler {
53+
const handleGet = createGetHandler(options);
54+
return async function (event: FetchEvent): Promise<Response | void> {
55+
try {
56+
if (options.cors && event.request.method === 'OPTIONS') {
57+
return handleOptions(event);
58+
} else if (event.request.method === 'GET') {
59+
return await handleGet(event);
60+
}
61+
} catch (err) {
62+
return handleClientError(err);
63+
}
64+
};
65+
}

0 commit comments

Comments
 (0)