diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..42fff14 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +node_modules +npm-debug.log +.git +.gitignore +README.md +.env +.nyc_output +coverage +.nyc_output +.coverage +.vscode +.idea +*.log +test/ +examples/ +scripts/ +.github/ \ No newline at end of file diff --git a/.github/workflows/release-docker.yaml b/.github/workflows/release-docker.yaml new file mode 100644 index 0000000..65db076 --- /dev/null +++ b/.github/workflows/release-docker.yaml @@ -0,0 +1,59 @@ +# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images#publishing-images-to-github-packages +name: Create and publish a Docker image + +on: + release: + types: [published] + +# Defines two custom environment variables for the workflow. These are used for the Container registry domain, and a name for the Docker image that this workflow builds. +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu. +jobs: + build-and-push-image: + runs-on: ubuntu-latest + # Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job. + permissions: + contents: read + packages: write + attestations: write + id-token: write + # + steps: + - name: Checkout repository + uses: actions/checkout@v4 + # Uses the `docker/login-action` action to log in to the Container registry registry using the account and password that will publish the packages. Once published, the packages are scoped to the account defined here. + - name: Log in to the Container registry + uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a subsequent step. The `images` value provides the base name for the tags and labels. + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If the build succeeds, it pushes the image to GitHub Packages. + # It uses the `context` parameter to define the build's context as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)" in the README of the `docker/build-push-action` repository. + # It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step. + - name: Build and push Docker image + id: push + uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4 + with: + context: . + push: true + file: ./Dockerfile + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + # This step generates an artifact attestation for the image, which is an unforgeable statement about where and how it was built. It increases supply chain security for people who consume the image. For more information, see "[AUTOTITLE](/actions/security-guides/using-artifact-attestations-to-establish-provenance-for-builds)." + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v1 + with: + subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME}} + subject-digest: ${{ steps.push.outputs.digest }} + push-to-registry: true diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4b34dbd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copy package files first for better layer caching +COPY package*.json ./ +COPY tsconfig*.json ./ + +# Install dependencies +RUN npm ci --ignore-scripts && npm cache clean --force + +# Copy source code +COPY src/ ./src/ + +# Build the server directly using TypeScript compiler +RUN npx tsc -p tsconfig.server.json + +# Production stage +FROM node:22-alpine AS production + +# Install curl for health checks +RUN apk add --no-cache curl + +# Create app directory +WORKDIR /app + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 -G nodejs + +# Create cache directory with proper permissions +RUN mkdir -p /app/cache && \ + chown -R nodejs:nodejs /app + +# Copy built application from builder stage +COPY --from=builder --chown=nodejs:nodejs /app/dist ./dist +COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules +COPY --from=builder --chown=nodejs:nodejs /app/package*.json ./ + +# Switch to non-root user +USER nodejs + +# Expose port +EXPOSE 3000 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:3000/health || exit 1 + +# Environment variables +ENV NODE_ENV=production +ENV PORT=3000 +ENV CACHE_BASE_PATH=/app/cache + +# Start the application +CMD ["node", "dist/start-server-standalone.js"] diff --git a/README.md b/README.md index 7873b7d..edbc8b3 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ This can significantly boost your E2E test performance. - [Cleanup (by prefix)](#cleanup-by-prefix) - [Typed cache](#typed-cache) - [Configuration](#configuration) +- [Docker](#docker) - [API](#api) - [`globalCache.setup`](#globalcachesetup) - [`globalCache.teardown`](#globalcacheteardown) @@ -458,6 +459,43 @@ globalCache.defineConfig({ [Available options](#globalcachedefineconfigconfig). +## Docker + +The global cache server can be run as a standalone Docker container, which is useful for distributed testing environments or when you need to share cache across multiple test runners. + +### Building the Docker Image + +Build the Docker image using the provided npm script: + +```bash +npm run docker:build +``` + +Or manually: + +```bash +docker build -t global-cache-server . +``` + +### Running the Container + +Run the container with persistent storage: + +```bash +npm run docker:run +``` + +Or manually: + +```bash +docker run -d \ + --name global-cache-server \ + -p 3000:3000 \ + -v global-cache-data:/app/cache \ + global-cache-server +``` + + ## API `globalCache` is a singleton used to manage cache values. Import it directly from the package: diff --git a/package.json b/package.json index d8e3a3f..c52c3af 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,10 @@ "files": [ "dist", "src", - "README.md" + "README.md", + "Dockerfile", + "docker-compose.yml", + ".dockerignore" ], "scripts": { "prepare": "git config core.hooksPath scripts/git-hooks", @@ -40,7 +43,9 @@ "pw:install": "PLAYWRIGHT_SKIP_BROWSER_GC=1 npx playwright install chromium", "toc": "md-magic --files README.md", "build": "./scripts/build.sh", - "release": "release-it" + "release": "release-it", + "docker:build": "docker build -t global-cache-server .", + "docker:run": "docker run -d --name global-cache-server -p 3000:3000 -v global-cache-data:/app/cache global-cache-server" }, "dependencies": { "debug": "^4.4.1", diff --git a/src/client/api.ts b/src/client/api.ts index 77eb06e..85e6564 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -54,4 +54,10 @@ export class StorageApi { const res = await this.http.post('/clear-session'); await throwIfHttpError(res, 'Failed to clear session:'); } + + async healthCheck() { + const res = await this.http.get('/health'); + await throwIfHttpError(res, 'Health check failed:'); + return (await res.json()) as { status: string; timestamp: string; uptime: number }; + } } diff --git a/src/config.ts b/src/config.ts index c1c861f..f082eab 100644 --- a/src/config.ts +++ b/src/config.ts @@ -11,6 +11,8 @@ export type GlobalConfigInput = { basePath?: string; /* Disables global storage, all values will be computed each time */ disabled?: boolean; + /* Custom server URL to connect to an external global-cache server */ + serverUrl?: string; }; type GlobalConfigResolved = GlobalConfigInput & { diff --git a/src/server/index.ts b/src/server/index.ts index 9adba58..dfb7b8c 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -14,6 +14,7 @@ import { router as routeSet } from './routes/set'; import { router as routeGetStale } from './routes/get-stale'; import { router as routeGetStaleList } from './routes/get-stale-list'; import { router as routeClearSession } from './routes/clear-session'; +import { router as routeHealth } from './routes/health'; import { errorHandler } from './error'; import { GlobalStorageServerConfig, setConfig } from './config'; @@ -28,10 +29,23 @@ export class StorageServer { this.app.use('/', routeGetStale); this.app.use('/', routeGetStaleList); this.app.use('/', routeClearSession); - // todo: - // this.app.get('/', (req, res) => { - // res.send('Global Storage Server is running.'); - // }); + this.app.use('/', routeHealth); + // Basic info endpoint + this.app.get('/', (req, res) => { + res.json({ + name: 'Global Cache Server', + version: process.env.npm_package_version || '1.0.0', + status: 'running', + endpoints: [ + 'GET /health - Health check', + 'GET /cache/:key - Get cached value', + 'POST /cache/:key - Set cached value', + 'GET /cache-stale/:key - Get stale value', + 'GET /cache-stale-list/:prefix - Get stale values by prefix', + 'DELETE /session - Clear session', + ], + }); + }); // Must be after all other middleware and routes this.app.use(errorHandler); } @@ -49,7 +63,7 @@ export class StorageServer { debug('Starting server...'); setConfig(this.app, config); await new Promise((resolve, reject) => { - this.server = this.app.listen(config.port || 0); + this.server = this.app.listen(config.port || 0, '0.0.0.0'); this.server.once('listening', resolve); this.server.once('error', reject); }); diff --git a/src/server/routes/health.ts b/src/server/routes/health.ts new file mode 100644 index 0000000..9a88095 --- /dev/null +++ b/src/server/routes/health.ts @@ -0,0 +1,11 @@ +import { Router } from 'express'; + +export const router = Router(); + +router.get('/health', (req, res) => { + res.status(200).json({ + status: 'healthy', + timestamp: new Date().toISOString(), + uptime: process.uptime(), + }); +}); diff --git a/src/setup.ts b/src/setup.ts index dcddf1a..172c20f 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,6 +1,23 @@ import { debug } from './shared/debug'; import { globalConfig } from './config'; import { storageServer } from './server'; +import { StorageApi } from './client/api'; + +async function healthCheckExternalServer(serverUrl: string) { + try { + debug('Performing health check on external server...'); + const healthStatus = await new StorageApi(serverUrl).healthCheck(); + debug( + `External server is healthy: ${healthStatus.status} (uptime: ${Math.round(healthStatus.uptime)}s)`, + ); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + debug(`External server health check failed: ${errorMessage}`); + throw new Error( + `External global-cache server at ${serverUrl} is not accessible: ${errorMessage}`, + ); + } +} export default async function globalSetup() { if (globalConfig.disabled) { @@ -8,6 +25,14 @@ export default async function globalSetup() { return; } + // If serverUrl is already configured, don't start a local server + if (globalConfig.serverUrl) { + debug(`Using external global-cache server: ${globalConfig.serverUrl}`); + await healthCheckExternalServer(globalConfig.serverUrl); + return; + } + + // Start local server only if no external serverUrl is provided await storageServer.start({ basePath: globalConfig.basePath, }); diff --git a/src/start-server-standalone.ts b/src/start-server-standalone.ts new file mode 100644 index 0000000..9c61ff8 --- /dev/null +++ b/src/start-server-standalone.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env node + +/** + * Standalone server entry point for Docker deployments. + * This allows running the global-cache server as a dedicated service. + */ + +import { StorageServer } from './server'; + +interface ServerConfig { + port?: number; + basePath?: string; + host?: string; +} + +const { log, error: consoleError } = console; + +function getServerConfig(): ServerConfig { + return { + port: parseInt(process.env.PORT || '3000', 10), + basePath: process.env.CACHE_BASE_PATH || '/app/cache', + host: process.env.HOST || '0.0.0.0', + }; +} + +function setupGracefulShutdown(server: StorageServer) { + const shutdown = async (signal: string) => { + log(`Received ${signal}, shutting down gracefully...`); + try { + await server.stop(); + process.exit(0); + } catch (error) { + consoleError('Error during shutdown:', error); + process.exit(1); + } + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); +} + +function logServerStarted(config: ServerConfig, actualPort: number) { + log(`✅ Global Cache Server started successfully`); + log(`🌐 Server URL: http://${config.host}:${actualPort}`); + log(`📁 Cache Path: ${config.basePath}`); + log(`🚀 Ready to accept connections`); +} + +async function startServer() { + const config = getServerConfig(); + + log('Starting Global Cache Server...'); + log(`Configuration:`, config); + + const server = new StorageServer(); + setupGracefulShutdown(server); + + try { + await server.start({ + port: config.port, + basePath: config.basePath, + }); + + logServerStarted(config, server.port); + } catch (error) { + consoleError('❌ Failed to start server:', error); + process.exit(1); + } +} + +startServer().catch((error) => { + consoleError('Fatal error:', error); + process.exit(1); +}); diff --git a/tsconfig.json b/tsconfig.json index abba7a7..815b4c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,12 @@ "useUnknownInCatchVariables": false, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "noEmit": true + "noEmit": true, + "baseUrl": ".", + "paths": { + "@vitalets/global-cache": ["./src/index.ts"], + "@vitalets/global-cache/server": ["./src/server/index.ts"] + } }, "include": ["**/*.ts"], "exclude": ["dist"] diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..66decd5 --- /dev/null +++ b/tsconfig.server.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "noEmit": false, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/server/**/*", "src/shared/**/*", "src/start-server-standalone.ts"] +}