Initial commit

This commit is contained in:
Samraaj Bath
2026-06-29 15:11:48 -07:00
commit 66dfdcc58d
404 changed files with 45970 additions and 0 deletions
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@cloner/api",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"start": "tsx src/server.ts",
"test": "node --import tsx --test test/*.test.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@cloner/core": "*",
"@cloner/db": "*",
"@cloner/storage": "*",
"@hono/node-server": "^1.13.7",
"@modelcontextprotocol/sdk": "^1.29.0",
"hono": "^4.6.14",
"zod": "^3.24.1"
},
"devDependencies": {
"@cloner/test-utils": "*",
"@types/node": "22.10.5",
"tsx": "4.22.4",
"typescript": "5.7.3"
}
}
+173
View File
@@ -0,0 +1,173 @@
import { Hono } from "hono";
import { z } from "zod";
import { RESPONSE_ALREADY_SENT } from "@hono/node-server/utils/response";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { IncomingMessage, ServerResponse } from "node:http";
import { normalizeCloneRequestOptions } from "@cloner/core";
import type { Backend } from "./backend.js";
import { createMcpServer } from "./mcp.js";
import { apiKeyAuth, rateLimit, type AuthConfig } from "./auth.js";
const OptionsSchema = z
.object({
mode: z.enum(["single", "multi"]).optional(),
styling: z.enum(["tailwind", "css"]).optional(),
framework: z.enum(["next", "vite"]).optional(),
verify: z.boolean().optional(),
asyncVerify: z.boolean().optional(),
maxRoutes: z.number().int().positive().optional(),
maxCollection: z.number().int().positive().optional(),
captureConcurrency: z.number().int().positive().optional(),
validationConcurrency: z.number().int().positive().optional(),
viewportConcurrency: z.number().int().positive().optional(),
// Deprecated compatibility aliases and dev-only escape hatches.
multiPage: z.boolean().optional(),
humanizeMode: z.enum(["tailwind", "css"]).optional(),
viewports: z.array(z.number().int().positive()).min(1).optional(),
interactions: z.boolean().optional(),
components: z.boolean().optional(),
motion: z.boolean().optional(),
noCache: z.boolean().optional(),
})
.strict();
const CloneRequest = z.object({
url: z.string().url(),
options: OptionsSchema.optional(),
});
export type AppDeps = {
backend: Backend;
/** absolute base URL used in MCP-returned references (binary/bundle URLs). */
baseUrl?: string;
/** mount the MCP Streamable-HTTP endpoint at /mcp (default true). */
mcp?: boolean;
/** require an API key on /v1/* and /mcp (omit = open). */
auth?: AuthConfig;
/** per-window request cap on /v1/* and /mcp (omit = unlimited). */
rateLimitPerMinute?: number;
/** SSRF guard run on submit (omit = no check — set in production). Throws to reject. */
assertUrl?: (url: string) => Promise<void>;
};
/** Build the Hono app over a Backend. The in-memory backend (M1) runs clones inline
* (POST → 200 + file map); the DB backend (M2) enqueues (POST → 202) and the worker
* fills the result (poll via GET). The HTTP surface is identical either way. */
export function createApp(deps: AppDeps): Hono {
const { backend } = deps;
const app = new Hono();
app.get("/healthz", (c) => c.json({ ok: true }));
// Protect the API + MCP surfaces (not /healthz). Auth before rate-limit so the
// limiter can key by API key.
if (deps.auth) {
const mw = apiKeyAuth(deps.auth);
app.use("/v1/*", mw);
app.use("/mcp", mw);
}
if (deps.rateLimitPerMinute && deps.rateLimitPerMinute > 0) {
const mw = rateLimit({ perMinute: deps.rateLimitPerMinute });
app.use("/v1/*", mw);
app.use("/mcp", mw);
}
app.post("/v1/clones", async (c) => {
const body = await c.req.json().catch(() => null);
const parsed = CloneRequest.safeParse(body);
if (!parsed.success) {
return c.json({ error: "invalid request", details: parsed.error.flatten() }, 400);
}
const { url, options } = parsed.data;
if (!/^https?:\/\//i.test(url)) {
return c.json({ error: "url must be http(s)" }, 400);
}
// SSRF guard (production): block private/link-local/metadata targets.
if (deps.assertUrl) {
try {
await deps.assertUrl(url);
} catch (e) {
return c.json({ error: "url not allowed", reason: String((e as Error).message ?? e) }, 400);
}
}
// Header alias for the per-request cache bypass.
const noCacheHeader = (c.req.header("cache-control") ?? "").toLowerCase().includes("no-cache");
const normalizedOptions = normalizeCloneRequestOptions(options ?? {});
const opts = noCacheHeader ? { ...normalizedOptions, noCache: true } : normalizedOptions;
try {
const out = await backend.submit(url, opts);
if (out.status === "queued") return c.json({ jobId: out.jobId, status: "queued" }, 202);
return c.json(out.result, 200);
} catch (e) {
return c.json({ status: "failed", error: String(e).slice(0, 500) }, 500);
}
});
app.get("/v1/clones", async (c) => {
return c.json({ clones: await backend.list() });
});
app.get("/v1/clones/:id", async (c) => {
const view = await backend.status(c.req.param("id"));
if (!view) return c.json({ error: "not found" }, 404);
return c.json(view, 200);
});
app.get("/v1/clones/:id/result", async (c) => {
const out = await backend.result(c.req.param("id"));
if (!out) return c.json({ error: "not found" }, 404);
if (!out.ready) return c.json({ jobId: c.req.param("id"), status: out.status, error: out.error }, 409);
return c.json(out.result, 200);
});
app.get("/v1/clones/:id/bundle", async (c) => {
const fmt = c.req.query("format") === "zip" ? "zip" : "tgz";
const b = await backend.bundle(c.req.param("id"), fmt);
if (!b) return c.json({ error: "not found or not ready" }, 404);
if (b.url) return c.redirect(b.url, 302); // S3: hand off to the presigned URL
c.header("content-type", fmt === "zip" ? "application/zip" : "application/gzip");
c.header("content-disposition", `attachment; filename="clone-${c.req.param("id")}.${fmt}"`);
c.header("content-length", String(b.bytes.length));
c.header("x-content-sha256", b.sha256);
return c.body(b.bytes);
});
app.get("/v1/clones/:id/files/:path{.+}", async (c) => {
const file = await backend.file(c.req.param("id"), c.req.param("path"));
if (!file) return c.json({ error: "file not found" }, 404);
c.header("content-type", file.contentType);
c.header("content-length", String(file.bytes.length));
return c.body(file.bytes);
});
app.delete("/v1/clones/:id", async (c) => {
const ok = await backend.remove(c.req.param("id"));
return c.json({ deleted: ok }, ok ? 200 : 404);
});
// MCP over Streamable-HTTP (stateless): a fresh server+transport per request.
// Requires the Node http req/res from @hono/node-server (not available under
// app.request — MCP is exercised in tests via the in-memory transport instead).
if (deps.mcp !== false) {
app.all("/mcp", async (c) => {
const env = c.env as { incoming?: IncomingMessage; outgoing?: ServerResponse };
if (!env?.incoming || !env?.outgoing) {
return c.json({ error: "MCP requires the Node HTTP server (run via @hono/node-server)" }, 501);
}
const server = createMcpServer(backend, { baseUrl: deps.baseUrl });
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
env.outgoing.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
const body = c.req.method === "POST" ? await c.req.json().catch(() => undefined) : undefined;
await transport.handleRequest(env.incoming, env.outgoing, body);
return RESPONSE_ALREADY_SENT;
});
}
return app;
}
+76
View File
@@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import type { MiddlewareHandler } from "hono";
export function hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
export type AuthConfig = {
/** sha256 hashes of accepted keys (from env). */
keyHashes: Set<string>;
/** optional async lookup (e.g. the DB apiKeys table) by key hash. */
lookup?: (keyHash: string) => Promise<boolean>;
};
function extractKey(authHeader: string | undefined, xApiKey: string | undefined): string | undefined {
if (xApiKey) return xApiKey;
if (authHeader) {
const m = /^Bearer\s+(.+)$/i.exec(authHeader);
return m ? m[1]! : authHeader;
}
return undefined;
}
/** Require a valid API key (Authorization: Bearer <key> or x-api-key). */
export function apiKeyAuth(cfg: AuthConfig): MiddlewareHandler {
return async (c, next) => {
const provided = extractKey(c.req.header("authorization"), c.req.header("x-api-key"));
if (!provided) return c.json({ error: "missing API key" }, 401);
const h = hashApiKey(provided);
if (cfg.keyHashes.has(h)) return next();
if (cfg.lookup && (await cfg.lookup(h))) return next();
return c.json({ error: "invalid API key" }, 401);
};
}
export type RateLimitConfig = {
perMinute: number;
/** key the limit by (default: API-key hash, else client IP). */
keyFn?: (c: Parameters<MiddlewareHandler>[0]) => string;
};
/** Fixed-window in-memory rate limiter. Sufficient for a single API instance; a
* shared store (Redis/PG) would be needed across replicas. Keyed by API key when
* present, else client IP. */
export function rateLimit(cfg: RateLimitConfig): MiddlewareHandler {
const windowMs = 60_000;
const buckets = new Map<string, { count: number; reset: number }>();
const keyFn =
cfg.keyFn ??
((c) => {
const provided = extractKey(c.req.header("authorization"), c.req.header("x-api-key"));
if (provided) return "k:" + hashApiKey(provided);
const xff = c.req.header("x-forwarded-for");
const ip = (xff ? xff.split(",")[0]!.trim() : undefined) ?? c.req.header("x-real-ip") ?? "anon";
return "ip:" + ip;
});
return async (c, next) => {
const now = Date.now();
const key = keyFn(c);
let b = buckets.get(key);
if (!b || now > b.reset) {
b = { count: 0, reset: now + windowMs };
buckets.set(key, b);
}
b.count++;
const remaining = Math.max(0, cfg.perMinute - b.count);
c.header("x-ratelimit-limit", String(cfg.perMinute));
c.header("x-ratelimit-remaining", String(remaining));
if (b.count > cfg.perMinute) {
c.header("retry-after", String(Math.ceil((b.reset - now) / 1000)));
return c.json({ error: "rate limit exceeded" }, 429);
}
return next();
};
}
+59
View File
@@ -0,0 +1,59 @@
import type { CloneOptions, RouteInfo } from "@cloner/core";
import type { RestCloneResult } from "./rest.js";
export type JobStatus = "queued" | "running" | "succeeded" | "failed" | "cached";
/** The cheap status/list view (no file contents). */
export type JobView = {
jobId: string;
url: string;
kind: "clone" | "clone_site";
status: JobStatus;
options: CloneOptions;
compilerVersion?: string;
timings?: unknown;
routes?: RouteInfo[];
capture?: { nodeCount: number; pollution: boolean; blocked: boolean };
verify?: unknown;
fileCount?: number;
totalBytes?: number;
bundleUrl?: string;
error?: string;
};
export type SubmitOutcome =
| { jobId: string; status: "succeeded" | "cached"; httpStatus: 200; result: RestCloneResult }
| { jobId: string; status: "queued"; httpStatus: 202 };
export type ResultOutcome =
| { ready: true; result: RestCloneResult }
| { ready: false; status: JobStatus; error?: string };
/** A file with a content/url accessor — the substrate for MCP list/read (so the
* filtering + size-budget logic lives once, regardless of backend). */
export type FileFacet = {
path: string;
kind: "text" | "binary";
bytes: number;
sha256: string;
content?: string; // text only
binaryUrl?: () => Promise<string>; // binary only
};
export type BundleFormat = "tgz" | "zip";
export type CloneBundle = { bytes: Buffer; sha256: string; format: BundleFormat; url?: string };
/** The HTTP routes talk to this; concrete backends are the in-memory sync runner
* (M1) and the DB+queue async backend (M2). */
export interface Backend {
submit(url: string, options: CloneOptions | undefined): Promise<SubmitOutcome>;
status(jobId: string): Promise<JobView | null>;
result(jobId: string): Promise<ResultOutcome | null>;
file(jobId: string, path: string): Promise<{ bytes: Buffer; contentType: string } | null>;
list(): Promise<JobView[]>;
remove(jobId: string): Promise<boolean>;
/** All files with content/url accessors (null if job not ready/found). */
facets(jobId: string): Promise<FileFacet[] | null>;
/** The whole app as one compressed archive (null if not ready/found). */
bundle(jobId: string, format?: BundleFormat): Promise<CloneBundle | null>;
}
+145
View File
@@ -0,0 +1,145 @@
import { cacheKey, COMPILER_VERSION, resolveCloneMode, type CloneOptions, type RouteInfo } from "@cloner/core";
import { repo, enqueueClone, type Db, type PgBoss } from "@cloner/db";
import { makeTarGz, makeZip, sha256hex, type ArtifactStore, type StoredFile } from "@cloner/storage";
import type { Backend, BundleFormat, CloneBundle, FileFacet, JobView, JobStatus, ResultOutcome, SubmitOutcome } from "../backend.js";
import { contentTypeFor, restResultFromStored } from "../rest.js";
/** The jsonb envelope persisted in clones.fileManifest: text files inline + binary
* keys (from the ArtifactStore) plus the reproduced-route map. */
export type StoredEnvelope = { files: StoredFile[]; routes?: RouteInfo[]; bundleKey?: string };
/** Async, DB+queue backend (M2): submit enqueues a job and returns 202; the worker
* processes it and writes the result. Reads come from Postgres + the ArtifactStore. */
export class DbBackend implements Backend {
constructor(private deps: { db: Db; boss: PgBoss; store: ArtifactStore }) {}
async submit(url: string, options: CloneOptions | undefined): Promise<SubmitOutcome> {
const key = cacheKey(url, options, COMPILER_VERSION);
const kind: "clone" | "clone_site" = resolveCloneMode(options) === "multi" ? "clone_site" : "clone";
if (!options?.noCache) {
const hit = await repo.cacheGetFresh(this.deps.db, key, COMPILER_VERSION);
if (hit?.jobId) {
const r = await this.result(hit.jobId);
if (r && r.ready) {
return { jobId: hit.jobId, status: "cached", httpStatus: 200, result: { ...r.result, status: "cached" } };
}
}
}
const job = await repo.createJob(this.deps.db, { kind, url, options: options ?? {}, status: "queued", cacheKey: key });
await enqueueClone(this.deps.boss, job.id);
return { jobId: job.id, status: "queued", httpStatus: 202 };
}
async status(jobId: string): Promise<JobView | null> {
const job = await repo.getJob(this.deps.db, jobId);
if (!job) return null;
const base: JobView = {
jobId: job.id,
url: job.url,
kind: job.kind as "clone" | "clone_site",
status: job.status as JobStatus,
options: job.options as CloneOptions,
compilerVersion: job.compilerVersion ?? undefined,
timings: job.timings ?? undefined,
error: job.error ?? undefined,
};
if (job.status === "succeeded") {
const clone = await repo.getClone(this.deps.db, jobId);
if (clone) {
const env = clone.fileManifest as StoredEnvelope;
let totalBytes = 0;
for (const f of env.files) totalBytes += f.bytes;
return { ...base, capture: clone.captureMeta as JobView["capture"], verify: clone.verify ?? undefined, routes: env.routes, fileCount: env.files.length, totalBytes };
}
}
return base;
}
async result(jobId: string): Promise<ResultOutcome | null> {
const job = await repo.getJob(this.deps.db, jobId);
if (!job) return null;
if (job.status !== "succeeded") return { ready: false, status: job.status as JobStatus, error: job.error ?? undefined };
const clone = await repo.getClone(this.deps.db, jobId);
if (!clone) return { ready: false, status: "running" };
const env = clone.fileManifest as StoredEnvelope;
const result = await restResultFromStored(jobId, {
url: job.url,
kind: job.kind as "clone" | "clone_site",
options: job.options as CloneOptions,
compilerVersion: job.compilerVersion ?? COMPILER_VERSION,
timings: (job.timings as { captureMs: number; generateMs: number }) ?? { captureMs: 0, generateMs: 0 },
capture: clone.captureMeta as { nodeCount: number; pollution: boolean; blocked: boolean },
routes: env.routes,
verify: clone.verify ?? undefined,
files: env.files,
binaryUrl: (p) => this.deps.store.binaryUrl(jobId, p),
});
return { ready: true, result };
}
async file(jobId: string, path: string): Promise<{ bytes: Buffer; contentType: string } | null> {
// Text lives inline in the manifest (not in blob storage); binaries come from
// the store. This keeps /files/* working in S3 mode where text isn't uploaded.
const env = await this.envelope(jobId);
const meta = env?.files.find((f) => f.path === path);
if (meta?.kind === "text") return { bytes: Buffer.from(meta.content, "utf8"), contentType: contentTypeFor(path) };
const got = await this.deps.store.getFile(jobId, path);
if (!got) return null;
return { bytes: got.bytes, contentType: contentTypeFor(path) };
}
async list(): Promise<JobView[]> {
const rows = await repo.listJobs(this.deps.db, 50);
return rows.map((job) => ({
jobId: job.id,
url: job.url,
kind: job.kind as "clone" | "clone_site",
status: job.status as JobStatus,
options: job.options as CloneOptions,
compilerVersion: job.compilerVersion ?? undefined,
timings: job.timings ?? undefined,
error: job.error ?? undefined,
}));
}
async remove(jobId: string): Promise<boolean> {
await this.deps.store.remove(jobId).catch(() => {});
return repo.deleteJob(this.deps.db, jobId);
}
private async envelope(jobId: string): Promise<StoredEnvelope | null> {
const clone = await repo.getClone(this.deps.db, jobId);
if (!clone) return null;
return clone.fileManifest as StoredEnvelope;
}
async facets(jobId: string): Promise<FileFacet[] | null> {
const env = await this.envelope(jobId);
if (!env) return null;
return env.files.map((f) =>
f.kind === "text"
? { path: f.path, kind: "text", bytes: f.bytes, sha256: f.sha256, content: f.content }
: { path: f.path, kind: "binary", bytes: f.bytes, sha256: f.sha256, binaryUrl: () => this.deps.store.binaryUrl(jobId, f.path) },
);
}
async bundle(jobId: string, format: BundleFormat = "tgz"): Promise<CloneBundle | null> {
const env = await this.envelope(jobId);
if (!env) return null;
const entries: Array<{ path: string; bytes: Buffer }> = [];
for (const f of env.files) {
if (f.kind === "text") {
entries.push({ path: f.path, bytes: Buffer.from(f.content, "utf8") });
} else {
const got = await this.deps.store.getFile(jobId, f.path);
if (got) entries.push({ path: f.path, bytes: got.bytes });
}
}
const bytes = format === "zip" ? makeZip(entries) : makeTarGz(entries);
// S3 store: upload + presign so the client downloads directly; local: served by the API.
const url = this.deps.store.uploadBundle ? await this.deps.store.uploadBundle(jobId, format, bytes) : undefined;
return { bytes, sha256: sha256hex(bytes), format, url };
}
}
+105
View File
@@ -0,0 +1,105 @@
import { mkdtempSync, rmSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { resolveCloneMode, verifyCloneJobResult, type CloneJobResult, type CloneOptions, type RunCloneJobInput } from "@cloner/core";
import { makeTarGz, makeZip, sha256hex } from "@cloner/storage";
import { InMemoryStore } from "../store.js";
import { buildRestResult, buildRestSummary, contentTypeFor } from "../rest.js";
import type { Backend, BundleFormat, CloneBundle, FileFacet, JobView, ResultOutcome, SubmitOutcome } from "../backend.js";
export type RunJob = (input: RunCloneJobInput) => Promise<CloneJobResult>;
/** Sync, in-memory backend (M1): runs the clone INLINE on submit and holds results
* in memory. POST returns the file map immediately (200). */
export class InMemoryBackend implements Backend {
constructor(private deps: { store: InMemoryStore; runJob: RunJob; makeTempBase?: () => string; captureCacheDir?: string }) {}
private makeBase(): string {
return (this.deps.makeTempBase ?? (() => mkdtempSync(join(tmpdir(), "api-clone-"))))();
}
async submit(url: string, options: CloneOptions | undefined): Promise<SubmitOutcome> {
const id = randomUUID();
const base = this.makeBase();
try {
// captureCacheDir (persistent, shared across submits) powers the single→multi
// reuse: a single-page submit stashes its capture; a later multi-page submit for
// the same URL reuses it as the entry route (no re-capture) and expands the site.
const result = await this.deps.runJob({ url, options, runsDir: base, captureCacheDir: this.deps.captureCacheDir });
this.deps.store.put({ id, status: "succeeded", url, kind: result.kind, options: result.options, createdAt: Date.now(), result, base });
if (result.options.asyncVerify) {
void verifyCloneJobResult(result, {
validationConcurrency: result.options.validationConcurrency,
viewportConcurrency: result.options.viewportConcurrency,
}).then((done) => {
result.verify = done.verify;
result.timings = { ...result.timings, verifyMs: done.verifyMs };
}).catch((e) => {
result.verify = { error: String(e).slice(0, 500), async: true };
});
}
return { jobId: id, status: "succeeded", httpStatus: 200, result: buildRestResult(id, result, `/v1/clones/${id}/files`) };
} catch (e) {
try {
rmSync(base, { recursive: true, force: true });
} catch {
/* best effort */
}
this.deps.store.put({ id, status: "failed", url, kind: resolveCloneMode(options) === "multi" ? "clone_site" : "clone", options: options ?? {}, createdAt: Date.now(), error: String(e) });
throw e;
}
}
async status(jobId: string): Promise<JobView | null> {
const rec = this.deps.store.get(jobId);
if (!rec) return null;
if (rec.status === "succeeded" && rec.result) return { ...buildRestSummary(jobId, rec.result), verify: rec.result.verify };
return { jobId, url: rec.url, kind: rec.kind, status: rec.status, options: rec.options, error: rec.error };
}
async result(jobId: string): Promise<ResultOutcome | null> {
const rec = this.deps.store.get(jobId);
if (!rec) return null;
if (rec.status === "succeeded" && rec.result) return { ready: true, result: buildRestResult(jobId, rec.result, `/v1/clones/${jobId}/files`) };
return { ready: false, status: rec.status, error: rec.error };
}
async file(jobId: string, path: string): Promise<{ bytes: Buffer; contentType: string } | null> {
const rec = this.deps.store.get(jobId);
if (!rec?.result) return null;
const entry = rec.result.files[path];
if (!entry) return null;
return { bytes: readFileSync(entry.absPath), contentType: contentTypeFor(path) };
}
async list(): Promise<JobView[]> {
return this.deps.store.list().map((rec) =>
rec.status === "succeeded" && rec.result
? buildRestSummary(rec.id, rec.result)
: { jobId: rec.id, url: rec.url, kind: rec.kind, status: rec.status, options: rec.options, error: rec.error },
);
}
async remove(jobId: string): Promise<boolean> {
return this.deps.store.remove(jobId);
}
async facets(jobId: string): Promise<FileFacet[] | null> {
const rec = this.deps.store.get(jobId);
if (!rec?.result) return null;
return Object.entries(rec.result.files).map(([path, f]) =>
f.kind === "text"
? { path, kind: "text", bytes: f.bytes, sha256: f.sha256, content: f.content ?? "" }
: { path, kind: "binary", bytes: f.bytes, sha256: f.sha256, binaryUrl: async () => `/v1/clones/${jobId}/files/${path}` },
);
}
async bundle(jobId: string, format: BundleFormat = "tgz"): Promise<CloneBundle | null> {
const rec = this.deps.store.get(jobId);
if (!rec?.result) return null;
const entries = Object.entries(rec.result.files).map(([path, f]) => ({ path, bytes: readFileSync(f.absPath) }));
const bytes = format === "zip" ? makeZip(entries) : makeTarGz(entries);
return { bytes, sha256: sha256hex(bytes), format };
}
}
+40
View File
@@ -0,0 +1,40 @@
import { join } from "node:path";
/** Service configuration from the environment. */
export type ApiEnv = {
port: number;
/** in-memory mode only: retention before sweeping completed clones. */
cloneTtlMs: number;
/** when set, the API runs in async DB+queue mode; otherwise sync in-memory. */
databaseUrl?: string;
/** local blob root (until S3 in M4). */
artifactsDir: string;
/** in-memory mode: persistent entry-capture cache for the single→multi reuse path
* ("" disables). */
captureCacheDir: string;
/** absolute base URL for MCP-returned references (binary/bundle URLs). */
publicBaseUrl?: string;
/** accepted API keys (raw, from API_KEYS=comma,separated). Empty = open. */
apiKeys: string[];
/** per-minute request cap on /v1/* and /mcp (0 = unlimited). */
rateLimitPerMinute: number;
/** SSRF guard (default on). */
ssrfEnabled: boolean;
/** allow loopback targets through SSRF (local dev cloning of localhost). */
ssrfAllowLoopback: boolean;
};
export function loadEnv(): ApiEnv {
return {
port: parseInt(process.env.PORT ?? "8787", 10),
cloneTtlMs: parseInt(process.env.CLONE_TTL_MS ?? String(30 * 60 * 1000), 10),
databaseUrl: process.env.DATABASE_URL,
artifactsDir: process.env.ARTIFACTS_DIR ?? join(process.cwd(), "local-data", "artifacts"),
captureCacheDir: process.env.CAPTURE_CACHE_DIR ?? join(process.cwd(), "local-data", "capture-cache"),
publicBaseUrl: process.env.PUBLIC_BASE_URL,
apiKeys: (process.env.API_KEYS ?? "").split(",").map((s) => s.trim()).filter(Boolean),
rateLimitPerMinute: parseInt(process.env.RATE_LIMIT_PER_MINUTE ?? "0", 10),
ssrfEnabled: process.env.SSRF_DISABLE !== "true",
ssrfAllowLoopback: process.env.SSRF_ALLOW_LOOPBACK === "true",
};
}
+97
View File
@@ -0,0 +1,97 @@
import type { FileFacet } from "./backend.js";
export type FileMeta = { path: string; type: "text" | "binary"; bytes: number; sha256: string };
/** Minimal glob → RegExp: `**` = any (incl. `/`), `*` = any except `/`, `?` = one
* non-`/`. Sufficient for `**/*.tsx`, `src/app/*.css`, etc. */
export function globToRegExp(glob: string): RegExp {
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob[i]!;
if (c === "*") {
if (glob[i + 1] === "*") {
re += ".*";
i++;
} else {
re += "[^/]*";
}
} else if (c === "?") {
re += "[^/]";
} else {
re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
}
}
return new RegExp("^" + re + "$");
}
export function metaOf(facets: FileFacet[]): FileMeta[] {
return facets.map((f) => ({ path: f.path, type: f.kind, bytes: f.bytes, sha256: f.sha256 }));
}
/** Filter file metadata by glob and/or route (Next = `src/app/<seg>`, Vite = `src/routes/<key>`). */
export function filterMetas(metas: FileMeta[], opts: { glob?: string; route?: string }): FileMeta[] {
let out = metas;
if (opts.glob) {
const re = globToRegExp(opts.glob);
out = out.filter((m) => re.test(m.path));
}
if (opts.route && opts.route !== "/") {
const seg = opts.route.replace(/^\/+/, "").replace(/\/+$/, "");
const viteKey = seg.replace(/\//g, "__") || "home";
out = out.filter((m) =>
m.path === `src/app/${seg}` || m.path.startsWith(`src/app/${seg}/`) ||
m.path === `src/routes/${viteKey}` || m.path.startsWith(`src/routes/${viteKey}/`)
);
}
return out;
}
/** Opaque numeric-offset pagination over a sorted list. */
export function paginate<T>(arr: T[], cursor: string | undefined, limit: number): { items: T[]; nextCursor?: string } {
const offset = cursor ? Math.max(0, parseInt(cursor, 10) || 0) : 0;
const items = arr.slice(offset, offset + limit);
const next = offset + limit;
return next < arr.length ? { items, nextCursor: String(next) } : { items };
}
export type ReadFileEntry =
| { path: string; type: "text"; bytes: number; sha256: string; content: string }
| { path: string; type: "text"; bytes: number; sha256: string; skipped: true; reason: string }
| { path: string; type: "binary"; bytes: number; sha256: string; url: string }
| { path: string; error: string };
/** Read the requested paths with a per-call text-size budget. Binaries are always
* returned as URLs (never bytes); text over budget is flagged skipped, so a careless
* `["**"]` (no exact match anyway) can't flood the consumer's context. */
export async function readFiles(
facets: FileFacet[],
paths: string[],
opts: { maxBytes?: number; resolveUrl?: (u: string) => string },
): Promise<{ files: ReadFileEntry[]; totalBytes: number; truncated: boolean }> {
const budget = opts.maxBytes ?? 256 * 1024;
const byPath = new Map(facets.map((f) => [f.path, f]));
const out: ReadFileEntry[] = [];
let used = 0;
let truncated = false;
for (const p of paths) {
const f = byPath.get(p);
if (!f) {
out.push({ path: p, error: "not found" });
continue;
}
if (f.kind === "binary") {
let url = f.binaryUrl ? await f.binaryUrl() : "";
if (opts.resolveUrl) url = opts.resolveUrl(url);
out.push({ path: p, type: "binary", bytes: f.bytes, sha256: f.sha256, url });
continue;
}
if (used + f.bytes > budget) {
out.push({ path: p, type: "text", bytes: f.bytes, sha256: f.sha256, skipped: true, reason: "per-call size budget exceeded" });
truncated = true;
continue;
}
used += f.bytes;
out.push({ path: p, type: "text", bytes: f.bytes, sha256: f.sha256, content: f.content ?? "" });
}
return { files: out, totalBytes: used, truncated };
}
+19
View File
@@ -0,0 +1,19 @@
export { createApp, type AppDeps } from "./app.js";
export { InMemoryStore, type JobRecord } from "./store.js";
export { InMemoryBackend, type RunJob } from "./backends/inMemory.js";
export { DbBackend, type StoredEnvelope } from "./backends/db.js";
export type { Backend, JobView, JobStatus, SubmitOutcome, ResultOutcome } from "./backend.js";
export {
type RestCloneResult,
type RestCloneSummary,
type RestFileEntry,
buildRestResult,
buildRestSummary,
restResultFromStored,
contentTypeFor,
} from "./rest.js";
export { loadEnv, type ApiEnv } from "./env.js";
export { createMcpServer } from "./mcp.js";
export { globToRegExp, filterMetas, paginate, readFiles, metaOf, type FileMeta, type ReadFileEntry } from "./files.js";
export { apiKeyAuth, rateLimit, hashApiKey, type AuthConfig, type RateLimitConfig } from "./auth.js";
export { assertPublicUrl, isBlockedIp, SsrfError, type DnsResolver } from "./ssrf.js";
+154
View File
@@ -0,0 +1,154 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { normalizeCloneRequestOptions } from "@cloner/core";
import type { Backend } from "./backend.js";
import { filterMetas, metaOf, paginate, readFiles } from "./files.js";
const optionsShape = {
mode: z.enum(["single", "multi"]).optional(),
styling: z.enum(["tailwind", "css"]).optional(),
framework: z.enum(["next", "vite"]).optional(),
verify: z.boolean().optional(),
asyncVerify: z.boolean().optional(),
maxRoutes: z.number().int().positive().optional(),
maxCollection: z.number().int().positive().optional(),
captureConcurrency: z.number().int().positive().optional(),
validationConcurrency: z.number().int().positive().optional(),
viewportConcurrency: z.number().int().positive().optional(),
multiPage: z.boolean().optional(),
humanizeMode: z.enum(["tailwind", "css"]).optional(),
viewports: z.array(z.number().int().positive()).optional(),
interactions: z.boolean().optional(),
components: z.boolean().optional(),
motion: z.boolean().optional(),
noCache: z.boolean().optional(),
};
const json = (data: unknown, isError = false) => ({
content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
...(isError ? { isError: true } : {}),
});
/**
* MCP server exposing the same service functions as the REST API. The key
* principle: never flood the agent's context. Tools return references +
* manifests; the agent pulls only the files it needs (list-then-read), and
* binaries/bundles are always URLs, never bytes.
*/
export function createMcpServer(backend: Backend, opts?: { baseUrl?: string }): McpServer {
const baseUrl = opts?.baseUrl ?? "";
const abs = (u: string) => (u.startsWith("/") && baseUrl ? baseUrl + u : u);
const server = new McpServer({ name: "ditto.site", version: "0.1.0" });
// clone_website → start a job; returns immediately, never blocks.
server.registerTool(
"clone_website",
{
description: "Clone a website by URL. Returns { jobId, status } immediately — poll get_clone_status, then browse with list_clone_files / read_clone_files. Never returns file contents.",
inputSchema: { url: z.string().url(), options: z.object(optionsShape).optional() },
},
async ({ url, options }) => {
if (!/^https?:\/\//i.test(url)) return json({ error: "url must be http(s)" }, true);
const out = await backend.submit(url, normalizeCloneRequestOptions(options ?? {}));
return json({ jobId: out.jobId, status: out.status });
},
);
server.registerTool(
"get_clone_status",
{ description: "Poll a clone job's status.", inputSchema: { jobId: z.string() } },
async ({ jobId }) => {
const v = await backend.status(jobId);
if (!v) return json({ error: "not found", jobId }, true);
return json({ jobId, status: v.status, timings: v.timings, capture: v.capture, error: v.error });
},
);
// get_clone_result → METADATA ONLY (no file contents): the cheap overview.
server.registerTool(
"get_clone_result",
{ description: "Get a clone's result metadata (status, timings, routes, verify summary, capture sanity, fileCount, totalBytes, bundleUrl) — NO file contents.", inputSchema: { jobId: z.string() } },
async ({ jobId }) => {
const v = await backend.status(jobId);
if (!v) return json({ error: "not found", jobId }, true);
if (v.status !== "succeeded" && v.status !== "cached") return json({ jobId, status: v.status, error: v.error });
return json({
jobId,
status: v.status,
url: v.url,
kind: v.kind,
timings: v.timings,
routes: v.routes,
capture: v.capture,
verify: v.verify,
fileCount: v.fileCount,
totalBytes: v.totalBytes,
bundleUrl: abs(`/v1/clones/${jobId}/bundle?format=tgz`),
});
},
);
// list_clone_files → the manifest (paths only, no content), filterable + paginated.
server.registerTool(
"list_clone_files",
{
description: "List a clone's files as a manifest [{ path, type, bytes, sha256 }] with NO content. Filter by glob (e.g. \"**/*.tsx\") or route; paginated via cursor.",
inputSchema: { jobId: z.string(), glob: z.string().optional(), route: z.string().optional(), cursor: z.string().optional(), limit: z.number().int().positive().max(1000).optional() },
},
async ({ jobId, glob, route, cursor, limit }) => {
const facets = await backend.facets(jobId);
if (!facets) return json({ error: "not found or not ready", jobId }, true);
const metas = filterMetas(metaOf(facets), { glob, route }).sort((a, b) => (a.path < b.path ? -1 : 1));
const page = paginate(metas, cursor, limit ?? 200);
return json({ jobId, files: page.items, nextCursor: page.nextCursor, total: metas.length });
},
);
// read_clone_files → contents for SPECIFIC files (text inline, binaries as URLs),
// with a per-call size budget so a careless request can't blow the window.
server.registerTool(
"read_clone_files",
{
description: "Read specific files by exact path. Text inline; binaries as URLs (never bytes). Enforces a per-call size budget (default 256KB); oversized text is flagged skipped.",
inputSchema: { jobId: z.string(), paths: z.array(z.string()).min(1), maxBytes: z.number().int().positive().optional() },
},
async ({ jobId, paths, maxBytes }) => {
const facets = await backend.facets(jobId);
if (!facets) return json({ error: "not found or not ready", jobId }, true);
const res = await readFiles(facets, paths, { maxBytes, resolveUrl: abs });
return json({ jobId, ...res });
},
);
// get_clone_bundle → a DOWNLOAD REFERENCE to the whole app (URL, not bytes).
server.registerTool(
"get_clone_bundle",
{ description: "Get a download reference to the whole clone as one compressed archive: { url, format, bytes, sha256 } — a URL, not the bytes. Fetch it out-of-band and expand to a runnable generated app.", inputSchema: { jobId: z.string(), format: z.enum(["tgz", "zip"]).optional() } },
async ({ jobId, format }) => {
const b = await backend.bundle(jobId, format ?? "tgz");
if (!b) return json({ error: "not found or not ready", jobId }, true);
const url = b.url ?? abs(`/v1/clones/${jobId}/bundle?format=${b.format}`);
return json({ jobId, url, format: b.format, bytes: b.bytes.length, sha256: b.sha256 });
},
);
server.registerTool(
"list_clones",
{ description: "List recent clone jobs (metadata only).", inputSchema: {} },
async () => {
const clones = await backend.list();
return json({ clones: clones.map((c) => ({ jobId: c.jobId, url: c.url, kind: c.kind, status: c.status })) });
},
);
server.registerTool(
"cancel_clone",
{ description: "Cancel/purge a clone job and its artifacts.", inputSchema: { jobId: z.string() } },
async ({ jobId }) => {
const ok = await backend.remove(jobId);
return json({ jobId, cancelled: ok });
},
);
return server;
}
+152
View File
@@ -0,0 +1,152 @@
import { extname } from "node:path";
import type { CloneJobResult, CloneOptions, CloneTimings, FileMap, RouteInfo } from "@cloner/core";
import type { StoredFile } from "@cloner/storage";
/** The eager REST file entry. Text is inline; binaries are by reference
* (a URL the client fetches out-of-band). */
export type RestFileEntry =
| { type: "text"; content: string; bytes: number; sha256: string }
| { type: "binary"; url: string; bytes: number; sha256: string };
export type RestCloneResult = {
jobId: string;
url: string;
kind: "clone" | "clone_site";
options: CloneOptions;
status: "succeeded" | "failed" | "cached";
compilerVersion: string;
timings: CloneTimings;
routes?: RouteInfo[];
files: Record<string, RestFileEntry>;
bundleUrl?: string; // added in M4 (storage); omitted in the sync/in-memory build
verify?: unknown;
capture: { nodeCount: number; pollution: boolean; blocked: boolean };
/** true when a multi-page job reused a prior single-page entry capture (speed path). */
captureReused?: boolean;
};
/** The cheap overview (no file contents) for status polling / list. */
export type RestCloneSummary = {
jobId: string;
url: string;
kind: "clone" | "clone_site";
status: "succeeded" | "failed" | "cached";
options: CloneOptions;
compilerVersion: string;
timings: CloneTimings;
routes?: RouteInfo[];
capture: { nodeCount: number; pollution: boolean; blocked: boolean };
captureReused?: boolean;
fileCount: number;
totalBytes: number;
bundleUrl?: string;
};
/** Map a collected FileMap to the eager REST shape. `filesBaseUrl` is the per-file
* access prefix (e.g. "/v1/clones/<id>/files") used to reference binaries. */
export function toRestFiles(files: FileMap, filesBaseUrl: string): Record<string, RestFileEntry> {
const out: Record<string, RestFileEntry> = {};
for (const [path, f] of Object.entries(files)) {
if (f.kind === "text") {
out[path] = { type: "text", content: f.content ?? "", bytes: f.bytes, sha256: f.sha256 };
} else {
out[path] = { type: "binary", url: `${filesBaseUrl}/${path}`, bytes: f.bytes, sha256: f.sha256 };
}
}
return out;
}
export function buildRestResult(jobId: string, result: CloneJobResult, filesBaseUrl: string): RestCloneResult {
return {
jobId,
url: result.url,
kind: result.kind,
options: result.options,
status: "succeeded",
compilerVersion: result.compilerVersion,
timings: result.timings,
routes: result.routes,
files: toRestFiles(result.files, filesBaseUrl),
verify: result.verify,
capture: result.capture,
captureReused: result.captureReused,
};
}
export function buildRestSummary(jobId: string, result: CloneJobResult): RestCloneSummary {
let totalBytes = 0;
const vals = Object.values(result.files);
for (const f of vals) totalBytes += f.bytes;
return {
jobId,
url: result.url,
kind: result.kind,
status: "succeeded",
options: result.options,
compilerVersion: result.compilerVersion,
timings: result.timings,
routes: result.routes,
capture: result.capture,
captureReused: result.captureReused,
fileCount: vals.length,
totalBytes,
};
}
/** Reconstruct the eager REST result from persisted (DB + storage) data — the
* read path for the async DB backend. Text comes inline from the manifest;
* binary URLs are resolved via the store (local API route or presigned S3 URL). */
export async function restResultFromStored(
jobId: string,
args: {
url: string;
kind: "clone" | "clone_site";
options: CloneOptions;
compilerVersion: string;
timings: CloneTimings;
capture: { nodeCount: number; pollution: boolean; blocked: boolean };
routes?: RouteInfo[];
verify?: unknown;
files: StoredFile[];
bundleUrl?: string;
binaryUrl: (path: string) => Promise<string>;
},
): Promise<RestCloneResult> {
const files: Record<string, RestFileEntry> = {};
for (const f of args.files) {
if (f.kind === "text") {
files[f.path] = { type: "text", content: f.content, bytes: f.bytes, sha256: f.sha256 };
} else {
files[f.path] = { type: "binary", url: await args.binaryUrl(f.path), bytes: f.bytes, sha256: f.sha256 };
}
}
return {
jobId,
url: args.url,
kind: args.kind,
options: args.options,
status: "succeeded",
compilerVersion: args.compilerVersion,
timings: args.timings,
routes: args.routes,
files,
bundleUrl: args.bundleUrl,
verify: args.verify,
capture: args.capture,
};
}
const CONTENT_TYPES: Record<string, string> = {
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp",
".avif": "image/avif", ".gif": "image/gif", ".svg": "image/svg+xml", ".ico": "image/x-icon",
".woff2": "font/woff2", ".woff": "font/woff", ".ttf": "font/ttf", ".otf": "font/otf",
".mp4": "video/mp4", ".webm": "video/webm",
".tsx": "text/plain; charset=utf-8", ".ts": "text/plain; charset=utf-8",
".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8",
".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
".html": "text/html; charset=utf-8", ".txt": "text/plain; charset=utf-8",
};
export function contentTypeFor(path: string): string {
return CONTENT_TYPES[extname(path).toLowerCase()] ?? "application/octet-stream";
}
+71
View File
@@ -0,0 +1,71 @@
import { serve } from "@hono/node-server";
import { runCloneJob } from "@cloner/core";
import { createDb, createBoss, repo, type Db } from "@cloner/db";
import { artifactStoreFromEnv } from "@cloner/storage";
import { createApp } from "./app.js";
import { InMemoryStore } from "./store.js";
import { InMemoryBackend } from "./backends/inMemory.js";
import { DbBackend } from "./backends/db.js";
import type { Backend } from "./backend.js";
import { hashApiKey, type AuthConfig } from "./auth.js";
import { assertPublicUrl } from "./ssrf.js";
import { loadEnv, type ApiEnv } from "./env.js";
function buildAuth(env: ApiEnv, db?: Db): AuthConfig | undefined {
const keyHashes = new Set(env.apiKeys.map(hashApiKey));
// DB-backed keys (apiKeys table) are honored too when a DB is present.
const lookup = db
? async (h: string): Promise<boolean> => {
const k = await repo.getApiKeyByHash(db, h);
return !!k && !k.revokedAt;
}
: undefined;
if (keyHashes.size === 0 && !lookup) return undefined;
return { keyHashes, lookup };
}
async function main(): Promise<void> {
const env = loadEnv();
let backend: Backend;
let db: Db | undefined;
if (env.databaseUrl) {
const h = createDb(env.databaseUrl);
db = h.db;
const boss = await createBoss(env.databaseUrl);
const store = artifactStoreFromEnv();
backend = new DbBackend({ db, boss, store });
console.log(JSON.stringify({ event: "api_mode", mode: "db+queue" }));
} else {
const store = new InMemoryStore(env.cloneTtlMs);
store.startSweeper();
backend = new InMemoryBackend({ store, runJob: runCloneJob, captureCacheDir: env.captureCacheDir || undefined });
console.log(JSON.stringify({ event: "api_mode", mode: "in-memory" }));
}
const auth = buildAuth(env, db);
const app = createApp({
backend,
baseUrl: env.publicBaseUrl,
auth,
rateLimitPerMinute: env.rateLimitPerMinute,
assertUrl: env.ssrfEnabled ? async (url) => void (await assertPublicUrl(url, { allowLoopback: env.ssrfAllowLoopback })) : undefined,
});
serve({ fetch: app.fetch, port: env.port }, (info) => {
console.log(
JSON.stringify({
event: "api_listening",
port: info.port,
auth: !!auth,
rateLimitPerMinute: env.rateLimitPerMinute || null,
ssrf: env.ssrfEnabled,
}),
);
});
}
main().catch((e) => {
console.error(e);
process.exit(1);
});
+102
View File
@@ -0,0 +1,102 @@
import { BlockList, isIP } from "node:net";
import { lookup } from "node:dns/promises";
export class SsrfError extends Error {
constructor(message: string) {
super(message);
this.name = "SsrfError";
}
}
/** Private / loopback / link-local / metadata / reserved ranges that a public
* "fetch any URL" service must never reach (the cloud metadata endpoint
* 169.254.169.254 is inside link-local 169.254/16). */
function buildBlockList(): BlockList {
const b = new BlockList();
// IPv4
b.addSubnet("0.0.0.0", 8, "ipv4");
b.addSubnet("10.0.0.0", 8, "ipv4");
b.addSubnet("100.64.0.0", 10, "ipv4"); // CGNAT
b.addSubnet("127.0.0.0", 8, "ipv4"); // loopback
b.addSubnet("169.254.0.0", 16, "ipv4"); // link-local incl. 169.254.169.254
b.addSubnet("172.16.0.0", 12, "ipv4");
b.addSubnet("192.0.0.0", 24, "ipv4");
b.addSubnet("192.168.0.0", 16, "ipv4");
b.addSubnet("198.18.0.0", 15, "ipv4"); // benchmarking
b.addSubnet("224.0.0.0", 4, "ipv4"); // multicast
b.addSubnet("240.0.0.0", 4, "ipv4"); // reserved
b.addAddress("255.255.255.255", "ipv4");
// IPv6
b.addAddress("::1", "ipv6"); // loopback
b.addAddress("::", "ipv6"); // unspecified
b.addSubnet("fc00::", 7, "ipv6"); // unique local
b.addSubnet("fe80::", 10, "ipv6"); // link-local
b.addSubnet("ff00::", 8, "ipv6"); // multicast
return b;
}
const BLOCK = buildBlockList();
const LOOPBACK = (() => {
const b = new BlockList();
b.addSubnet("127.0.0.0", 8, "ipv4");
b.addAddress("::1", "ipv6");
return b;
})();
/** True if `ip` is in a blocked range. v4-mapped IPv6 (::ffff:a.b.c.d) is unwrapped
* and checked as IPv4. When `allowLoopback`, loopback is permitted (local dev). */
export function isBlockedIp(ip: string, allowLoopback = false): boolean {
let addr = ip;
let fam = isIP(addr);
if (fam === 6 && addr.includes(".")) {
// v4-mapped (e.g. ::ffff:127.0.0.1) — check the embedded v4.
const v4 = addr.slice(addr.lastIndexOf(":") + 1);
if (isIP(v4) === 4) {
addr = v4;
fam = 4;
}
}
if (fam === 0) return true; // not a valid IP → treat as blocked
const type = fam === 4 ? "ipv4" : "ipv6";
if (allowLoopback && LOOPBACK.check(addr, type)) return false;
return BLOCK.check(addr, type);
}
export type DnsResolver = (hostname: string) => Promise<string[]>;
const defaultResolver: DnsResolver = async (hostname) => (await lookup(hostname, { all: true })).map((a) => a.address);
const BLOCKED_HOST_RE = /^(localhost|.*\.localhost|.*\.local|.*\.internal|.*\.localdomain)$/i;
/**
* Validate a target URL is safe to fetch: http(s) only, and every IP the host
* resolves to is public. Run at submit time so the service can't be used as an
* open proxy into the private network / cloud metadata. Returns the parsed URL.
*/
export async function assertPublicUrl(raw: string, opts?: { resolver?: DnsResolver; allowLoopback?: boolean }): Promise<URL> {
let u: URL;
try {
u = new URL(raw);
} catch {
throw new SsrfError("invalid URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:") throw new SsrfError("only http(s) URLs are allowed");
const host = u.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
if (!opts?.allowLoopback && BLOCKED_HOST_RE.test(host)) throw new SsrfError(`blocked host: ${host}`);
const literal = isIP(host) !== 0;
const ips = literal ? [host] : await safeResolve(opts?.resolver ?? defaultResolver, host);
if (ips.length === 0) throw new SsrfError(`host does not resolve: ${host}`);
for (const ip of ips) {
if (isBlockedIp(ip, opts?.allowLoopback)) throw new SsrfError(`blocked address for ${host}: ${ip}`);
}
return u;
}
async function safeResolve(resolver: DnsResolver, host: string): Promise<string[]> {
try {
return await resolver(host);
} catch {
throw new SsrfError(`DNS resolution failed: ${host}`);
}
}
+76
View File
@@ -0,0 +1,76 @@
import { rmSync } from "node:fs";
import type { CloneJobResult, CloneOptions } from "@cloner/core";
/** A completed (or failed) clone held in memory for the M1 sync API. The `base` is
* the temp dir holding the run's files (kept alive so /files/* can stream them);
* it is removed on eviction. M2/M4 replace this with DB rows + S3 objects. */
export type JobRecord = {
id: string;
status: "succeeded" | "failed";
url: string;
kind: "clone" | "clone_site";
options: CloneOptions;
createdAt: number;
result?: CloneJobResult;
base?: string;
error?: string;
};
/** In-memory result store with TTL eviction. Swapped for a DB-backed store in M2. */
export class InMemoryStore {
private jobs = new Map<string, JobRecord>();
private timer: NodeJS.Timeout | null = null;
constructor(private ttlMs: number) {}
put(rec: JobRecord): void {
this.jobs.set(rec.id, rec);
}
get(id: string): JobRecord | undefined {
return this.jobs.get(id);
}
list(): JobRecord[] {
return [...this.jobs.values()].sort((a, b) => b.createdAt - a.createdAt);
}
remove(id: string): boolean {
const rec = this.jobs.get(id);
if (!rec) return false;
if (rec.base) {
try {
rmSync(rec.base, { recursive: true, force: true });
} catch {
/* best effort */
}
}
return this.jobs.delete(id);
}
sweep(): void {
const now = Date.now();
for (const rec of [...this.jobs.values()]) {
if (now - rec.createdAt > this.ttlMs) this.remove(rec.id);
}
}
/** Start a periodic sweep (unref'd so it never holds the process open). */
startSweeper(intervalMs = 60_000): void {
if (this.timer) return;
this.timer = setInterval(() => this.sweep(), intervalMs);
this.timer.unref?.();
}
stopSweeper(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/** Evict everything (used by tests / shutdown). */
clear(): void {
for (const id of [...this.jobs.keys()]) this.remove(id);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, it, before, after } from "node:test";
import assert from "node:assert/strict";
import { runCloneJob } from "@cloner/core";
import { serveDir, FIXTURES_DIR, hasChromium } from "@cloner/test-utils";
import { createApp } from "../src/app.js";
import { InMemoryStore } from "../src/store.js";
import { InMemoryBackend } from "../src/backends/inMemory.js";
// End-to-end: real clone of a served fixture through the HTTP layer. Skipped when
// no Chromium is installed.
describe("POST /v1/clones (real clone, served fixture)", { skip: hasChromium() ? false : "no Chromium installed" }, () => {
let server: { url: string; close: () => Promise<void> };
const store = new InMemoryStore(60_000);
before(async () => {
server = await serveDir(FIXTURES_DIR);
});
after(async () => {
store.clear();
await server.close();
});
it("clones a fixture and returns the real generated app file map", async () => {
const app = createApp({ backend: new InMemoryBackend({ store, runJob: runCloneJob }) });
const res = await app.request("/v1/clones", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: server.url + "/components.html", options: { interactions: false, components: true, motion: false } }),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.status, "succeeded");
assert.ok(body.files["src/app/page.tsx"], "has page.tsx");
assert.equal(body.files["src/app/page.tsx"].type, "text");
assert.ok(body.files["package.json"], "has package.json");
assert.ok(body.capture.nodeCount > 0);
assert.equal(body.capture.blocked, false);
});
});
+143
View File
@@ -0,0 +1,143 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { collectFileMap, type CloneJobResult } from "@cloner/core";
import { createApp } from "../src/app.js";
import { InMemoryStore } from "../src/store.js";
import { InMemoryBackend, type RunJob } from "../src/backends/inMemory.js";
/** A browser-free fake clone: writes a tiny generated app under the provided temp
* base, then returns a real CloneJobResult via collectFileMap. Proves the REST
* file-map contract (text inline + binary by reference + per-file streaming)
* without launching Chromium. */
const fakeRunJob: RunJob = async (input) => {
const base = input.runsDir!;
const app = join(base, "generated", "app");
mkdirSync(join(app, "src", "app"), { recursive: true });
mkdirSync(join(app, "public", "assets", "cloned", "images"), { recursive: true });
writeFileSync(join(app, "package.json"), '{"name":"cloned-app"}\n');
writeFileSync(join(app, "src", "app", "page.tsx"), "export default function Page(){return <div/>}\n");
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
writeFileSync(join(app, "public", "assets", "cloned", "images", "a.png"), png);
const files = collectFileMap(base);
return {
url: input.url,
kind: "clone",
options: input.options ?? {},
status: "succeeded",
compilerVersion: "test-0",
timings: { captureMs: 5, generateMs: 0 },
files,
capture: { nodeCount: 42, pollution: false, blocked: false },
runDir: base,
} satisfies CloneJobResult;
};
test("POST /v1/clones returns the eager file map; binaries by reference; streaming + lifecycle", async () => {
const store = new InMemoryStore(60_000);
const app = createApp({ backend: new InMemoryBackend({ store, runJob: fakeRunJob }) });
try {
const res = await app.request("/v1/clones", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", options: {} }),
});
assert.equal(res.status, 200);
const body = await res.json();
assert.ok(body.jobId);
assert.equal(body.status, "succeeded");
assert.equal(body.kind, "clone");
// Text inline.
const page = body.files["src/app/page.tsx"];
assert.equal(page.type, "text");
assert.ok(page.content.includes("Page"));
assert.ok(page.sha256);
// Binary by reference (URL, not bytes).
const bin = body.files["public/assets/cloned/images/a.png"];
assert.equal(bin.type, "binary");
assert.equal(bin.content, undefined);
assert.equal(bin.url, `/v1/clones/${body.jobId}/files/public/assets/cloned/images/a.png`);
// Per-file streaming returns the actual bytes.
const fileRes = await app.request(bin.url);
assert.equal(fileRes.status, 200);
assert.equal(fileRes.headers.get("content-type"), "image/png");
const bytes = Buffer.from(await fileRes.arrayBuffer());
assert.equal(bytes.length, 7);
// Cheap metadata overview (no file contents).
const meta = await (await app.request(`/v1/clones/${body.jobId}`)).json();
assert.equal(meta.fileCount, 3);
assert.equal(meta.capture.nodeCount, 42);
assert.equal(meta.totalBytes > 0, true);
// Full result fetch.
const result = await app.request(`/v1/clones/${body.jobId}/result`);
assert.equal(result.status, 200);
// List.
const list = await (await app.request("/v1/clones")).json();
assert.equal(list.clones.length, 1);
// Delete purges; subsequent fetch 404s.
const del = await app.request(`/v1/clones/${body.jobId}`, { method: "DELETE" });
assert.equal((await del.json()).deleted, true);
assert.equal((await app.request(`/v1/clones/${body.jobId}`)).status, 404);
} finally {
store.clear();
}
});
test("POST /v1/clones validates the body", async () => {
const store = new InMemoryStore(60_000);
const app = createApp({ backend: new InMemoryBackend({ store, runJob: fakeRunJob }) });
try {
assert.equal((await app.request("/v1/clones", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })).status, 400);
assert.equal(
(await app.request("/v1/clones", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ url: "ftp://x" }) })).status,
400,
);
assert.equal(
(await app.request("/v1/clones", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ url: "https://x.com", options: { bogus: 1 } }) })).status,
400,
);
} finally {
store.clear();
}
});
test("POST /v1/clones normalizes product options and legacy aliases", async () => {
const store = new InMemoryStore(60_000);
const app = createApp({ backend: new InMemoryBackend({ store, runJob: fakeRunJob }) });
try {
const product = await app.request("/v1/clones", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", options: { mode: "multi", styling: "css", framework: "vite" } }),
});
assert.equal(product.status, 200);
const productBody = await product.json();
assert.deepEqual(productBody.options, { mode: "multi", styling: "css", framework: "vite" });
const legacy = await app.request("/v1/clones", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "https://example.com/", options: { multiPage: true, humanizeMode: "css" } }),
});
assert.equal(legacy.status, 200);
const legacyBody = await legacy.json();
assert.deepEqual(legacyBody.options, { mode: "multi", styling: "css", framework: "next" });
} finally {
store.clear();
}
});
test("GET /healthz", async () => {
const app = createApp({ backend: new InMemoryBackend({ store: new InMemoryStore(1000), runJob: fakeRunJob }) });
const res = await app.request("/healthz");
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: true });
});
+48
View File
@@ -0,0 +1,48 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createApp } from "../src/app.js";
import { InMemoryStore } from "../src/store.js";
import { InMemoryBackend, type RunJob } from "../src/backends/inMemory.js";
import { hashApiKey } from "../src/auth.js";
const noopRun: RunJob = async () => {
throw new Error("clone should not run in these tests");
};
function appWith(opts: Partial<Parameters<typeof createApp>[0]>) {
return createApp({ backend: new InMemoryBackend({ store: new InMemoryStore(1000), runJob: noopRun }), ...opts });
}
test("auth: 401 without key / with wrong key, 200 with a valid key; /healthz stays open", async () => {
const app = appWith({ auth: { keyHashes: new Set([hashApiKey("s3cret")]) } });
assert.equal((await app.request("/v1/clones")).status, 401);
assert.equal((await app.request("/v1/clones", { headers: { authorization: "Bearer wrong" } })).status, 401);
assert.equal((await app.request("/v1/clones", { headers: { "x-api-key": "s3cret" } })).status, 200);
assert.equal((await app.request("/v1/clones", { headers: { authorization: "Bearer s3cret" } })).status, 200);
assert.equal((await app.request("/healthz")).status, 200, "health check is unauthenticated");
});
test("rate limit: 429 once the per-minute cap is exceeded", async () => {
const app = appWith({ rateLimitPerMinute: 2 });
const headers = { "x-forwarded-for": "9.9.9.9" };
assert.equal((await app.request("/v1/clones", { headers })).status, 200);
assert.equal((await app.request("/v1/clones", { headers })).status, 200);
const limited = await app.request("/v1/clones", { headers });
assert.equal(limited.status, 429);
assert.ok(limited.headers.get("retry-after"));
});
test("ssrf: a submit is rejected (400) when the URL guard throws", async () => {
const app = appWith({
assertUrl: async (u) => {
if (u.includes("169.254")) throw new Error("blocked address");
},
});
const res = await app.request("/v1/clones", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: "http://169.254.169.254/" }),
});
assert.equal(res.status, 400);
assert.equal((await res.json()).error, "url not allowed");
});
+107
View File
@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { collectFileMap, type CloneJobResult } from "@cloner/core";
import { createMcpServer } from "../src/mcp.js";
import { InMemoryStore } from "../src/store.js";
import { InMemoryBackend, type RunJob } from "../src/backends/inMemory.js";
const fakeRunJob: RunJob = async (input) => {
const base = input.runsDir!;
const app = join(base, "generated", "app");
mkdirSync(join(app, "src", "app"), { recursive: true });
mkdirSync(join(app, "public", "assets", "cloned", "images"), { recursive: true });
writeFileSync(join(app, "package.json"), '{"name":"cloned-app"}\n');
writeFileSync(join(app, "src", "app", "page.tsx"), "export default function Page(){return <div/>}\n");
writeFileSync(join(app, "public", "assets", "cloned", "images", "a.png"), Buffer.from([1, 2, 3, 4]));
return {
url: input.url,
kind: "clone",
options: input.options ?? {},
status: "succeeded",
compilerVersion: "test-0",
timings: { captureMs: 1, generateMs: 0 },
files: collectFileMap(base),
capture: { nodeCount: 9, pollution: false, blocked: false },
runDir: base,
} satisfies CloneJobResult;
};
async function connect(backend: InMemoryBackend) {
const server = createMcpServer(backend, { baseUrl: "http://test" });
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: "test-client", version: "0.0.0" });
await Promise.all([server.connect(serverT), client.connect(clientT)]);
return client;
}
const parse = (res: unknown): any => JSON.parse((res as { content: { text: string }[] }).content[0]!.text);
test("MCP: list-then-read + bundle contract (never floods context)", async () => {
const store = new InMemoryStore(60_000);
const backend = new InMemoryBackend({ store, runJob: fakeRunJob });
const client = await connect(backend);
try {
const tools = (await client.listTools()).tools.map((t) => t.name);
for (const n of ["clone_website", "get_clone_status", "get_clone_result", "list_clone_files", "read_clone_files", "get_clone_bundle", "cancel_clone"]) {
assert.ok(tools.includes(n), `tool ${n} registered`);
}
// clone_website → jobId + status only (no files).
const cw = parse(await client.callTool({ name: "clone_website", arguments: { url: "https://example.com/", options: {} } }));
assert.ok(cw.jobId);
assert.equal(cw.status, "succeeded");
assert.ok(!("files" in cw));
const jobId = cw.jobId;
// status
const status = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
assert.equal(status.status, "succeeded");
// get_clone_result → metadata only, NO file contents.
const meta = parse(await client.callTool({ name: "get_clone_result", arguments: { jobId } }));
assert.equal(meta.fileCount, 3);
assert.equal(meta.capture.nodeCount, 9);
assert.ok(!("files" in meta), "result metadata must not include file contents");
assert.ok(meta.bundleUrl.includes("/bundle"));
// list_clone_files (glob) → manifest only, no content.
const list = parse(await client.callTool({ name: "list_clone_files", arguments: { jobId, glob: "**/*.tsx" } }));
assert.ok(list.files.length >= 1);
assert.ok(list.files.every((f: any) => f.path.endsWith(".tsx")));
assert.ok(list.files.some((f: any) => f.path === "src/app/page.tsx"));
assert.ok(list.files.every((f: any) => !("content" in f)), "list must not include content");
// read_clone_files → text inline, binary by URL.
const read = parse(await client.callTool({ name: "read_clone_files", arguments: { jobId, paths: ["src/app/page.tsx", "public/assets/cloned/images/a.png"] } }));
const page = read.files.find((f: any) => f.path === "src/app/page.tsx");
assert.equal(page.type, "text");
assert.ok(page.content.includes("Page"));
const bin = read.files.find((f: any) => f.path.endsWith("a.png"));
assert.equal(bin.type, "binary");
assert.ok(bin.url.startsWith("http://test/"), "binary returned as absolute URL");
assert.ok(!("content" in bin), "binary must not inline bytes");
// read size budget → oversized text flagged skipped, not dumped.
const tiny = parse(await client.callTool({ name: "read_clone_files", arguments: { jobId, paths: ["src/app/page.tsx"], maxBytes: 1 } }));
assert.equal(tiny.files[0].skipped, true);
assert.equal(tiny.truncated, true);
// get_clone_bundle → a download reference, not bytes.
const bundle = parse(await client.callTool({ name: "get_clone_bundle", arguments: { jobId } }));
assert.equal(bundle.format, "tgz");
assert.ok(bundle.bytes > 0);
assert.ok(/^[0-9a-f]{64}$/.test(bundle.sha256));
assert.ok(bundle.url.includes(`/v1/clones/${jobId}/bundle`));
// cancel_clone → purges.
const cancel = parse(await client.callTool({ name: "cancel_clone", arguments: { jobId } }));
assert.equal(cancel.cancelled, true);
const after = parse(await client.callTool({ name: "get_clone_status", arguments: { jobId } }));
assert.ok(after.error, "status after cancel reports not found");
} finally {
store.clear();
}
});
+35
View File
@@ -0,0 +1,35 @@
import test from "node:test";
import assert from "node:assert/strict";
import { assertPublicUrl, isBlockedIp, SsrfError } from "../src/ssrf.js";
test("isBlockedIp: private / loopback / link-local / metadata / ULA are blocked", () => {
for (const ip of ["10.0.0.1", "127.0.0.1", "169.254.169.254", "192.168.1.1", "172.16.0.1", "100.64.0.1", "::1", "fe80::1", "fc00::1", "::ffff:127.0.0.1"]) {
assert.equal(isBlockedIp(ip), true, `${ip} should be blocked`);
}
});
test("isBlockedIp: public addresses are allowed", () => {
for (const ip of ["8.8.8.8", "1.1.1.1", "93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"]) {
assert.equal(isBlockedIp(ip), false, `${ip} should be allowed`);
}
assert.equal(isBlockedIp("127.0.0.1", true), false, "loopback allowed when opted in");
});
test("assertPublicUrl: rejects non-http, IP literals in blocked ranges, localhost", async () => {
await assert.rejects(() => assertPublicUrl("ftp://example.com/"), SsrfError);
await assert.rejects(() => assertPublicUrl("http://169.254.169.254/latest/meta-data/"), SsrfError);
await assert.rejects(() => assertPublicUrl("http://10.1.2.3/"), SsrfError);
await assert.rejects(() => assertPublicUrl("http://localhost:3000/"), SsrfError);
});
test("assertPublicUrl: blocks hostnames that resolve to private IPs (DNS rebinding)", async () => {
await assert.rejects(() => assertPublicUrl("http://rebind.evil.test/", { resolver: async () => ["10.0.0.5"] }), SsrfError);
await assert.rejects(() => assertPublicUrl("http://nope.test/", { resolver: async () => [] }), SsrfError);
});
test("assertPublicUrl: allows public hostnames + opt-in loopback", async () => {
const u = await assertPublicUrl("https://example.test/path", { resolver: async () => ["93.184.216.34"] });
assert.equal(u.hostname, "example.test");
const u2 = await assertPublicUrl("http://127.0.0.1:8080/", { allowLoopback: true });
assert.equal(u2.port, "8080");
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts"]
}