diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33c699c..ba81e1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: with: deno-version: v1.x - name: Run tests - run: deno test --allow-env --allow-read=. --allow-net=api.github.com + run: deno test --allow-env --allow-read=. --allow-net=api.github.com,0.0.0.0,localhost - name: Deploy to Deno Deploy uses: denoland/deployctl@v1 with: diff --git a/dprint.json b/dprint.json index 51a77d0..5473381 100644 --- a/dprint.json +++ b/dprint.json @@ -4,9 +4,9 @@ "**/*-lock.json" ], "plugins": [ - "https://plugins.dprint.dev/typescript-0.88.3.wasm", - "https://plugins.dprint.dev/json-0.19.0.wasm", - "https://plugins.dprint.dev/markdown-0.16.2.wasm", - "https://plugins.dprint.dev/prettier-0.27.0.json@3557a62b4507c55a47d8cde0683195b14d13c41dda66d0f0b0e111aed107e2fe" + "https://plugins.dprint.dev/typescript-0.88.7.wasm", + "https://plugins.dprint.dev/json-0.19.1.wasm", + "https://plugins.dprint.dev/markdown-0.16.3.wasm", + "https://plugins.dprint.dev/prettier-0.31.0.json@8808616145fecc35574b79e78620f7fb241afba445201e64042dc78e01a1022b" ] } diff --git a/handleRequest.test.ts b/handleRequest.test.ts index a115c0d..0f1d4e6 100644 --- a/handleRequest.test.ts +++ b/handleRequest.test.ts @@ -1,9 +1,20 @@ import { assertEquals } from "./deps.test.ts"; -import { handleRequest } from "./handleRequest.ts"; +import { createRequestHandler } from "./handleRequest.ts"; +import { RealClock } from "./utils/clock.ts"; + +const connInfo: Deno.ServeHandlerInfo = { + remoteAddr: { + transport: "tcp", + hostname: "127.0.0.1", + port: 80, + }, +}; Deno.test("should get info.json", async () => { + const { handleRequest } = createRequestHandler(new RealClock()); const response = await handleRequest( new Request("https://plugins.dprint.dev/info.json"), + connInfo, ); assertEquals(response.headers.get("content-type"), "application/json; charset=utf-8"); const data = await response.json(); @@ -15,8 +26,10 @@ Deno.test("should get info.json", async () => { }); Deno.test("should get cli.json", async () => { + const { handleRequest } = createRequestHandler(new RealClock()); const response = await handleRequest( new Request("https://plugins.dprint.dev/cli.json"), + connInfo, ); assertEquals(response.headers.get("content-type"), "application/json; charset=utf-8"); const data = await response.json(); @@ -24,8 +37,10 @@ Deno.test("should get cli.json", async () => { }); async function getRedirectUrl(url: string) { + const { handleRequest } = createRequestHandler(new RealClock()); const response = await handleRequest( new Request(url), + connInfo, ); assertEquals(response.status, 302); return response.headers.get("location")!; diff --git a/handleRequest.ts b/handleRequest.ts index b2ae5d3..0cf6232 100644 --- a/handleRequest.ts +++ b/handleRequest.ts @@ -2,7 +2,8 @@ import { renderHome } from "./home.tsx"; import oldMappings from "./old_redirects.json" assert { type: "json" }; import { tryResolveLatestJson, tryResolvePluginUrl, tryResolveSchemaUrl } from "./plugins.ts"; import { readInfoFile } from "./readInfoFile.ts"; -import { fetchCached, getCliInfo } from "./utils/mod.ts"; +import { Clock } from "./utils/clock.ts"; +import { createFetchCacher, getCliInfo } from "./utils/mod.ts"; const contentTypes = { css: "text/css; charset=utf-8", @@ -12,73 +13,112 @@ const contentTypes = { wasm: "application/wasm", }; -export async function handleRequest(request: Request) { - const url = new URL(request.url); - const newUrl = await resolvePluginOrSchemaUrl(url); - if (newUrl != null) { - const contentType = newUrl.endsWith(".json") - ? contentTypes.json - : newUrl.endsWith(".wasm") - ? contentTypes.wasm - : contentTypes.plain; - return handleConditionalRedirectRequest({ - request, - url: newUrl, - contentType, - }); - } - - const userLatestInfo = await tryResolveLatestJson(url); - if (userLatestInfo != null) { - if (userLatestInfo === 404) { - return new Response("Not Found", { - status: 404, - }); +export function createRequestHandler(clock: Clock) { + const { fetchCached } = createFetchCacher(clock); + return { + async handleRequest(request: Request, info: Deno.ServeHandlerInfo) { + const url = new URL(request.url); + const newUrl = await resolvePluginOrSchemaUrl(url); + if (newUrl != null) { + const contentType = newUrl.endsWith(".json") + ? contentTypes.json + : newUrl.endsWith(".wasm") + ? contentTypes.wasm + : contentTypes.plain; + return handleConditionalRedirectRequest({ + request, + url: newUrl, + contentType, + hostname: info.remoteAddr.hostname, + }); + } + + const userLatestInfo = await tryResolveLatestJson(url); + if (userLatestInfo != null) { + if (userLatestInfo === 404) { + return new Response("Not Found", { + status: 404, + }); + } else { + return createJsonResponse(JSON.stringify(userLatestInfo, undefined, 2), request); + } + } + + if (url.pathname.startsWith("/info.json")) { + const infoFileData = await readInfoFile(); + return createJsonResponse(JSON.stringify(infoFileData, null, 2), request); + } + + if (url.pathname.startsWith("/cli.json")) { + const cliInfo = await getCliInfo(); + return createJsonResponse(JSON.stringify(cliInfo, null, 2), request); + } + + if (url.pathname === "/style.css") { + return Deno.readTextFile("./style.css").then(text => + new Response(text, { + headers: { + "content-type": "text/css; charset=utf-8", + }, + status: 200, + }) + ); + } + + if (url.pathname === "/") { + return renderHome().then(home => + new Response(home, { + headers: { + "content-type": contentTypes.html, + }, + status: 200, + }) + ).catch(err => + new Response(`${err.toString?.() ?? err}`, { + headers: { + "content-type": contentTypes.plain, + }, + status: 500, + }) + ); + } + + return create404Response(); + }, + }; + + // This is done to allow the playground to access these files + function handleConditionalRedirectRequest(params: { + request: Request; + url: string; + contentType: string; + hostname: string; + }) { + if (shouldDirectlyServeFile(params.request)) { + return fetchCached(params) + .then(result => { + if (result.kind === "error") { + return result.response; + } + + return new Response(result.body, { + headers: { + "content-type": params.contentType, + // allow the playground to download this + "Access-Control-Allow-Origin": getAccessControlAllowOrigin(params.request), + }, + status: 200, + }); + }).catch(err => { + console.error(err); + return new Response("Internal Server Error", { + status: 500, + }); + }); } else { - return createJsonResponse(JSON.stringify(userLatestInfo, undefined, 2), request); + return createRedirectResponse(params.url); } } - - if (url.pathname.startsWith("/info.json")) { - const infoFileData = await readInfoFile(); - return createJsonResponse(JSON.stringify(infoFileData, null, 2), request); - } - - if (url.pathname.startsWith("/cli.json")) { - const cliInfo = await getCliInfo(); - return createJsonResponse(JSON.stringify(cliInfo, null, 2), request); - } - - if (url.pathname === "/style.css") { - return Deno.readTextFile("./style.css").then(text => - new Response(text, { - headers: { - "content-type": "text/css; charset=utf-8", - }, - status: 200, - }) - ); - } - - if (url.pathname === "/") { - return renderHome().then(home => - new Response(home, { - headers: { - "content-type": contentTypes.html, - }, - status: 200, - }) - ).catch(err => - new Response(`${err.toString?.() ?? err}`, { - headers: { - "content-type": contentTypes.plain, - }, - status: 500, - }) - ); - } - - return create404Response(); } async function resolvePluginOrSchemaUrl(url: URL) { @@ -87,41 +127,13 @@ async function resolvePluginOrSchemaUrl(url: URL) { ?? await tryResolveSchemaUrl(url); } -// This is done to allow the playground to access these files -function handleConditionalRedirectRequest(params: { request: Request; url: string; contentType: string }) { - if (shouldDirectlyServeFile(params.request)) { - return fetchCached(params.url) - .then(result => { - if (result.kind === "error") { - return result.response; - } - - return new Response(result.body, { - headers: { - "content-type": params.contentType, - // allow the playground to download this - "Access-Control-Allow-Origin": getAccessControlAllowOrigin(params.request), - }, - status: 200, - }); - }).catch(err => { - console.error(err); - return new Response("Internal Server Error", { - status: 500, - }); - }); - } else { - return createRedirectResponse(params.url); - } -} - function getAccessControlAllowOrigin(request: Request) { const origin = request.headers.get("origin"); return origin != null && new URL(origin).hostname === "localhost" ? origin : "https://dprint.dev"; } function shouldDirectlyServeFile(request: Request) { - // directly serve for when Deno makes a request + // directly serve for when Deno makes a request in order to fix the content type if (request.headers.get("user-agent")?.startsWith("Deno/")) { return true; } diff --git a/import_map.json b/import_map.json index 3ed7ba7..9b02f03 100644 --- a/import_map.json +++ b/import_map.json @@ -1,7 +1,7 @@ { "imports": { "preact": "https://esm.sh/preact@10.15.1?pin=v135", - "preact/": "https://esm.sh/preact@10.15.1?pin=v135/", + "preact/": "https://esm.sh/preact@10.15.1&pin=v135/", "preact-render-to-string": "https://esm.sh/*preact-render-to-string@6.1.0?pin=v135" } } diff --git a/main.ts b/main.ts index a7c9223..3b5b02f 100644 --- a/main.ts +++ b/main.ts @@ -1,3 +1,6 @@ -import { handleRequest } from "./handleRequest.ts"; +import { createRequestHandler } from "./handleRequest.ts"; +import { RealClock } from "./utils/clock.ts"; -Deno.serve((request) => handleRequest(request)); +const { handleRequest } = createRequestHandler(new RealClock()); + +Deno.serve((request, info) => handleRequest(request, info)); diff --git a/utils/LruCache.ts b/utils/LruCache.ts index 4a07e3d..aeaa3fa 100644 --- a/utils/LruCache.ts +++ b/utils/LruCache.ts @@ -1,53 +1,107 @@ +export class LruCacheSet { + #inner: LruCache; + + constructor(options: { size: number }) { + this.#inner = new LruCache({ size: options.size }); + } + + has(key: TKey) { + return this.#inner.get(key) != null; + } + + insert(key: TKey) { + this.#inner.set(key, true); + } +} + +interface LruCacheNode { + key: TKey; + value: TValue; + prev?: LruCacheNode; + next?: LruCacheNode; +} + export class LruCache { - #size: number; - #map = new Map(); - #recent: TKey[] = []; + readonly #size: number; + #map = new Map>(); + #head: LruCacheNode | undefined; + #tail: LruCacheNode | undefined; constructor(options: { size: number }) { this.#size = options.size; } get(key: TKey) { - if (!this.#map.has(key)) { - return undefined; + const node = this.#map.get(key); + if (node) { + this.#moveToFront(node); + return node.value; } - this.#setMostRecentForKey(key); - return this.#map.get(key); - } - - #setMostRecentForKey(key: TKey) { - this.#removeFromRecent(key); - this.#recent.push(key); + return undefined; } set(key: TKey, value: TValue) { - if (this.#map.has(key)) { - this.#setMostRecentForKey(key); + let node = this.#map.get(key); + + if (node) { + node.value = value; + this.#moveToFront(node); } else { - this.#recent.push(key); - if (this.#recent.length > this.#size) { - this.#map.delete(this.#recent[0]); - this.#recent.splice(0, 1); + node = { key, value }; + if (this.#map.size === this.#size) { + this.#removeLeastRecentlyUsed(); } + this.#map.set(key, node); + this.#addToFront(node); } - - this.#map.set(key, value); } remove(key: TKey) { - if (this.#map.delete(key)) { - this.#removeFromRecent(key); + const node = this.#map.get(key); + if (node) { + this.#removeNode(node); + this.#map.delete(key); } } - #removeFromRecent(key: TKey) { - for (let i = this.#recent.length - 1; i >= 0; i--) { - if (this.#recent[i] === key) { - this.#recent.splice(i, 1); - break; - } + #removeNode(node: LruCacheNode) { + if (node.prev) { + node.prev.next = node.next; + } else { + this.#head = node.next; + } + + if (node.next) { + node.next.prev = node.prev; + } else { + this.#tail = node.prev; } } + + #removeLeastRecentlyUsed(): void { + if (!this.#tail) return; + + this.#map.delete(this.#tail.key); + this.#removeNode(this.#tail); + } + + #addToFront(node: LruCacheNode): void { + if (!this.#head) { + this.#head = node; + this.#tail = node; + return; + } + node.next = this.#head; + this.#head.prev = node; + this.#head = node; + } + + #moveToFront(node: LruCacheNode): void { + if (node === this.#head) return; + + this.#removeNode(node); + this.#addToFront(node); + } } interface ExpirableItem { diff --git a/utils/RateLimiter.test.ts b/utils/RateLimiter.test.ts new file mode 100644 index 0000000..5cdd545 --- /dev/null +++ b/utils/RateLimiter.test.ts @@ -0,0 +1,30 @@ +import { assert } from "../deps.test.ts"; +import { Clock } from "./clock.ts"; +import { RateLimiter } from "./RateLimiter.ts"; + +Deno.test("rate limits", () => { + let time = 0; + const clock: Clock = { + getTime() { + return time; + }, + }; + const rateLimiter = new RateLimiter(clock, { + limit: 2, + timeWindowMs: 1000, + }); + + assert(rateLimiter.isAllowed("127.0.0.1")); + time += 100; + assert(rateLimiter.isAllowed("127.0.0.1")); + assert(!rateLimiter.isAllowed("127.0.0.1")); + time += 500; + assert(!rateLimiter.isAllowed("127.0.0.1")); + time += 500; + assert(rateLimiter.isAllowed("127.0.0.1")); + assert(!rateLimiter.isAllowed("127.0.0.1")); + time += 500; + assert(rateLimiter.isAllowed("127.0.0.1")); + assert(!rateLimiter.isAllowed("127.0.0.1")); + assert(rateLimiter.isAllowed("127.0.0.2")); +}); diff --git a/utils/RateLimiter.ts b/utils/RateLimiter.ts new file mode 100644 index 0000000..38a8e29 --- /dev/null +++ b/utils/RateLimiter.ts @@ -0,0 +1,44 @@ +import { Clock } from "./clock.ts"; +import { LruCache } from "./LruCache.ts"; + +export interface RateLimiterOptions { + limit: number; + timeWindowMs: number; +} + +export class RateLimiter { + #clock: Clock; + #timestampsByHostname = new LruCache({ + size: 100_000, + }); + + #limit: number; + #timeWindowMs: number; + + constructor(clock: Clock, options: RateLimiterOptions) { + this.#clock = clock; + this.#limit = options.limit; + this.#timeWindowMs = options.timeWindowMs; + } + + isAllowed(hostname: string) { + const currentTime = this.#clock.getTime(); + let timestamps = this.#timestampsByHostname.get(hostname); + if (timestamps == null) { + timestamps = []; + this.#timestampsByHostname.set(hostname, timestamps); + } else { + // remove the first timestamp if it's outside the time window + if (timestamps.length > 0 && currentTime - timestamps[0] > this.#timeWindowMs) { + timestamps.shift(); + } + } + + if (timestamps.length < this.#limit) { + timestamps.push(currentTime); + return true; + } else { + return false; + } + } +} diff --git a/utils/clock.ts b/utils/clock.ts new file mode 100644 index 0000000..eb40448 --- /dev/null +++ b/utils/clock.ts @@ -0,0 +1,9 @@ +export interface Clock { + getTime(): number; +} + +export class RealClock implements Clock { + getTime() { + return Date.now(); + } +} diff --git a/utils/fetchCached.test.ts b/utils/fetchCached.test.ts new file mode 100644 index 0000000..e90cc70 --- /dev/null +++ b/utils/fetchCached.test.ts @@ -0,0 +1,85 @@ +import { assertEquals } from "../deps.test.ts"; +import { createFetchCacher } from "./fetchCached.ts"; + +Deno.test("should error when going above 10mb", async (t) => { + let time = 0; + const clock = { + getTime() { + return time; + }, + }; + const { fetchCached } = createFetchCacher(clock); + await using _server = Deno.serve({ port: 8040 }, (request) => { + if (request.url.endsWith("large")) { + return new Response(new Uint8Array(11 * 1024 * 1024).buffer, { + status: 200, + }); + } else if (request.url.endsWith("small")) { + return new Response(new Uint8Array(9 * 1024 * 1024).buffer, { + status: 200, + }); + } else { + return new Response("Not found", { + status: 404, + }); + } + }); + + // large + await t.step("should error going above 10mb", async () => { + const response = await fetchCached({ + url: `http://localhost:8040/large`, + hostname: "127.0.0.1", + }); + if (response.kind !== "error") { + throw new Error("Expected error."); + } + assertEquals(response.response.status, 413); + }); + + // small + await t.step("should not error below 10mb", async () => { + const response = await fetchCached({ + url: `http://localhost:8040/small`, + hostname: "127.0.0.1", + }); + if (response.kind !== "success") { + throw new Error("Expected error."); + } + assertEquals(response.body.byteLength, 9 * 1024 * 1024); + + const response2 = await fetchCached({ + url: `http://localhost:8040/small`, + hostname: "127.0.0.1", + }); + if (response.body !== response2.body) { + throw new Error("Should have been the same objects."); + } + }); + + await t.step("should error after 20 downloads because of rate limiting", async () => { + for (let i = 0; i < 19; i++) { + const response = await fetchCached({ + url: `http://localhost:8040/small`, + hostname: "127.0.0.1", + }); + assertEquals(response.kind, "success"); + } + + let response = await fetchCached({ + url: `http://localhost:8040/small`, + hostname: "127.0.0.1", + }); + if (response.kind !== "error") { + throw new Error("Was not error."); + } + assertEquals(response.response.status, 429); + // advance time and it should work again + time += 61 * 1000; + response = await fetchCached({ + url: `http://localhost:8040/small`, + hostname: "127.0.0.1", + }); + assertEquals(response.kind, "success"); + }); +}); diff --git a/utils/fetchCached.ts b/utils/fetchCached.ts index f6fb7bf..d4769d6 100644 --- a/utils/fetchCached.ts +++ b/utils/fetchCached.ts @@ -1,24 +1,87 @@ -import { LruCache } from "./LruCache.ts"; +import { Clock } from "./clock.ts"; +import { LruCache, LruCacheSet } from "./LruCache.ts"; +import { RateLimiter } from "./RateLimiter.ts"; -const cache = new LruCache({ size: 50 }); +const tooLargeResponse = { + kind: "error" as const, + response: new Response("Response body exceeds 10MB limit", { + status: 413, + }), +}; +const tooManyRequestsResponse = { + kind: "error" as const, + response: new Response("Too many requests", { + status: 429, + }), +}; -export async function fetchCached(url: string) { - let cachedBody = cache.get(url); - if (cachedBody == null) { - const response = await fetch(url); - if (!response.ok) { - return { - kind: "error", - response, - } as const; - } +export function createFetchCacher(clock: Clock) { + const directDownloadRateLimiter = new RateLimiter(clock, { + limit: 10, + timeWindowMs: 5 * 60 * 1_000, + }); + const cachedRateLimiter = new RateLimiter(clock, { + limit: 20, + timeWindowMs: 60 * 1_000, + }); + const cache = new LruCache({ size: 50 }); + const tooLargeCache = new LruCacheSet({ size: 1000 }); - const body = await response.arrayBuffer(); - cachedBody = body; - cache.set(url, cachedBody); - } return { - kind: "success", - body: cachedBody, - } as const; + async fetchCached({ url, hostname }: { url: string; hostname: string }) { + let cachedBody = cache.get(url); + if (cachedBody == null) { + if (!directDownloadRateLimiter.isAllowed(hostname)) { + return tooManyRequestsResponse; + } + + const response = await fetch(url); + if (!response.ok) { + return { + kind: "error", + response, + } as const; + } + + const reader = response.body!.getReader(); + + let receivedLength = 0; // received that many bytes at the moment + let chunks = []; // array of received binary chunks (comprises the body) + while (true) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + chunks.push(value); + receivedLength += value.length; + + // Check if the received length is greater than 10MB + if (receivedLength > 10 * 1024 * 1024) { + reader.cancel(); // stops the reading process + tooLargeCache.insert(url); + return tooLargeResponse; + } + } + + // Concatenate chunks into single Uint8Array + let chunksAll = new Uint8Array(receivedLength); + let position = 0; + for (let chunk of chunks) { + chunksAll.set(chunk, position); + position += chunk.length; + } + + cachedBody = chunksAll.buffer; + cache.set(url, cachedBody); + } else if (!cachedRateLimiter.isAllowed(hostname)) { + return tooManyRequestsResponse; + } + return { + kind: "success", + body: cachedBody, + } as const; + }, + }; }