diff --git a/.github/workflows/publish-cache-server.yml b/.github/workflows/publish-cache-server.yml new file mode 100644 index 0000000..35fa0a1 --- /dev/null +++ b/.github/workflows/publish-cache-server.yml @@ -0,0 +1,50 @@ +name: Publish @nimpl/cache-server + +on: + push: + tags: + - nimpl/cache-server@* + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: olegtarasov/get-tag@v2.1.3 + id: tagName + with: + tagRegex: "nimpl/cache-server@(?.*)" + + - name: Checkout code + uses: actions/checkout@v5 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Install deps and Build package + run: | + pnpm install + pnpm run build + cp ../../LICENSE . + working-directory: ./packages/cache-server + + - name: Publish on main + if: "!contains(github.ref_name, 'canary')" + run: | + npm set //registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}} + npm version --no-git-tag-version ${{steps.tagName.outputs.version}} + npm publish --access public + working-directory: ./packages/cache-server + + - name: Publish on canary + if: contains(github.ref_name, 'canary') + run: | + npm set //registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}} + npm version --no-git-tag-version ${{steps.tagName.outputs.version}} + npm publish --tag canary --access public + working-directory: ./packages/cache-server diff --git a/examples/external-store/site/.gitignore b/examples/external-store-server/.gitignore similarity index 96% rename from examples/external-store/site/.gitignore rename to examples/external-store-server/.gitignore index 0e29073..dd79846 100644 --- a/examples/external-store/site/.gitignore +++ b/examples/external-store-server/.gitignore @@ -37,3 +37,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# cache +.cache diff --git a/examples/external-store/README.md b/examples/external-store-server/README.md similarity index 100% rename from examples/external-store/README.md rename to examples/external-store-server/README.md diff --git a/examples/external-store-server/index.js b/examples/external-store-server/index.js new file mode 100644 index 0000000..4d597c2 --- /dev/null +++ b/examples/external-store-server/index.js @@ -0,0 +1,12 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +// @ts-check + +const { run } = require("@nimpl/cache-server"); +const { CacheHandler, LruLayer, FsLayer } = require("@nimpl/cache"); + +const cacheHandler = new CacheHandler({ + ephemeralLayer: new LruLayer(), + persistentLayer: new FsLayer(), +}); + +run(cacheHandler); diff --git a/examples/external-store/server/package.json b/examples/external-store-server/package.json similarity index 67% rename from examples/external-store/server/package.json rename to examples/external-store-server/package.json index 966586f..528fcae 100644 --- a/examples/external-store/server/package.json +++ b/examples/external-store-server/package.json @@ -6,7 +6,7 @@ "start": "node index" }, "dependencies": { - "@nimpl/cache-adapter": "latest", - "@nimpl/cache-in-memory": "latest" + "@nimpl/cache-server": "workspace:*", + "@nimpl/cache": "workspace:*" } } diff --git a/examples/external-store/server/.gitignore b/examples/external-store-site/.gitignore similarity index 100% rename from examples/external-store/server/.gitignore rename to examples/external-store-site/.gitignore diff --git a/examples/external-store/site/README.md b/examples/external-store-site/README.md similarity index 100% rename from examples/external-store/site/README.md rename to examples/external-store-site/README.md diff --git a/examples/external-store-site/cache-handler.js b/examples/external-store-site/cache-handler.js new file mode 100644 index 0000000..3cdb52d --- /dev/null +++ b/examples/external-store-site/cache-handler.js @@ -0,0 +1,10 @@ +// @ts-check +/* eslint-disable @typescript-eslint/no-require-imports */ +const { CacheHandler, LruLayer, FetchLayer } = require("@nimpl/cache"); + +global.cacheHandler ||= new CacheHandler({ + ephemeralLayer: new LruLayer({ ttl: 10 }), + persistentLayer: new FetchLayer(), +}); + +module.exports = global.cacheHandler; diff --git a/examples/external-store/site/clone.js b/examples/external-store-site/clone.js similarity index 100% rename from examples/external-store/site/clone.js rename to examples/external-store-site/clone.js diff --git a/examples/external-store-site/next.config.js b/examples/external-store-site/next.config.js new file mode 100644 index 0000000..df0755e --- /dev/null +++ b/examples/external-store-site/next.config.js @@ -0,0 +1,11 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + cacheMaxMemorySize: 0, + cacheHandlers: { + default: require.resolve("./cache-handler.js"), + }, + cacheComponents: true, + distDir: process.env.DIST_DIR || ".next", +}; + +module.exports = nextConfig; diff --git a/examples/external-store/site/package.json b/examples/external-store-site/package.json similarity index 87% rename from examples/external-store/site/package.json rename to examples/external-store-site/package.json index f3d6763..6347d8b 100644 --- a/examples/external-store/site/package.json +++ b/examples/external-store-site/package.json @@ -10,8 +10,7 @@ "lint": "next lint" }, "dependencies": { - "@nimpl/cache-adapter": "latest", - "@nimpl/cache-in-memory": "latest", + "@nimpl/cache": "workspace:*", "next": "16.0.10", "react": "19.2.3", "react-dom": "19.2.3" diff --git a/examples/external-store/site/src/app/api/revalidate/route.ts b/examples/external-store-site/src/app/api/revalidate/route.ts similarity index 100% rename from examples/external-store/site/src/app/api/revalidate/route.ts rename to examples/external-store-site/src/app/api/revalidate/route.ts diff --git a/examples/external-store/site/src/app/favicon.ico b/examples/external-store-site/src/app/favicon.ico similarity index 100% rename from examples/external-store/site/src/app/favicon.ico rename to examples/external-store-site/src/app/favicon.ico diff --git a/examples/external-store/site/src/app/layout.tsx b/examples/external-store-site/src/app/layout.tsx similarity index 100% rename from examples/external-store/site/src/app/layout.tsx rename to examples/external-store-site/src/app/layout.tsx diff --git a/examples/external-store/site/src/app/page.tsx b/examples/external-store-site/src/app/page.tsx similarity index 58% rename from examples/external-store/site/src/app/page.tsx rename to examples/external-store-site/src/app/page.tsx index 7a7f393..18f9679 100644 --- a/examples/external-store/site/src/app/page.tsx +++ b/examples/external-store-site/src/app/page.tsx @@ -1,6 +1,9 @@ +import { cacheLife } from "next/cache"; import RevalidateButton from "./revalidate-button"; -export default function Home() { +export default async function Home() { + "use cache"; + cacheLife({ stale: 30, revalidate: 60, expire: 300 }); return (
@@ -10,5 +13,3 @@ export default function Home() {
); } - -export const dynamic = "force-static"; diff --git a/examples/external-store/site/src/app/revalidate-button.tsx b/examples/external-store-site/src/app/revalidate-button.tsx similarity index 100% rename from examples/external-store/site/src/app/revalidate-button.tsx rename to examples/external-store-site/src/app/revalidate-button.tsx diff --git a/examples/external-store/site/tsconfig.json b/examples/external-store-site/tsconfig.json similarity index 67% rename from examples/external-store/site/tsconfig.json rename to examples/external-store-site/tsconfig.json index 9f3e52b..3a13f90 100644 --- a/examples/external-store/site/tsconfig.json +++ b/examples/external-store-site/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, @@ -10,18 +11,24 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, - "checkJs": true, "plugins": [ { "name": "next" } ], "paths": { - "@/*": ["./src/*"] + "@/*": ["./*"] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "cache-handler.js", "test.js"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], "exclude": ["node_modules"] } diff --git a/examples/external-store/server/index.js b/examples/external-store/server/index.js deleted file mode 100644 index 7959c47..0000000 --- a/examples/external-store/server/index.js +++ /dev/null @@ -1,11 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -// @ts-check - -const { createServer } = require("@nimpl/cache-adapter"); -const { default: CacheHandler } = require("@nimpl/cache-in-memory"); - -const server = createServer(new CacheHandler({})); - -server.listen("4000", () => { - console.log("Server is running at http://localhost:4000"); -}); diff --git a/examples/external-store/site/cache-handler.js b/examples/external-store/site/cache-handler.js deleted file mode 100644 index 39dd175..0000000 --- a/examples/external-store/site/cache-handler.js +++ /dev/null @@ -1,20 +0,0 @@ -/* eslint-disable @typescript-eslint/no-require-imports */ -// @ts-check -const { AppAdapter } = require("@nimpl/cache-adapter"); -const { default: CacheHandler } = require("@nimpl/cache-in-memory"); - -class CustomCacheHandler extends AppAdapter { - /** @param {any} options */ - constructor(options) { - super({ - CacheHandler, - buildId: process.env.BUILD_ID || "base_id", - cacheUrl: "http://localhost:4000", - cacheMode: "remote", - options, - buildReady: options.buildReady || false, - }); - } -} - -module.exports = CustomCacheHandler; diff --git a/examples/external-store/site/next.config.mjs b/examples/external-store/site/next.config.mjs deleted file mode 100644 index f6a4dc6..0000000 --- a/examples/external-store/site/next.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { - cacheMaxMemorySize: 0, - cacheHandler: import.meta.resolve("@nimpl/cache-in-memory"), - distDir: process.env.DIST_DIR || ".next", -}; - -export default nextConfig; diff --git a/package.json b/package.json index a10d90e..53dc0c0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "lint-staged": { "*.{ts,tsx,js,jsx}": ["eslint"] }, + "resolutions": { + "@nimpl/cache-adapter>@nimpl/cache": "workspace:*" + }, "license": "MIT", "packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a" } diff --git a/packages/cache-server/README.md b/packages/cache-server/README.md new file mode 100644 index 0000000..c2df62f --- /dev/null +++ b/packages/cache-server/README.md @@ -0,0 +1,299 @@ +# @nimpl/cache-server + +A standalone HTTP server that exposes a `CacheHandler` instance via HTTP API. Designed to work with `FetchLayer` from `@nimpl/cache` to enable distributed caching across multiple application instances. + +## Installation + +```bash +npm install @nimpl/cache-server @nimpl/cache +# or +pnpm add @nimpl/cache-server @nimpl/cache +``` + +## Overview + +`@nimpl/cache-server` provides an HTTP server that wraps any `CacheHandler` instance and exposes it through a standardized HTTP API. This enables you to: + +- Share cache across multiple application instances in distributed deployments +- Run cache as a separate service for better scalability +- Use `FetchLayer` in your applications to access the cache remotely + +The server implements the HTTP API expected by `FetchLayer`, making it easy to set up distributed caching solutions. + +## Usage + +### Basic Setup + +Create a cache handler and start the server: + +```ts +import { run } from "@nimpl/cache-server"; +import { CacheHandler, LruLayer, FsLayer } from "@nimpl/cache"; + +const cacheHandler = new CacheHandler({ + ephemeralLayer: new LruLayer(), + persistentLayer: new FsLayer(), +}); + +run(cacheHandler); +``` + +The server will start on `http://localhost:4000` by default. + +### Custom Configuration + +```ts +import { run } from "@nimpl/cache-server"; +import { CacheHandler, LruLayer, RedisLayer } from "@nimpl/cache"; + +const cacheHandler = new CacheHandler({ + ephemeralLayer: new LruLayer(), + persistentLayer: new RedisLayer(), +}); + +run(cacheHandler, { + port: 8080, + host: "0.0.0.0", + verifyRequest: async (req) => { + // Add authentication/authorization logic + const authHeader = req.headers.authorization; + return authHeader === `Bearer ${process.env.CACHE_TOKEN}`; + }, +}); +``` + +### Using with FetchLayer + +In your application instances, use `FetchLayer` to connect to the cache server: + +```ts +// cache-handler.js +import { CacheHandler, LruLayer, FetchLayer } from "@nimpl/cache"; + +export default new CacheHandler({ + ephemeralLayer: new LruLayer(), + persistentLayer: new FetchLayer({ + baseUrl: process.env.CACHE_SERVER_URL || "http://cache-server:4000", + }), +}); +``` + +## API + +### `init(cacheHandler, options?)` + +Creates an HTTP server instance without starting it. Useful when you need to control when the server starts or integrate it with other server frameworks. + +**Parameters:** + +- `cacheHandler` (`CacheHandlerRoot`): The cache handler instance to expose via HTTP +- `options` (`CacheServerOptions`): Optional configuration + - `verifyRequest` (`(req: IncomingMessage) => Promise`): Optional callback to verify/authenticate requests. Return `false` to reject the request with 403 status. + +**Returns:** `http.Server` - The HTTP server instance + +**Example:** + +```ts +import { init } from "@nimpl/cache-server"; +import { CacheHandler, LruLayer, RedisLayer } from "@nimpl/cache"; + +const cacheHandler = new CacheHandler({ + ephemeralLayer: new LruLayer(), + persistentLayer: new RedisLayer(), +}); + +const server = init(cacheHandler, { + verifyRequest: async (req) => { + // Custom authentication logic + return true; + }, +}); + +// Start the server manually +server.listen(4000, "0.0.0.0", () => { + console.log("Cache server running on port 4000"); +}); +``` + +### `run(cacheHandler, options?)` + +Creates and starts an HTTP server that exposes the cache handler. + +**Parameters:** + +- `cacheHandler` (`CacheHandlerRoot`): The cache handler instance to expose via HTTP +- `options` (`CacheServerOptions`): Optional configuration + - `port` (`number`): Port to run the server on. Default: `4000` + - `host` (`string`): Host to bind the server to. Default: `"localhost"` + - `verifyRequest` (`(req: IncomingMessage) => Promise`): Optional callback to verify/authenticate requests. Return `false` to reject the request with 403 status. + +**Returns:** `http.Server` - The HTTP server instance + +**Example:** + +```ts +import { run } from "@nimpl/cache-server"; +import { CacheHandler, LruLayer, FsLayer } from "@nimpl/cache"; + +const cacheHandler = new CacheHandler({ + ephemeralLayer: new LruLayer(), + persistentLayer: new FsLayer(), +}); + +run(cacheHandler, { + port: 4000, + host: "0.0.0.0", +}); +``` + +## HTTP API + +The server implements the following HTTP endpoints expected by `FetchLayer`: + +### `GET /?key=` + +Retrieves a cache entry. + +- **Query Parameters:** + - `key` (required): The cache key +- **Response:** + - `200 OK`: Returns the cache entry as a stream with `x-cache-metadata` header + - `404 Not Found`: Cache entry not found + - `400 Bad Request`: Missing key parameter + - `500 Internal Server Error`: Server error + +### `POST /?key=` + +Stores a cache entry. + +- **Query Parameters:** + - `key` (required): The cache key +- **Headers:** + - `x-cache-metadata` (required): JSON string containing cache metadata (tags, timestamp, stale, expire, revalidate) + - `Content-Type`: `application/octet-stream` +- **Body:** Stream containing the cache value +- **Response:** + - `200 OK`: Entry stored successfully + - `400 Bad Request`: Missing key or metadata + - `500 Internal Server Error`: Server error + +### `PUT /` + +Updates tags for cache entries. + +- **Body:** JSON object with `tags` (array) and optional `durations` object +- **Response:** + - `200 OK`: Tags updated successfully + - `400 Bad Request`: Invalid request body + - `500 Internal Server Error`: Server error + +### `DELETE /?key=` + +Deletes a cache entry. + +- **Query Parameters:** + - `key` (required): The cache key +- **Response:** + - `200 OK`: Entry deleted successfully + - `400 Bad Request`: Missing key parameter + - `500 Internal Server Error`: Server error + +### `GET /keys` + +Returns all cache keys. + +- **Response:** + - `200 OK`: JSON array of cache keys + - `500 Internal Server Error`: Server error + +### `GET /readiness` + +Health check endpoint. + +- **Response:** + - `200 OK`: JSON object with `ready` boolean indicating cache handler readiness + +## Security + +The server does not include built-in authentication. For production deployments, you should implement request verification using the `verifyRequest` option: + +```ts +run(cacheHandler, { + verifyRequest: async (req) => { + // Example: Verify API token + const authHeader = req.headers.authorization; + const expectedToken = process.env.CACHE_SERVER_TOKEN; + + if (!authHeader || !expectedToken) { + return false; + } + + return authHeader === `Bearer ${expectedToken}`; + }, +}); +``` + +For additional security, consider: + +- Running the server behind a reverse proxy with authentication (e.g., nginx, Traefik) +- Using network policies to restrict access to the cache server +- Implementing rate limiting +- Using TLS/HTTPS for encrypted communication + +## Deployment + +### Docker + +```dockerfile +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --production + +COPY . . + +CMD ["node", "server.js"] +``` + +### Kubernetes + +The cache server can be deployed as a separate service in Kubernetes: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: cache-server +spec: + replicas: 1 + selector: + matchLabels: + app: cache-server + template: + metadata: + labels: + app: cache-server + spec: + containers: + - name: cache-server + image: your-registry/cache-server:latest + ports: + - containerPort: 4000 + env: + - name: REDIS_URL + valueFrom: + secretKeyRef: + name: cache-secrets + key: redis-url +``` + +## Examples + +See the [external-store-server example](https://github.com/alexdln/nimpl-cache/tree/main/examples/external-store-server) for a complete setup demonstrating cache server with filesystem storage. + +## License + +[MIT](https://github.com/alexdln/nimpl-cache/blob/main/LICENSE) diff --git a/packages/cache-server/package.json b/packages/cache-server/package.json new file mode 100644 index 0000000..fde5f74 --- /dev/null +++ b/packages/cache-server/package.json @@ -0,0 +1,53 @@ +{ + "name": "@nimpl/cache-server", + "version": "0.0.1", + "description": "", + "files": [ + "dist" + ], + "main": "dist/cjs/index.js", + "module": "dist/esm/index.mjs", + "types": "dist/cjs/index.d.ts", + "exports": { + ".": { + "types": "./dist/cjs/index.d.ts", + "import": "./dist/esm/index.mjs", + "require": "./dist/cjs/index.js", + "default": "./dist/esm/index.mjs" + } + }, + "scripts": { + "build": "rollup -c", + "clean": "rm -rf dist" + }, + "keywords": [ + "next", + "nextjs", + "react", + "cache", + "cache-handler", + "remote cache", + "next k8s cache" + ], + "repository": { + "type": "git", + "url": "git://github.com/alexdln/nimpl-cache.git" + }, + "author": { + "name": "Alex Savelyev", + "email": "dev@alexdln.com", + "url": "https://github.com/alexdln/" + }, + "devDependencies": { + "@nimpl/cache": "latest", + "@rollup/plugin-commonjs": "29.0.0", + "@rollup/plugin-node-resolve": "16.0.3", + "@rollup/plugin-terser": "0.4.4", + "@rollup/plugin-typescript": "12.3.0", + "@types/node": "24.10.1", + "rollup": "4.53.3", + "tslib": "2.8.1", + "typescript": "5.9.3" + }, + "license": "MIT" +} \ No newline at end of file diff --git a/packages/cache-server/rollup.config.js b/packages/cache-server/rollup.config.js new file mode 100644 index 0000000..5c58102 --- /dev/null +++ b/packages/cache-server/rollup.config.js @@ -0,0 +1,34 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +const commonjs = require("@rollup/plugin-commonjs"); +const typescript = require("@rollup/plugin-typescript"); +const terser = require("@rollup/plugin-terser"); +const { nodeResolve } = require("@rollup/plugin-node-resolve"); + +const isProduction = process.env.NODE_ENV === "production"; + +const createConfig = (format, outputDir, tsconfig) => ({ + input: ["src/index.ts"], + output: { + dir: outputDir, + format, + sourcemap: true, + preserveModules: true, + preserveModulesRoot: "src", + entryFileNames: `[name].${format === "esm" ? "mjs" : "js"}`, + }, + plugins: [ + nodeResolve(), + commonjs(), + typescript({ + tsconfig: tsconfig, + declaration: true, + declarationDir: outputDir, + }), + isProduction && terser(), + ].filter(Boolean), +}); + +module.exports = [ + createConfig("esm", "dist/esm", "./tsconfig.esm.json"), + createConfig("cjs", "dist/cjs", "./tsconfig.cjs.json"), +]; diff --git a/packages/cache-server/src/index.ts b/packages/cache-server/src/index.ts new file mode 100644 index 0000000..213d027 --- /dev/null +++ b/packages/cache-server/src/index.ts @@ -0,0 +1,189 @@ +import { createServer as createHttpServer } from "http"; +import { Readable } from "node:stream"; +import { ReadableStream as WebReadableStream } from "node:stream/web"; + +import { type CacheHandlerRoot } from "@nimpl/cache"; +import { type CacheServerOptions } from "./types"; + +/** + * Run cache server to control cache remotely via HTTP API + * Implements routes expected by FetchLayer: + * - GET /?key=... - get entry (returns stream with x-cache-metadata header) + * - POST /?key=... - set entry (expects stream body and x-cache-metadata header) + * - PUT / - updateTags (expects JSON body with tags and durations) + * - DELETE /?key=... - delete entry + * - GET /keys - get keys (returns JSON array) + * - GET /readiness - checkIsReady (returns ok status) + * + * @param cacheHandler CacheHandler instance from @nimpl/cache + * @param options.port port to run server on (default: 4000) + * @param options.host host to run server on (default: localhost) + * @param options.verifyRequest callback to verify request + * @returns HTTP server + */ +export const init = (cacheHandler: CacheHandlerRoot, options?: Pick) => { + const { verifyRequest } = options || {}; + + const server = createHttpServer(async (req, res) => { + try { + if (!req.url || (verifyRequest && !(await verifyRequest(req)))) { + res.statusCode = 403; + return res.end(); + } + + const url = new URL(req.url, "http://n"); + const pathname = url.pathname; + const method = req.method?.toUpperCase(); + const key = url.searchParams.get("key"); + + if (method === "GET" && pathname === "/readiness") { + const isReady = await cacheHandler.checkIsReady(); + res.setHeader("Content-Type", "application/json"); + return res.end(JSON.stringify({ ready: isReady })); + } + + if (method === "GET" && pathname === "/keys") { + try { + const keys = await cacheHandler.keys(); + res.setHeader("Content-Type", "application/json"); + return res.end(JSON.stringify(keys)); + } catch (error) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + return res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + } + + if (method === "GET" && pathname === "/") { + if (!key) { + res.statusCode = 400; + return res.end(); + } + + try { + const entry = await cacheHandler.get(key); + if (!entry) { + res.statusCode = 404; + return res.end(); + } + + const metadata = { + tags: entry.tags, + timestamp: entry.timestamp, + stale: entry.stale, + expire: entry.expire, + revalidate: entry.revalidate, + }; + + res.statusCode = 200; + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("x-cache-metadata", JSON.stringify(metadata)); + const [cacheStream, responseStream] = entry.value.tee(); + entry.value = cacheStream; + + const nodeStream = Readable.fromWeb(responseStream as WebReadableStream); + nodeStream.pipe(res); + } catch { + res.statusCode = 500; + return res.end(); + } + return; + } + + if (method === "POST" && pathname === "/") { + if (!key) { + res.statusCode = 400; + return res.end(); + } + + try { + const metadataHeader = req.headers["x-cache-metadata"]; + if (!metadataHeader || typeof metadataHeader !== "string") { + res.statusCode = 400; + return res.end(); + } + + const metadata = JSON.parse(metadataHeader); + const bodyStream = Readable.toWeb(req); + + const entry = { + ...metadata, + value: bodyStream, + }; + + await cacheHandler.set(key, entry); + + res.statusCode = 200; + return res.end(); + } catch (error) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + return res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + } + + if (method === "PUT" && pathname === "/") { + try { + let body = ""; + for await (const chunk of req) { + body += chunk.toString(); + } + const { tags, durations } = JSON.parse(body); + + if (!Array.isArray(tags)) { + res.statusCode = 400; + return res.end(); + } + + await cacheHandler.updateTags(tags, durations); + + res.statusCode = 200; + return res.end(); + } catch (error) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + return res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + } + + if (method === "DELETE" && pathname === "/") { + if (!key) { + res.statusCode = 400; + return res.end(); + } + + try { + await cacheHandler.delete(key); + res.statusCode = 200; + return res.end(); + } catch (error) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + return res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + } + + res.statusCode = 404; + return res.end(); + } catch (error) { + console.error("error on cache processing", error); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) })); + } + } + }); + return server; +}; + +export const run = (cacheHandler: CacheHandlerRoot, options?: CacheServerOptions) => { + const { port = 4000, host = "localhost", ...rest } = options || {}; + const server = init(cacheHandler, rest); + + server.listen(port, host, () => { + console.log(`Cache server is running on http://${host}:${port}`); + }); + + return server; +}; diff --git a/packages/cache-server/src/types.ts b/packages/cache-server/src/types.ts new file mode 100644 index 0000000..9916cde --- /dev/null +++ b/packages/cache-server/src/types.ts @@ -0,0 +1,7 @@ +import { type IncomingMessage } from "http"; + +export type CacheServerOptions = { + port?: number; + host?: string; + verifyRequest?: (req: IncomingMessage) => Promise; +}; diff --git a/packages/cache-server/tsconfig.cjs.json b/packages/cache-server/tsconfig.cjs.json new file mode 100644 index 0000000..5862dbb --- /dev/null +++ b/packages/cache-server/tsconfig.cjs.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist/cjs", + "declarationDir": "dist/cjs" + }, + "include": ["src/**/*"] +} diff --git a/packages/cache-server/tsconfig.esm.json b/packages/cache-server/tsconfig.esm.json new file mode 100644 index 0000000..842d5f1 --- /dev/null +++ b/packages/cache-server/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "bundler", + "outDir": "dist/esm", + "declarationDir": "dist/esm" + }, + "include": ["src/**/*"] +} diff --git a/packages/cache-server/tsconfig.json b/packages/cache-server/tsconfig.json new file mode 100644 index 0000000..666c3e5 --- /dev/null +++ b/packages/cache-server/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "es2019", + "module": "commonjs", + "jsx": "react", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "outDir": "dist/cjs", + "rootDir": "src", + "declaration": true, + "declarationDir": "dist/cjs" + }, + "include": ["src/**/*"] +} diff --git a/packages/cache/README.md b/packages/cache/README.md index 160b5b4..1886b66 100644 --- a/packages/cache/README.md +++ b/packages/cache/README.md @@ -91,6 +91,28 @@ Expired entries are detected and not returned, but files are not automatically d > **Note**: In multi-pod deployments without shared volumes, each pod will have its own filesystem cache, which won't be shared between instances. +**Fetch Client** - Persistent cache accessed via HTTP API. Communicates with a remote cache server using HTTP requests, making it suitable for distributed deployments where cache needs to be shared across multiple instances or accessed through a dedicated cache service. + +Options (`FetchLayerOptions`): + +- `baseUrl` (string): Base URL of the cache server. Default: `"http://localhost:4000"` +- `fetch` (typeof globalThis.fetch): Custom fetch implementation. Default: `globalThis.fetch` + +The fetch layer communicates with a cache server that implements the following HTTP API: + +- `GET /?key=...` - Retrieve cache entry (returns stream with `x-cache-metadata` header) +- `POST /?key=...` - Store cache entry (expects stream body and `x-cache-metadata` header) +- `PUT /` - Update tags (expects JSON body with `tags` and `durations`) +- `DELETE /?key=...` - Delete cache entry +- `GET /keys` - Get all cache keys (returns JSON array) +- `GET /readiness` - Health check (returns ok status) + +The layer handles streaming cache values efficiently and includes memoization to prevent duplicate requests for the same key. Cache metadata (tags, timestamps, expiration) is transferred via HTTP headers. + +You can use the `createServer` function from `@nimpl/cache` to create an HTTP server that implements this API and wraps any `CacheHandler` instance as a store. + +> **Note**: The fetch layer requires a running cache server. If the server is unavailable, cache operations will fail for persistent layer. + ## Configuration ### Options diff --git a/packages/cache/src/cache-handler.ts b/packages/cache/src/cache-handler.ts index 8356106..6678d65 100644 --- a/packages/cache/src/cache-handler.ts +++ b/packages/cache/src/cache-handler.ts @@ -55,10 +55,10 @@ export class CacheHandler implements CacheHandlerRoot { const ephemeralCache = await this.ephemeralLayer.getEntry(key); if (ephemeralCache) { if (ephemeralCache.status === "revalidate") { - this.logOperation("GET", "REVALIDATING", "MEMORY", key); + this.logOperation("GET", "REVALIDATING", "EPHEMERAL", key); return undefined; } - this.logOperation("GET", "HIT", "MEMORY", key); + this.logOperation("GET", "HIT", "EPHEMERAL", key); return ephemeralCache; } @@ -68,7 +68,7 @@ export class CacheHandler implements CacheHandlerRoot { return undefined; } if (pendingGet) { - this.logOperation("GET", "HIT", "REDIS", key); + this.logOperation("GET", "HIT", "PERSISTENT", key); const [cacheStream, responseStream] = pendingGet.entry.value.tee(); pendingGet.entry.value = cacheStream; return { entry: { ...pendingGet.entry, value: responseStream }, size: pendingGet.size, status: "valid" }; @@ -88,7 +88,7 @@ export class CacheHandler implements CacheHandlerRoot { this.logOperation( "GET", persistentCache === null ? "EXPIRED" : "MISS", - persistentCache === null ? "REDIS" : "NONE", + persistentCache === null ? "PERSISTENT" : "NONE", key, ); resolvePending(null); @@ -103,15 +103,15 @@ export class CacheHandler implements CacheHandlerRoot { const responseEntry = { ...entry, value: responseStream }; if (status === "revalidate") { - this.logOperation("GET", "REVALIDATING", "REDIS", key); + this.logOperation("GET", "REVALIDATING", "PERSISTENT", key); resolvePending(undefined); return undefined; } resolvePending({ entry: responseEntry, size, status: "valid" }); - this.logOperation("GET", "HIT", "REDIS", key); + this.logOperation("GET", "HIT", "PERSISTENT", key); return { entry: responseEntry, size, status: "valid" }; } catch (error) { - this.logOperation("GET", "ERROR", "REDIS", key, error instanceof Error ? error.message : undefined); + this.logOperation("GET", "ERROR", "PERSISTENT", key, error instanceof Error ? error.message : undefined); resolvePending(null); if (error instanceof CacheError) throw error; @@ -143,7 +143,7 @@ export class CacheHandler implements CacheHandlerRoot { this.logOperation("SET", "REVALIDATED", "NEW", key); } catch (error) { resolvePending(undefined); - this.logOperation("SET", "ERROR", "REDIS", key, error instanceof Error ? error.message : undefined); + this.logOperation("SET", "ERROR", "PERSISTENT", key, error instanceof Error ? error.message : undefined); if (error instanceof CacheError) throw error; } } @@ -163,17 +163,17 @@ export class CacheHandler implements CacheHandlerRoot { return; } - this.logOperation("UPDATE_TAGS", "REVALIDATING", "MEMORY", tagsKey); + this.logOperation("UPDATE_TAGS", "REVALIDATING", "EPHEMERAL", tagsKey); await this.ephemeralLayer.updateTags(tags, durations); try { - this.logOperation("UPDATE_TAGS", "REVALIDATING", "REDIS", tagsKey); + this.logOperation("UPDATE_TAGS", "REVALIDATING", "PERSISTENT", tagsKey); await this.persistentLayer.updateTags(tags, durations); } catch (error) { this.logOperation( "UPDATE_TAGS", "ERROR", - "REDIS", + "PERSISTENT", tagsKey, error instanceof Error ? error.message : undefined, ); diff --git a/packages/cache/src/layers/fetch-layer/index.ts b/packages/cache/src/layers/fetch-layer/index.ts new file mode 100644 index 0000000..fab365e --- /dev/null +++ b/packages/cache/src/layers/fetch-layer/index.ts @@ -0,0 +1,152 @@ +import { type Logger, type Durations, type Entry, type CacheEntry, type CacheHandlerLayer } from "../../types"; +import { type FetchLayerOptions } from "./types"; +import { logger as defaultLogger } from "../../lib/logger"; +import { getCacheStatus } from "../../lib/helpers"; +import { PendingsLayer } from "../pendings-layer"; +import { CacheError } from "../../lib/error"; + +export * from "./types"; + +export class FetchLayer implements CacheHandlerLayer { + private baseUrl: string; + + private fetchFn: typeof globalThis.fetch; + + private logger: Logger; + + private pendingKeysLayer = new PendingsLayer(); + + private pendingGetsLayer = new PendingsLayer(); + + constructor(options?: FetchLayerOptions, logger?: Logger) { + const { baseUrl = "http://localhost:4000", fetch: fetchFn = globalThis.fetch } = options || {}; + const isLoggerEnabled = logger || process.env.NEXT_PRIVATE_DEBUG_CACHE || process.env.NIC_LOGGER; + + this.logger = isLoggerEnabled ? logger || defaultLogger : () => {}; + this.baseUrl = baseUrl.replace(/\/$/, ""); + this.fetchFn = fetchFn; + } + + async getEntry(key: string): Promise { + const pendingGet = this.pendingGetsLayer.get(key); + if (pendingGet) { + const cacheEntry = await pendingGet; + if (!cacheEntry) return cacheEntry; + const [cacheStream, responseStream] = cacheEntry.entry.value.tee(); + cacheEntry.entry.value = cacheStream; + return { ...cacheEntry, entry: { ...cacheEntry.entry, value: responseStream } }; + } + + const resolvePending = this.pendingGetsLayer.set(key); + + const valueResponse = await this.fetchFn(`${this.baseUrl}/?key=${encodeURIComponent(key)}`); + const metadataRaw = valueResponse.headers.get("x-cache-metadata"); + + if (!valueResponse.ok || !metadataRaw) { + resolvePending(undefined); + return undefined; + } + + const metadata = JSON.parse(metadataRaw); + const status = getCacheStatus(metadata.timestamp, metadata.revalidate, metadata.expire); + if (status === "expire") { + resolvePending(null); + return null; + } + + const entry: Entry = Object.assign(metadata, { + value: valueResponse.body, + }); + + const cacheEntry = { entry, size: Number(valueResponse.headers.get("content-length")) || 1, status }; + const [cacheStream, responseStream] = entry.value.tee(); + entry.value = cacheStream; + + resolvePending(cacheEntry); + return { ...cacheEntry, entry: { ...entry, value: responseStream } }; + } + + async get(key: string): Promise { + const cacheEntry = await this.getEntry(key); + return cacheEntry && cacheEntry.status === "valid" ? cacheEntry.entry : undefined; + } + + async set(key: string, pendingEntry: Promise | Entry) { + const entry = await pendingEntry; + + const metadata = { + tags: entry.tags, + timestamp: entry.timestamp, + stale: entry.stale, + expire: entry.expire, + revalidate: entry.revalidate, + }; + + const result = await this.fetchFn(`${this.baseUrl}/?key=${encodeURIComponent(key)}`, { + method: "POST", + headers: { + "Content-Type": "application/octet-stream", + "X-Cache-Metadata": JSON.stringify(metadata), + }, + body: entry.value as unknown as BodyInit, + duplex: "half", + } as unknown as RequestInit); + + if (!result.ok) { + throw new CacheError(result.statusText || "Failed to set entry in fetch cache"); + } + } + + async updateTags(tags: string[], durations?: Durations) { + const result = await this.fetchFn(`${this.baseUrl}/`, { + method: "PUT", + body: JSON.stringify({ tags, durations }), + }); + + if (!result.ok) { + this.logger({ + type: "UPDATE_TAGS", + status: "ERROR", + source: "FETCH", + key: "tags", + message: result.statusText || "Failed to update tags", + }); + } + } + + async delete(key: string) { + await this.fetchFn(`${this.baseUrl}/?key=${encodeURIComponent(key)}`, { + method: "DELETE", + }); + } + + async checkIsReady() { + const result = await this.fetchFn(`${this.baseUrl}/readiness`); + return result.ok; + } + + async keys(): Promise { + const pendingKeys = this.pendingKeysLayer.get("keys"); + if (pendingKeys) return pendingKeys; + + const resolvePending = this.pendingKeysLayer.set("keys"); + + const result = await this.fetchFn(`${this.baseUrl}/keys`); + + if (!result.ok) { + this.logger({ + type: "GET", + status: "ERROR", + source: "FETCH", + key: "keys", + message: result.statusText || "Failed to fetch keys", + }); + resolvePending([]); + return []; + } + + const keys = await result.json(); + resolvePending(keys); + return keys; + } +} diff --git a/packages/cache/src/layers/fetch-layer/types.ts b/packages/cache/src/layers/fetch-layer/types.ts new file mode 100644 index 0000000..7885959 --- /dev/null +++ b/packages/cache/src/layers/fetch-layer/types.ts @@ -0,0 +1,4 @@ +export type FetchLayerOptions = { + baseUrl?: string; + fetch?: typeof globalThis.fetch; +}; diff --git a/packages/cache/src/layers/index.tsx b/packages/cache/src/layers/index.tsx index 0b3f0bf..41d8ec6 100644 --- a/packages/cache/src/layers/index.tsx +++ b/packages/cache/src/layers/index.tsx @@ -1,3 +1,4 @@ export * from "./lru-layer"; export * from "./redis-layer"; export * from "./fs-layer"; +export * from "./fetch-layer"; diff --git a/packages/cache/src/lib/logger.ts b/packages/cache/src/lib/logger.ts index 8e5a4f6..e7ae068 100644 --- a/packages/cache/src/lib/logger.ts +++ b/packages/cache/src/lib/logger.ts @@ -18,11 +18,14 @@ const STATUS_COLORS = { }; const SOURCE_COLORS = { + PERSISTENT: chalk.blue, + EPHEMERAL: chalk.green, MEMORY: chalk.blue, REDIS: chalk.red, FS: chalk.green, NEW: chalk.cyan, NONE: chalk.gray, + FETCH: chalk.magenta, DEFAULT: chalk.white, }; diff --git a/packages/cache/src/types.ts b/packages/cache/src/types.ts index 4d7a92a..8e459c6 100644 --- a/packages/cache/src/types.ts +++ b/packages/cache/src/types.ts @@ -38,7 +38,7 @@ export type LogData = { | "DISCONNECTED" | "RECONNECTING" | "RETRY"; - source: "MEMORY" | "REDIS" | "NEW" | "NONE" | "FS"; + source: "PERSISTENT" | "EPHEMERAL" | "MEMORY" | "REDIS" | "NEW" | "NONE" | "FS" | "FETCH"; key: string; message?: string; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be0612e..482dc55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@nimpl/cache-adapter>@nimpl/cache': workspace:* + importers: .: @@ -64,6 +67,46 @@ importers: specifier: 5.9.3 version: 5.9.3 + examples/external-store-server: + dependencies: + '@nimpl/cache': + specifier: workspace:* + version: link:../../packages/cache + '@nimpl/cache-server': + specifier: workspace:* + version: link:../../packages/cache-server + + examples/external-store-site: + dependencies: + '@nimpl/cache': + specifier: workspace:* + version: link:../../packages/cache + next: + specifier: 16.0.10 + version: 16.0.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.95.0) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + devDependencies: + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + '@types/react': + specifier: 19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.7) + cross-env: + specifier: 10.1.0 + version: 10.1.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages/cache: dependencies: chalk: @@ -150,6 +193,36 @@ importers: specifier: 5.9.3 version: 5.9.3 + packages/cache-server: + devDependencies: + '@nimpl/cache': + specifier: latest + version: 0.2.0 + '@rollup/plugin-commonjs': + specifier: 29.0.0 + version: 29.0.0(rollup@4.53.3) + '@rollup/plugin-node-resolve': + specifier: 16.0.3 + version: 16.0.3(rollup@4.53.3) + '@rollup/plugin-terser': + specifier: 0.4.4 + version: 0.4.4(rollup@4.53.3) + '@rollup/plugin-typescript': + specifier: 12.3.0 + version: 12.3.0(rollup@4.53.3)(tslib@2.8.1)(typescript@5.9.3) + '@types/node': + specifier: 24.10.1 + version: 24.10.1 + rollup: + specifier: 4.53.3 + version: 4.53.3 + tslib: + specifier: 2.8.1 + version: 2.8.1 + typescript: + specifier: 5.9.3 + version: 5.9.3 + packages/cache-tools: devDependencies: '@rollup/plugin-commonjs': @@ -852,6 +925,9 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@epic-web/invariant@1.0.0': + resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} + '@eslint-community/eslint-utils@4.9.0': resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1216,6 +1292,9 @@ packages: '@nimpl/cache@0.0.0-experimental-e9174c4': resolution: {integrity: sha512-AdBHHOWIYQYni/7N3JZ+wpQcmo0XMl/vGMt6UA6+RCJb6eAMr3VBsGEOtDSf4MHUK8kLbNmvyXT8o0ctTZK7uQ==} + '@nimpl/cache@0.2.0': + resolution: {integrity: sha512-wMOP1r/vAvduHRujb2lLiUZlWcc6TvkrJG7tuIObEwBiimAmYOJT8NXe6HyI1+FmYaZxB2M0LQldFWrdbPhFrQ==} + '@parcel/watcher-android-arm64@2.5.1': resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} engines: {node: '>= 10.0.0'} @@ -1882,6 +1961,11 @@ packages: core-js-compat@3.47.0: resolution: {integrity: sha512-IGfuznZ/n7Kp9+nypamBhvwdwLsW6KC8IOaURw2doAK5e98AG3acVLdh0woOnEqCfUtS+Vu882JE4k/DAm3ItQ==} + cross-env@10.1.0: + resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} + engines: {node: '>=20'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -3791,6 +3875,8 @@ snapshots: tslib: 2.8.1 optional: true + '@epic-web/invariant@1.0.0': {} + '@eslint-community/eslint-utils@4.9.0(eslint@9.39.1(jiti@2.6.1))': dependencies: eslint: 9.39.1(jiti@2.6.1) @@ -4210,6 +4296,14 @@ snapshots: transitivePeerDependencies: - supports-color + '@nimpl/cache@0.2.0': + dependencies: + chalk: 4.1.2 + ioredis: 5.8.2 + lru-cache: 11.2.4 + transitivePeerDependencies: + - supports-color + '@parcel/watcher-android-arm64@2.5.1': optional: true @@ -4829,6 +4923,11 @@ snapshots: dependencies: browserslist: 4.28.1 + cross-env@10.1.0: + dependencies: + '@epic-web/invariant': 1.0.0 + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/tests/cache/unit/layers/fetch-layer.test.ts b/tests/cache/unit/layers/fetch-layer.test.ts new file mode 100644 index 0000000..42d03f1 --- /dev/null +++ b/tests/cache/unit/layers/fetch-layer.test.ts @@ -0,0 +1,536 @@ +import { type Entry } from "@nimpl/cache/src/types"; +import { FetchLayer } from "@nimpl/cache/src/layers/fetch-layer"; +import { CacheError } from "@nimpl/cache/src/lib/error"; + +const createMockStream = (value: string = "test-data") => { + return new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from(value)); + controller.close(); + }, + }); +}; + +const createMockMetadata = (overrides: Partial = {}) => { + const now = performance.timeOrigin + performance.now(); + return { + tags: [], + timestamp: now, + stale: 0, + expire: 10, + revalidate: 5, + ...overrides, + }; +}; + +describe("FetchLayer", () => { + let layer: FetchLayer; + let mockFetch: jest.Mock; + const mockLogger = jest.fn(); + const baseUrl = "http://localhost:3000"; + + beforeEach(() => { + mockFetch = jest.fn(); + layer = new FetchLayer({ baseUrl, fetch: mockFetch }, mockLogger); + jest.clearAllMocks(); + }); + + describe("constructor", () => { + it("should create layer with baseUrl", () => { + const customLayer = new FetchLayer({ baseUrl: "http://example.com" }); + expect(customLayer).toBeInstanceOf(FetchLayer); + }); + + it("should remove trailing slash from baseUrl", () => { + const customLayer = new FetchLayer({ baseUrl: "http://example.com/" }); + expect(customLayer["baseUrl"]).toBe("http://example.com"); + }); + + it("should use custom fetch function", () => { + const customFetch = jest.fn(); + const customLayer = new FetchLayer({ baseUrl, fetch: customFetch }); + expect(customLayer["fetchFn"]).toBe(customFetch); + }); + + it("should use global fetch when not provided", () => { + const customLayer = new FetchLayer({ baseUrl }); + expect(customLayer["fetchFn"]).toBe(globalThis.fetch); + }); + }); + + describe("checkIsReady", () => { + it("should return true when server responds with ok", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + const result = await layer.checkIsReady(); + expect(result).toBe(true); + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/readiness`); + }); + + it("should return false when server responds with error", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + }); + + const result = await layer.checkIsReady(); + expect(result).toBe(false); + }); + }); + + describe("get", () => { + it("should return undefined for non-existent key", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers(), + }); + + const result = await layer.get("non-existent"); + expect(result).toBeUndefined(); + }); + + it("should return entry for valid cache", async () => { + const metadata = createMockMetadata({ tags: ["tag1"] }); + const stream = createMockStream("test-content"); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: stream, + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + "content-length": "12", + }), + }); + + const result = await layer.get("test-key"); + + expect(result).toBeDefined(); + expect(result?.tags).toEqual(["tag1"]); + expect(result?.value).toBeInstanceOf(ReadableStream); + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/?key=test-key`); + }); + + it("should return undefined for expired entry", async () => { + const metadata = createMockMetadata({ + timestamp: performance.timeOrigin + performance.now() - 2000, + expire: 1, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + }), + }); + + const result = await layer.get("expired-key"); + expect(result).toBeUndefined(); + }); + + it("should return undefined when metadata header is missing", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers(), + }); + + const result = await layer.get("test-key"); + expect(result).toBeUndefined(); + }); + }); + + describe("getEntry", () => { + it("should return null for expired entry", async () => { + const metadata = createMockMetadata({ + timestamp: performance.timeOrigin + performance.now() - 2000, + expire: 1, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + }), + }); + + const result = await layer.getEntry("expired-key"); + expect(result).toBeNull(); + }); + + it("should return entry with status and size", async () => { + const metadata = createMockMetadata({ tags: ["tag1"] }); + const stream = createMockStream("test"); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: stream, + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + "content-length": "4", + }), + }); + + const result = await layer.getEntry("test-key"); + + expect(result).toBeDefined(); + expect(result?.status).toBe("valid"); + expect(result?.size).toBe(4); + expect(result?.entry.tags).toEqual(["tag1"]); + }); + + it("should return revalidate status when entry needs revalidation", async () => { + const metadata = createMockMetadata({ + timestamp: performance.timeOrigin + performance.now() - 600, + expire: 10, + revalidate: 0.5, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + "content-length": "9", + }), + }); + + const result = await layer.getEntry("revalidate-key"); + expect(result?.status).toBe("revalidate"); + }); + + it("should handle concurrent gets for same key", async () => { + const metadata = createMockMetadata(); + const stream1 = createMockStream(); + const stream2 = createMockStream(); + + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: stream1, + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + "content-length": "9", + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: stream2, + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + "content-length": "9", + }), + }); + + const [result1, result2] = await Promise.all([layer.getEntry("test-key"), layer.getEntry("test-key")]); + + expect(result1).toBeDefined(); + expect(result2).toBeDefined(); + }); + + it("should return undefined when response is not ok", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + headers: new Headers(), + }); + + const result = await layer.getEntry("non-existent"); + expect(result).toBeUndefined(); + }); + + it("should use default size of 1 when content-length is missing", async () => { + const metadata = createMockMetadata(); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + }), + }); + + const result = await layer.getEntry("test-key"); + expect(result?.size).toBe(1); + }); + }); + + describe("set", () => { + it("should store entry via POST request", async () => { + const now = performance.timeOrigin + performance.now(); + const entry: Entry = { + tags: ["tag1"], + timestamp: now, + stale: 0, + expire: 10, + revalidate: 5, + value: createMockStream("test-content"), + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.set("test-key", entry); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/?key=test-key`, + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/octet-stream", + "X-Cache-Metadata": expect.stringContaining('"tags":["tag1"]'), + }), + }), + ); + }); + + it("should handle promise entry", async () => { + const now = performance.timeOrigin + performance.now(); + const entry: Entry = { + tags: [], + timestamp: now, + stale: 0, + expire: 10, + revalidate: 5, + value: createMockStream(), + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.set("test-key", Promise.resolve(entry)); + expect(mockFetch).toHaveBeenCalled(); + }); + + it("should throw CacheError when request fails", async () => { + const now = performance.timeOrigin + performance.now(); + const entry: Entry = { + tags: [], + timestamp: now, + stale: 0, + expire: 10, + revalidate: 5, + value: createMockStream(), + }; + + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await expect(layer.set("test-key", entry)).rejects.toThrow(CacheError); + }); + }); + + describe("delete", () => { + it("should send DELETE request", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.delete("test-key"); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/?key=test-key`, { + method: "DELETE", + }); + }); + + it("should handle delete errors gracefully", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 404, + }); + + await expect(layer.delete("non-existent")).resolves.not.toThrow(); + }); + }); + + describe("updateTags", () => { + it("should send PUT request with tags and durations", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.updateTags(["tag1", "tag2"], { expire: 20 }); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/`, + expect.objectContaining({ + method: "PUT", + body: expect.stringContaining('"tags":["tag1","tag2"]'), + }), + ); + }); + + it("should log error when request fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + await layer.updateTags(["tag1"]); + + expect(mockLogger).toHaveBeenCalledWith( + expect.objectContaining({ + type: "UPDATE_TAGS", + status: "ERROR", + source: "FETCH", + key: "tags", + }), + ); + }); + + it("should work without durations", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.updateTags(["tag1"]); + + expect(mockFetch).toHaveBeenCalledWith( + `${baseUrl}/`, + expect.objectContaining({ + method: "PUT", + body: expect.stringContaining('"tags":["tag1"]'), + }), + ); + }); + }); + + describe("keys", () => { + it("should return empty array when no keys exist", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue([]), + }); + + const keys = await layer.keys(); + expect(keys).toEqual([]); + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/keys`); + }); + + it("should return all cache keys", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(["key1", "key2"]), + }); + + const keys = await layer.keys(); + expect(keys).toEqual(["key1", "key2"]); + }); + + it("should handle concurrent keys calls", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue(["test-key"]), + }); + + const [keys1, keys2] = await Promise.all([layer.keys(), layer.keys()]); + + expect(keys1).toEqual(keys2); + expect(keys1).toContain("test-key"); + }); + + it("should return empty array and log error when request fails", async () => { + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: "Internal Server Error", + }); + + const keys = await layer.keys(); + + expect(keys).toEqual([]); + expect(mockLogger).toHaveBeenCalledWith( + expect.objectContaining({ + type: "GET", + status: "ERROR", + source: "FETCH", + key: "keys", + }), + ); + }); + }); + + describe("error handling", () => { + it("should handle fetch errors gracefully", async () => { + mockFetch.mockRejectedValue(new Error("Network error")); + + await expect(layer.get("test-key")).rejects.toThrow(Error); + }); + + it("should handle invalid JSON in metadata", async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers({ + "x-cache-metadata": "invalid-json", + }), + }); + + await expect(layer.getEntry("test-key")).rejects.toThrow(); + }); + }); + + describe("URL encoding", () => { + it("should properly encode special characters in keys", async () => { + const metadata = createMockMetadata(); + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + body: createMockStream(), + headers: new Headers({ + "x-cache-metadata": JSON.stringify(metadata), + "content-length": "9", + }), + }); + + await layer.getEntry("test/key:with:special"); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/?key=test%2Fkey%3Awith%3Aspecial`); + }); + + it("should properly encode keys in set requests", async () => { + const entry: Entry = { + ...createMockMetadata(), + value: createMockStream(), + }; + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + }); + + await layer.set("test/key:with:special", entry); + + expect(mockFetch).toHaveBeenCalledWith(`${baseUrl}/?key=test%2Fkey%3Awith%3Aspecial`, expect.anything()); + }); + }); +});