diff --git a/.dockerignore b/.dockerignore index f84fa0c..a2fe839 100644 --- a/.dockerignore +++ b/.dockerignore @@ -16,6 +16,7 @@ LICENSE test-screenshots/ comparison/ .lighthouse/ +lighthouse/ # Build output dist/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1ba51aa..f63286c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -9,6 +9,9 @@ jobs: tests: name: "Unit and E2E Tests" runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write steps: - name: Checkout code uses: actions/checkout@v4 @@ -28,10 +31,16 @@ jobs: run: docker compose up -d - name: Wait for container to be ready + id: container-startup run: | + START_TIME=$(date +%s%3N) for i in {1..30}; do if curl -f http://localhost:3000/v1/health > /dev/null 2>&1; then - echo "Container is ready!" + END_TIME=$(date +%s%3N) + STARTUP_TIME_MS=$((END_TIME - START_TIME)) + STARTUP_TIME=$(echo "scale=2; $STARTUP_TIME_MS / 1000" | bc) + echo "startup_time=$STARTUP_TIME" >> $GITHUB_OUTPUT + echo "Container is ready in ${STARTUP_TIME}s!" exit 0 fi echo "Waiting for container... ($i/30)" @@ -42,6 +51,57 @@ jobs: docker compose logs exit 1 + - name: Collect Docker stats + if: github.event_name == 'pull_request' + continue-on-error: true + id: docker-stats + run: | + # Get image size + IMAGE_SIZE=$(docker images appwrite/browser:local --format "{{.Size}}") + + # Get container stats + CONTAINER_ID=$(docker compose ps -q appwrite-browser) + MEMORY_USAGE=$(docker stats $CONTAINER_ID --no-stream --format "{{.MemUsage}}" | cut -d'/' -f1 | xargs) + + # Quick screenshot benchmark (3 runs, average) + TOTAL=0 + for i in {1..3}; do + START=$(date +%s%3N) + curl -s -X POST http://localhost:3000/v1/screenshots \ + -H "Content-Type: application/json" \ + -d '{"url":"https://appwrite.io"}' \ + -o /dev/null + END=$(date +%s%3N) + DURATION=$((END - START)) + TOTAL=$((TOTAL + DURATION)) + done + SCREENSHOT_AVG_MS=$((TOTAL / 3)) + SCREENSHOT_AVG=$(echo "scale=2; $SCREENSHOT_AVG_MS / 1000" | bc) + + # Store in GitHub output + echo "image_size=$IMAGE_SIZE" >> $GITHUB_OUTPUT + echo "memory_usage=$MEMORY_USAGE" >> $GITHUB_OUTPUT + echo "screenshot_time=$SCREENSHOT_AVG" >> $GITHUB_OUTPUT + + - name: Comment PR with stats + if: github.event_name == 'pull_request' && steps.docker-stats.outcome == 'success' + continue-on-error: true + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: docker-image-stats + skip_unchanged: true + message: | + ## Docker Image Stats + + | Metric | Value | + |--------|-------| + | Image Size | ${{ steps.docker-stats.outputs.image_size }} | + | Memory Usage | ${{ steps.docker-stats.outputs.memory_usage }} | + | Cold Start Time | ${{ steps.container-startup.outputs.startup_time }}s | + | Screenshot Time | ${{ steps.docker-stats.outputs.screenshot_time }}s | + + Screenshot benchmark: Average of 3 runs on https://appwrite.io + - name: Run e2e tests run: bun test:e2e diff --git a/Dockerfile b/Dockerfile index 424f370..6f1f518 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,41 +1,47 @@ -FROM oven/bun:1.3.2-alpine AS base +# debian so we can re-use! +FROM oven/bun:1.3.2-debian AS base WORKDIR /app COPY package.json bun.lock ./ +COPY src/utils/clean-modules.ts ./src/utils/clean-modules.ts RUN bun install --frozen-lockfile --production && \ + bun run ./src/utils/clean-modules.ts && \ rm -rf ~/.bun/install/cache /tmp/* -FROM oven/bun:1.3.2-alpine AS final +# well-known OSS docker image +FROM chromedp/headless-shell:143.0.7445.3 AS final -RUN apk upgrade --no-cache --available && \ - apk add --no-cache \ - chromium \ - ttf-freefont \ - font-noto-emoji \ - tini && \ - apk add --no-cache font-wqy-zenhei --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community && \ - # remove unnecessary chromium files to save space - rm -rf /usr/lib/chromium/chrome_200_percent.pak \ - /usr/lib/chromium/chrome_100_percent.pak \ - /usr/lib/chromium/xdg-mime \ - /usr/lib/chromium/xdg-settings \ - /usr/lib/chromium/chrome-sandbox +# install fonts only +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + tini \ + ca-certificates \ + fonts-liberation \ + fonts-noto-color-emoji \ + fonts-wqy-zenhei && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/cache/apt/archives/* -RUN addgroup -S chrome && adduser -S -G chrome chrome +# copy bun from debian base above! +COPY --from=base /usr/local/bin/bun /usr/local/bin/bun + +# Add chrome user +RUN groupadd -r chrome && useradd -r -g chrome chrome ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ - PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium-browser \ + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/headless-shell/headless-shell \ NODE_ENV=production WORKDIR /app -COPY package.json ./ -COPY --from=base /app/node_modules ./node_modules -COPY src/ ./src/ +COPY --chown=chrome:chrome src/ ./src/ +COPY --chown=chrome:chrome package.json ./ +COPY --chown=chrome:chrome --from=base /app/node_modules ./node_modules -RUN chown -R chrome:chrome /app +# for e2e tests and `reports` endpoint! +RUN install -d -o chrome -g chrome lighthouse USER chrome diff --git a/bun.lock b/bun.lock index 724854e..ab49c02 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "appwrite-browser", diff --git a/src/schemas/screenshot.schema.ts b/src/schemas/screenshot.schema.ts index ca50351..3e5b156 100644 --- a/src/schemas/screenshot.schema.ts +++ b/src/schemas/screenshot.schema.ts @@ -4,7 +4,7 @@ export const screenshotSchema = z.object({ url: z.string().url(), theme: z.enum(["light", "dark"]).default("light"), headers: z.record(z.string(), z.any()).optional(), - sleep: z.number().min(0).max(60000).default(3000), + sleep: z.number().min(0).max(60000).default(0), // Viewport options viewport: z .object({ diff --git a/src/server.ts b/src/server.ts index 6abb755..11c0872 100644 --- a/src/server.ts +++ b/src/server.ts @@ -5,33 +5,17 @@ import { handleScreenshotsRequest, handleTestRequest, } from "./routes"; +import { Router } from "./utils/router"; + +const router = new Router(); +router.add("POST", "/v1/screenshots", handleScreenshotsRequest); +router.add("POST", "/v1/reports", handleReportsRequest); +router.add("GET", "/v1/health", handleHealthRequest); +router.add("GET", "/v1/test", handleTestRequest); const server = Bun.serve({ port, - async fetch(req) { - const url = new URL(req.url); - const path = url.pathname; - - // Route matching - if (path === "/v1/screenshots" && req.method === "POST") { - return await handleScreenshotsRequest(req); - } - - if (path === "/v1/reports" && req.method === "POST") { - return await handleReportsRequest(req); - } - - if (path === "/v1/health" && req.method === "GET") { - return await handleHealthRequest(req); - } - - if (path === "/v1/test" && req.method === "GET") { - return await handleTestRequest(req); - } - - // 404 Not Found - return new Response("Not Found", { status: 404 }); - }, + fetch: (request) => router.handle(request), }); console.log(`Server running on http://0.0.0.0:${server.port}`); diff --git a/src/utils/clean-modules.ts b/src/utils/clean-modules.ts new file mode 100644 index 0000000..76b14d5 --- /dev/null +++ b/src/utils/clean-modules.ts @@ -0,0 +1,207 @@ +import { readdirSync, unlinkSync } from "node:fs"; +import { join } from "node:path"; +import { $ } from "bun"; + +const NODE_MODULES = "/app/node_modules"; + +async function getDirSize(path: string): Promise { + const result = await $`du -sm ${path}`.quiet(); + return Number.parseInt(result.text().split("\t")[0]); +} + +async function deleteFiles( + pattern: string, + description: string, +): Promise { + console.log(`${description}...`); + await $`find ${NODE_MODULES} -name ${pattern} -delete 2>/dev/null || true`.quiet(); +} + +async function deleteDirectories( + dirName: string, + description: string, +): Promise { + console.log(`${description}...`); + await $`find ${NODE_MODULES} -depth -type d -name ${dirName} -exec rm -rf {} + 2>/dev/null || true`.quiet(); +} + +async function deletePath(path: string): Promise { + try { + await $`test -e ${path}`.quiet(); + await $`rm -rf ${path}`.quiet(); + } catch { + // ignore + } +} + +async function removeDocumentationFiles(): Promise { + console.log("๐Ÿ“ Removing documentation files..."); + await deleteFiles("*.md", " - Markdown files"); + await deleteFiles("*.d.ts", " - TypeScript declarations"); + await deleteFiles("*.map", " - Source maps"); + await deleteFiles("LICENSE*", " - LICENSE files"); + await deleteFiles("README*", " - README files"); + await deleteFiles("CHANGELOG*", " - CHANGELOG files"); + await deleteFiles("AUTHORS*", " - AUTHORS files"); + await deleteFiles("CONTRIBUTORS*", " - CONTRIBUTORS files"); + await deleteFiles("NOTICE*", " - NOTICE files"); + await deleteFiles("HISTORY*", " - HISTORY files"); + await deleteFiles("*.txt", " - Text files"); +} + +async function removeTypeScriptSources(): Promise { + console.log("๐Ÿ”ง Removing TypeScript sources..."); + await deleteFiles("*.ts", " - TypeScript files"); + await deleteFiles("*.jsx", " - JavaScript JSX files"); + await deleteFiles("*.tsx", " - TypeScript JSX files"); + await deleteFiles("tsconfig*.json", " - TypeScript configs"); + await deleteFiles("*.tsbuildinfo", " - TypeScript build info"); +} + +async function removeTestFiles(): Promise { + console.log("๐Ÿงช Removing test files..."); + await deleteFiles("*.test.js", " - JavaScript tests"); + await deleteFiles("*.test.ts", " - TypeScript tests"); + await deleteFiles("*.spec.js", " - JavaScript specs"); + await deleteFiles("*.spec.ts", " - TypeScript specs"); + await deleteDirectories("test", " - test/ directories"); + await deleteDirectories("tests", " - tests/ directories"); + await deleteDirectories("__tests__", " - __tests__/ directories"); + await deleteDirectories("__mocks__", " - __mocks__/ directories"); + await deleteDirectories("__fixtures__", " - __fixtures__/ directories"); + await deleteDirectories("fixtures", " - fixtures/ directories"); + await deleteDirectories("coverage", " - coverage/ directories"); +} + +async function removeDevelopmentDirectories(): Promise { + console.log("๐Ÿ—‚๏ธ Removing development directories..."); + await deleteDirectories(".github", " - .github/ directories"); + await deleteDirectories("docs", " - docs/ directories"); + await deleteDirectories("examples", " - examples/ directories"); + await deleteDirectories("benchmark", " - benchmark/ directories"); + await deleteDirectories("samples", " - samples/ directories"); +} + +async function removeDevelopmentFiles(): Promise { + console.log("โš™๏ธ Removing development config files..."); + await deleteFiles(".eslintrc*", " - ESLint configs"); + await deleteFiles(".prettierrc*", " - Prettier configs"); + await deleteFiles(".editorconfig", " - EditorConfig files"); + await deleteFiles("jest.config.*", " - Jest configs"); + await deleteFiles("vitest.config.*", " - Vitest configs"); + await deleteFiles(".npmignore", " - NPM ignore files"); + await deleteFiles(".gitignore", " - Git ignore files"); + await deleteFiles("bun.lock", " - Bun lock files"); + await deleteFiles("yarn.lock", " - Yarn lock files"); + await deleteFiles("package-lock.json", " - NPM lock files"); + await deleteFiles("pnpm-lock.yaml", " - PNPM lock files"); +} + +async function removeScriptsAndDeclarations(): Promise { + console.log("๐Ÿ“œ Removing scripts and declarations..."); + await deleteFiles("*.sh", " - Shell scripts"); + await deleteFiles("*.ps1", " - PowerShell scripts"); + await deleteFiles("*.d.cts", " - CommonJS TypeScript declarations"); + await deleteFiles("*.d.mts", " - ES Module TypeScript declarations"); +} + +async function removeTraceEngineLocales(): Promise { + console.log("๐ŸŒ Removing non-English trace_engine locales..."); + const traceEngineLocalesPath = `${NODE_MODULES}/@paulirish/trace_engine/locales`; + try { + const traceLocales = readdirSync(traceEngineLocalesPath); + for (const locale of traceLocales) { + if (locale !== "en-US.json") { + unlinkSync(join(traceEngineLocalesPath, locale)); + } + } + console.log( + ` - Removed ${traceLocales.length - 1} trace_engine locale files`, + ); + } catch { + console.log(" - trace_engine locales not found (skipped)"); + } +} + +async function removeLighthouseLocales(): Promise { + console.log("๐ŸŒ Replacing non-English Lighthouse locales with stubs..."); + const localesPath = `${NODE_MODULES}/lighthouse/shared/localization/locales`; + try { + const locales = readdirSync(localesPath); + const stubContent = "{}"; + for (const locale of locales) { + if (locale !== "en-US.json") { + const filePath = join(localesPath, locale); + unlinkSync(filePath); + await Bun.write(filePath, stubContent); + } + } + } catch { + console.log(" - Lighthouse locales not found (skipped)"); + } +} + +async function removeAxeCoreLocales(): Promise { + console.log("๐ŸŒ Replacing non-English axe-core locales with stubs..."); + const localesPath = `${NODE_MODULES}/axe-core/locales`; + try { + const locales = readdirSync(localesPath); + const stubContent = "{}"; + for (const locale of locales) { + if (locale !== "en.json" && !locale.startsWith("_")) { + const filePath = join(localesPath, locale); + unlinkSync(filePath); + await Bun.write(filePath, stubContent); + } + } + } catch { + console.log(" - axe-core locales not found (skipped)"); + } +} + +async function removeUnnecessaryFiles(): Promise { + console.log("๐ŸŽญ Removing unnecessary files..."); + await deletePath(`${NODE_MODULES}/playwright-core/lib/vite`); + await deletePath(`${NODE_MODULES}/puppeteer-core/src`); + await deletePath(`${NODE_MODULES}/zod/src`); + await deletePath(`${NODE_MODULES}/third-party-web/dist/domain-map.csv`); + await deletePath( + `${NODE_MODULES}/puppeteer-core/node_modules/devtools-protocol`, + ); + await deletePath(`${NODE_MODULES}/@sentry`); + await deletePath(`${NODE_MODULES}/@opentelemetry`); + await deletePath(`${NODE_MODULES}/axe-core/axe.js`); + await deletePath(`${NODE_MODULES}/lighthouse/cli`); + await deletePath(`${NODE_MODULES}/lighthouse/build-tracker.config.js`); + await deletePath(`${NODE_MODULES}/lighthouse/commitlint.config.js`); + await deletePath(`${NODE_MODULES}/lighthouse/eslint.config.mjs`); +} + +async function cleanModules(): Promise { + console.log("๐Ÿงน Starting node_modules cleanup..."); + const startSize = await getDirSize(NODE_MODULES); + + await Promise.all([ + removeDocumentationFiles(), + removeTypeScriptSources(), + removeTestFiles(), + removeDevelopmentDirectories(), + removeDevelopmentFiles(), + removeScriptsAndDeclarations(), + removeTraceEngineLocales(), + removeLighthouseLocales(), + removeAxeCoreLocales(), + removeUnnecessaryFiles(), + ]); + + const endSize = await getDirSize(NODE_MODULES); + const saved = startSize - endSize; + + console.log("\nโœ… Cleanup complete!"); + console.log(`๐Ÿ“Š ${startSize}MB โ†’ ${endSize}MB (Saved: ${saved}MB)`); +} + +cleanModules().catch((error) => { + console.error("โŒ Cleanup failed:", error); + process.exit(1); +}); diff --git a/src/utils/router.ts b/src/utils/router.ts new file mode 100644 index 0000000..1825a32 --- /dev/null +++ b/src/utils/router.ts @@ -0,0 +1,40 @@ +type HTTPMethod = + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE" + | "OPTIONS" + | "HEAD"; + +type RouteHandler = (req: Request) => Promise; + +type Route = { + method: HTTPMethod; + pattern: RegExp; + handler: RouteHandler; +}; + +export class Router { + private routes: Route[] = []; + + add(method: HTTPMethod, path: string, handler: RouteHandler): void { + this.routes.push({ + method, + pattern: new RegExp(`^${path}$`), + handler, + }); + } + + async handle(req: Request): Promise { + const url = new URL(req.url); + + for (const route of this.routes) { + if (route.method === req.method && route.pattern.test(url.pathname)) { + return await route.handler(req); + } + } + + return new Response("Not Found", { status: 404 }); + } +} diff --git a/tests/unit/screenshot.schema.test.ts b/tests/unit/screenshot.schema.test.ts index b6082ac..68a9901 100644 --- a/tests/unit/screenshot.schema.test.ts +++ b/tests/unit/screenshot.schema.test.ts @@ -23,7 +23,7 @@ describe("screenshotSchema", () => { expect(result.quality).toBe(90); expect(result.waitUntil).toBe("domcontentloaded"); expect(result.timeout).toBe(30000); - expect(result.sleep).toBe(3000); + expect(result.sleep).toBe(0); }); test("should validate custom viewport", () => {