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", () => {