From 4ae0fbfec6e36a0a0f32014b3ea54389c3a82bf1 Mon Sep 17 00:00:00 2001 From: tiago <70700766+tiagozip@users.noreply.github.com> Date: Mon, 22 Sep 2025 19:25:03 +0100 Subject: [PATCH] server(4.0.1): storage hooks: move from listExpired to deleteExpired this should result in much faster cleanup --- demo/{storage-hooks => basic-store}/bun.lock | 0 .../{storage-hooks => basic-store}/index.html | 0 demo/basic-store/index.js | 52 ++++++++ .../package.json | 0 demo/main/index.js | 108 +++++++++++---- demo/storage-hooks/index.js | 124 ------------------ docs/guide/index.md | 4 +- docs/guide/server.md | 20 +-- server/index.d.ts | 8 +- server/index.js | 29 ++-- server/package.json | 4 +- 11 files changed, 159 insertions(+), 190 deletions(-) rename demo/{storage-hooks => basic-store}/bun.lock (100%) rename demo/{storage-hooks => basic-store}/index.html (100%) create mode 100644 demo/basic-store/index.js rename demo/{storage-hooks => basic-store}/package.json (100%) delete mode 100644 demo/storage-hooks/index.js diff --git a/demo/storage-hooks/bun.lock b/demo/basic-store/bun.lock similarity index 100% rename from demo/storage-hooks/bun.lock rename to demo/basic-store/bun.lock diff --git a/demo/storage-hooks/index.html b/demo/basic-store/index.html similarity index 100% rename from demo/storage-hooks/index.html rename to demo/basic-store/index.html diff --git a/demo/basic-store/index.js b/demo/basic-store/index.js new file mode 100644 index 0000000..ab8f735 --- /dev/null +++ b/demo/basic-store/index.js @@ -0,0 +1,52 @@ +import fs from "node:fs/promises"; +import { Elysia, file } from "elysia"; +import Cap from "../../server/index.js"; + +const cap = new Cap({ + tokens_store_path: ".data/tokensList.json", +}); + +const app = new Elysia(); + +app.get("/", () => file("./index.html")); + +app.get("/cap.js", async ({ set }) => { + // in the newest version, the worker is injected into the main file + // by a build script. since we don't have a build script here, + // we'll need to run a minimal build ourselves. + + const main = await fs.readFile("../../widget/src/src/cap.js", "utf-8"); + const worker = await fs.readFile("../../widget/src/src/worker.js", "utf-8"); + + const bundle = main.replace("%%workerScript%%", worker); + + set.headers = { + "Content-Type": "application/javascript", + }; + + return bundle; +}); + +app.get("/cap-floating.js", () => file("../../widget/src/src/cap-floating.js")); + +app.post("/api/challenge", () => cap.createChallenge()); + +app.post("/api/redeem", async ({ body, set }) => { + const { token, solutions } = body; + if (!token || !solutions) { + set.status = 400; + return { success: false }; + } + + const answer = await cap.redeemChallenge({ token, solutions }); + + console.log("new challenge redeemed", { + ...answer, + isValid: (await cap.validateToken(answer.token)).success, + }); + + return answer; +}); + +app.listen(3000); +console.log("Server is running on http://localhost:3000"); diff --git a/demo/storage-hooks/package.json b/demo/basic-store/package.json similarity index 100% rename from demo/storage-hooks/package.json rename to demo/basic-store/package.json diff --git a/demo/main/index.js b/demo/main/index.js index ab8f735..7522aa9 100644 --- a/demo/main/index.js +++ b/demo/main/index.js @@ -1,9 +1,73 @@ +import { Database } from "bun:sqlite"; import fs from "node:fs/promises"; import { Elysia, file } from "elysia"; import Cap from "../../server/index.js"; +const db = new Database("./.data/cap.db"); + +db.exec(` + CREATE TABLE IF NOT EXISTS challenges ( + token TEXT PRIMARY KEY, + data TEXT NOT NULL, + expires INTEGER NOT NULL + ); + CREATE TABLE IF NOT EXISTS tokens ( + key TEXT PRIMARY KEY, + expires INTEGER NOT NULL + ); +`); + const cap = new Cap({ - tokens_store_path: ".data/tokensList.json", + storage: { + challenges: { + store: async (token, challengeData) => { + db.prepare( + "INSERT OR REPLACE INTO challenges (token, data, expires) VALUES (?, ?, ?)", + ).run( + token, + JSON.stringify(challengeData.challenge), + challengeData.expires, + ); + }, + read: async (token) => { + const row = db + .prepare( + "SELECT data, expires FROM challenges WHERE token = ? AND expires > ?", + ) + .get(token, Date.now()); + + return row + ? { challenge: JSON.parse(row.data), expires: row.expires } + : null; + }, + delete: async (token) => { + db.prepare("DELETE FROM challenges WHERE token = ?").run(token); + }, + deleteExpired: async () => { + db.prepare("DELETE FROM challenges WHERE expires <= ?").run(Date.now()); + }, + }, + tokens: { + store: async (tokenKey, expires) => { + db.prepare( + "INSERT OR REPLACE INTO tokens (key, expires) VALUES (?, ?)", + ).run(tokenKey, expires); + }, + get: async (tokenKey) => { + const row = db + .prepare("SELECT expires FROM tokens WHERE key = ? AND expires > ?") + .get(tokenKey, Date.now()); + + return row ? row.expires : null; + }, + delete: async (tokenKey) => { + db.prepare("DELETE FROM tokens WHERE key = ?").run(tokenKey); + }, + deleteExpired: async () => { + db.prepare("DELETE FROM tokens WHERE expires <= ?").run(Date.now()); + }, + }, + }, }); const app = new Elysia(); @@ -11,20 +75,20 @@ const app = new Elysia(); app.get("/", () => file("./index.html")); app.get("/cap.js", async ({ set }) => { - // in the newest version, the worker is injected into the main file - // by a build script. since we don't have a build script here, - // we'll need to run a minimal build ourselves. + // in the newest version, the worker is injected into the main file + // by a build script. since we don't have a build script here, + // we'll need to run a minimal build ourselves. - const main = await fs.readFile("../../widget/src/src/cap.js", "utf-8"); - const worker = await fs.readFile("../../widget/src/src/worker.js", "utf-8"); + const main = await fs.readFile("../../widget/src/src/cap.js", "utf-8"); + const worker = await fs.readFile("../../widget/src/src/worker.js", "utf-8"); - const bundle = main.replace("%%workerScript%%", worker); + const bundle = main.replace("%%workerScript%%", worker); - set.headers = { - "Content-Type": "application/javascript", - }; + set.headers = { + "Content-Type": "application/javascript", + }; - return bundle; + return bundle; }); app.get("/cap-floating.js", () => file("../../widget/src/src/cap-floating.js")); @@ -32,20 +96,20 @@ app.get("/cap-floating.js", () => file("../../widget/src/src/cap-floating.js")); app.post("/api/challenge", () => cap.createChallenge()); app.post("/api/redeem", async ({ body, set }) => { - const { token, solutions } = body; - if (!token || !solutions) { - set.status = 400; - return { success: false }; - } + const { token, solutions } = body; + if (!token || !solutions) { + set.status = 400; + return { success: false }; + } - const answer = await cap.redeemChallenge({ token, solutions }); + const answer = await cap.redeemChallenge({ token, solutions }); - console.log("new challenge redeemed", { - ...answer, - isValid: (await cap.validateToken(answer.token)).success, - }); + console.log("new challenge redeemed", { + ...answer, + isValid: (await cap.validateToken(answer.token)).success, + }); - return answer; + return answer; }); app.listen(3000); diff --git a/demo/storage-hooks/index.js b/demo/storage-hooks/index.js deleted file mode 100644 index 7270b5c..0000000 --- a/demo/storage-hooks/index.js +++ /dev/null @@ -1,124 +0,0 @@ -import { Database } from "bun:sqlite"; -import fs from "node:fs/promises"; -import { Elysia, file } from "elysia"; -import Cap from "../../server/index.js"; - -const db = new Database("./.data/cap.db"); - -db.exec(` - CREATE TABLE IF NOT EXISTS challenges ( - token TEXT PRIMARY KEY, - data TEXT NOT NULL, - expires INTEGER NOT NULL - ); - CREATE TABLE IF NOT EXISTS tokens ( - key TEXT PRIMARY KEY, - expires INTEGER NOT NULL - ); -`); - -const cap = new Cap({ - storage: { - challenges: { - store: async (token, challengeData) => { - db.prepare( - "INSERT OR REPLACE INTO challenges (token, data, expires) VALUES (?, ?, ?)", - ).run( - token, - JSON.stringify(challengeData.challenge), - challengeData.expires, - ); - }, - read: async (token) => { - const row = db - .prepare( - "SELECT data, expires FROM challenges WHERE token = ? AND expires > ?", - ) - .get(token, Date.now()); - - return row - ? { challenge: JSON.parse(row.data), expires: row.expires } - : null; - }, - delete: async (token) => { - db.prepare("DELETE FROM challenges WHERE token = ?").run(token); - }, - listExpired: async () => { - const rows = db - .prepare("SELECT token FROM challenges WHERE expires <= ?") - .all(Date.now()); - - return rows.map((row) => row.token); - }, - }, - tokens: { - store: async (tokenKey, expires) => { - db.prepare( - "INSERT OR REPLACE INTO tokens (key, expires) VALUES (?, ?)", - ).run(tokenKey, expires); - }, - get: async (tokenKey) => { - const row = db - .prepare("SELECT expires FROM tokens WHERE key = ? AND expires > ?") - .get(tokenKey, Date.now()); - - return row ? row.expires : null; - }, - delete: async (tokenKey) => { - db.prepare("DELETE FROM tokens WHERE key = ?").run(tokenKey); - }, - listExpired: async () => { - const rows = db - .prepare("SELECT key FROM tokens WHERE expires <= ?") - .all(Date.now()); - - return rows.map((row) => row.key); - }, - }, - }, -}); - -const app = new Elysia(); - -app.get("/", () => file("./index.html")); - -app.get("/cap.js", async ({ set }) => { - // in the newest version, the worker is injected into the main file - // by a build script. since we don't have a build script here, - // we'll need to run a minimal build ourselves. - - const main = await fs.readFile("../../widget/src/src/cap.js", "utf-8"); - const worker = await fs.readFile("../../widget/src/src/worker.js", "utf-8"); - - const bundle = main.replace("%%workerScript%%", worker); - - set.headers = { - "Content-Type": "application/javascript", - }; - - return bundle; -}); - -app.get("/cap-floating.js", () => file("../../widget/src/src/cap-floating.js")); - -app.post("/api/challenge", () => cap.createChallenge()); - -app.post("/api/redeem", async ({ body, set }) => { - const { token, solutions } = body; - if (!token || !solutions) { - set.status = 400; - return { success: false }; - } - - const answer = await cap.redeemChallenge({ token, solutions }); - - console.log("new challenge redeemed", { - ...answer, - isValid: (await cap.validateToken(answer.token)).success, - }); - - return answer; -}); - -app.listen(3000); -console.log("Server is running on http://localhost:3000"); diff --git a/docs/guide/index.md b/docs/guide/index.md index c783136..1059715 100644 --- a/docs/guide/index.md +++ b/docs/guide/index.md @@ -73,8 +73,6 @@ You can choose between using the Standalone server or implementing your own serv - For most use cases, we recommend using the **[Standalone server](./standalone/index.md)** as it provides a complete solution with built-in features like token storage, ratelimiting, analytics, and a pretty nice dashboard. However, it does require docker and exposing the server port. -- However, if you prefer to implement your own server, you can use the `@cap.js/server` package. This package provides the necessary methods to create and validate challenges, as well as redeem solutions, but it only works with a JavaScript backend. - - It also gives you a small, simple JSON token storage, but you'll probably want to use a more robust solution like SQLite or Redis, but don't worry, we'll tell you everything about that [on the server guide](./server.md). +- However, if you prefer to implement your own server, you can use the `@cap.js/server` package. This package provides the necessary methods to create and validate challenges, as well as redeem solutions, but it only works with a JavaScript backend and you'll have to add your own db. [See the server guide](./server.md) for more details. - If you would like the simplicity of the server package but with another language, check out the [community packages](./community.md) diff --git a/docs/guide/server.md b/docs/guide/server.md index 8b02ead..5c30d16 100644 --- a/docs/guide/server.md +++ b/docs/guide/server.md @@ -62,12 +62,8 @@ const cap = new Cap({ delete: async (token) => { db.prepare("DELETE FROM challenges WHERE token = ?").run(token); }, - listExpired: async () => { - const rows = db - .prepare("SELECT token FROM challenges WHERE expires <= ?") - .all(Date.now()); - - return rows.map((row) => row.token); + deleteExpired: async () => { + db.prepare("DELETE FROM challenges WHERE expires <= ?").run(Date.now()); }, }, tokens: { @@ -86,12 +82,8 @@ const cap = new Cap({ delete: async (tokenKey) => { db.prepare("DELETE FROM tokens WHERE key = ?").run(tokenKey); }, - listExpired: async () => { - const rows = db - .prepare("SELECT key FROM tokens WHERE expires <= ?") - .all(Date.now()); - - return rows.map((row) => row.key); + deleteExpired: async () => { + db.prepare("DELETE FROM tokens WHERE expires <= ?").run(Date.now()); }, }, }, @@ -201,13 +193,13 @@ if (!success) throw new Error("invalid cap token"); "store": "async (token, challengeData) => {}", "read": "async (token) => {}", "delete": "async (token) => {}", - "listExpired": "async () => []" + "deleteExpired": "async () => {}" }, "tokens": { "store": "async (tokenKey, expires) => {}", "get": "async (tokenKey) => {}", "delete": "async (tokenKey) => {}", - "listExpired": "async () => []" + "deleteExpired": "async () => {}" } }, diff --git a/server/index.d.ts b/server/index.d.ts index a7fb23f..252b93c 100644 --- a/server/index.d.ts +++ b/server/index.d.ts @@ -191,9 +191,9 @@ type ChallengeStorage = { */ delete: (arg0: string) => Promise; /** - * - List expired challenge tokens + * - Delete expired challenge tokens */ - listExpired: () => Promise; + deleteExpired: () => Promise; }; type TokenStorage = { /** @@ -209,9 +209,9 @@ type TokenStorage = { */ delete: (arg0: string) => Promise; /** - * - List expired token keys + * - Delete expired token keys */ - listExpired: () => Promise; + deleteExpired: () => Promise; }; type StorageHooks = { /** diff --git a/server/index.js b/server/index.js index 09785c6..8bd0580 100644 --- a/server/index.js +++ b/server/index.js @@ -52,7 +52,7 @@ * @property {function(string, ChallengeData): Promise} store - Store challenge data * @property {function(string): Promise} read - Retrieve challenge data * @property {function(string): Promise} delete - Delete challenge data - * @property {function(): Promise} listExpired - List expired challenge tokens + * @property {function(): Promise} deleteExpired - Delete expired challenge tokens */ /** @@ -60,7 +60,7 @@ * @property {function(string, number): Promise} store - Store token with expiration * @property {function(string): Promise} get - Retrieve token expiration * @property {function(string): Promise} delete - Delete token - * @property {function(): Promise} listExpired - List expired token keys + * @property {function(): Promise} deleteExpired - Delete expired token keys */ /** @@ -441,15 +441,8 @@ class Cap extends EventEmitter { const now = Date.now(); let tokensChanged = false; - if (this.config.storage?.challenges?.listExpired) { - const expiredChallenges = - await this.config.storage.challenges.listExpired(); - - await Promise.all( - expiredChallenges.map(async (token) => { - await this._deleteChallenge(token); - }), - ); + if (this.config.storage?.challenges?.deleteExpired) { + await this.config.storage.challenges.deleteExpired(); } else if (!this.config.storage?.challenges) { const expired = Object.entries(this.config.state.challengesList) .filter(([_, v]) => v.expires < now) @@ -462,18 +455,12 @@ class Cap extends EventEmitter { ); } else { console.warn( - "[cap] challenge storage hooks provided but no listExpired, couldn't delete expired challenges", + "[cap] challenge storage hooks provided but no deleteExpired, couldn't delete expired challenges", ); } - if (this.config.storage?.tokens?.listExpired) { - const expiredTokens = await this.config.storage.tokens.listExpired(); - await Promise.all( - expiredTokens.map(async (tokenKey) => { - await this._deleteToken(tokenKey); - tokensChanged = true; - }), - ); + if (this.config.storage?.tokens?.deleteExpired) { + await this.config.storage.tokens.deleteExpired(); } else if (!this.config.storage?.tokens) { for (const k in this.config.state.tokensList) { if (this.config.state.tokensList[k] < now) { @@ -483,7 +470,7 @@ class Cap extends EventEmitter { } } else { console.warn( - "[cap] token storage hooks provided but no listExpired, couldn't delete expired tokens", + "[cap] token storage hooks provided but no deleteExpired, couldn't delete expired tokens", ); } diff --git a/server/package.json b/server/package.json index 270d822..57b587c 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@cap.js/server", - "version": "3.0.1", + "version": "4.0.1", "author": "Tiago", "repository": { "type": "git", @@ -54,7 +54,7 @@ "license": "Apache-2.0", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", - "npm:publish": "bun publish --access public" + "publish": "bun publish --access public" }, "type": "commonjs", "dependencies": {