Initial commit
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user