Skip to content

Configuration

unsurf uses Alchemy to define infrastructure as TypeScript. No YAML. No TOML.

alchemy.run.ts View source →
import alchemy from "alchemy";
import { BrowserRendering, D1Database, R2Bucket, Worker } from "alchemy/cloudflare";

const app = await alchemy("unsurf", {
	password: process.env.ALCHEMY_PASSWORD || "dev-password",
});

const DB = await D1Database("unsurf-db", {
	migrationsDir: "./migrations",
});

const STORAGE = await R2Bucket("unsurf-storage");

const BROWSER = BrowserRendering();

export const WORKER = await Worker("unsurf", {
	name: "unsurf",
	entrypoint: "./src/index.ts",
	bindings: { DB, STORAGE, BROWSER },
	url: true,
	adopt: true,
});

await app.finalize();
VariableRequiredDescription
ALCHEMY_PASSWORDPassword for Alchemy state encryption

Set in a .env file at the project root or export in your shell.

These are configured automatically by Alchemy:

BindingTypeNamePurpose
DBD1 Databaseunsurf-dbStores sites, endpoints, paths, run history
STORAGER2 Bucketunsurf-storageStores HAR logs, screenshots, traces
BROWSERBrowser RenderingHeadless browser for scouting

unsurf uses Drizzle ORM with SQLite (D1). The complete schema:

src/db/schema.ts View source →
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";

export const sites = sqliteTable("sites", {
	id: text("id").primaryKey(),
	url: text("url").notNull(),
	domain: text("domain").notNull(),
	firstScoutedAt: text("first_scouted_at").notNull(),
	lastScoutedAt: text("last_scouted_at").notNull(),
});

export const endpoints = sqliteTable(
	"endpoints",
	{
		id: text("id").primaryKey(),
		siteId: text("site_id")
			.notNull()
			.references(() => sites.id),
		method: text("method").notNull(),
		pathPattern: text("path_pattern").notNull(),
		requestSchema: text("request_schema"),
		responseSchema: text("response_schema"),
		requestHeaders: text("request_headers"),
		responseHeaders: text("response_headers"),
		sampleCount: integer("sample_count").notNull().default(1),
		firstSeenAt: text("first_seen_at").notNull(),
		lastSeenAt: text("last_seen_at").notNull(),
	},
	(table) => [
		uniqueIndex("endpoints_site_method_path").on(table.siteId, table.method, table.pathPattern),
	],
);

export const paths = sqliteTable("paths", {
	id: text("id").primaryKey(),
	siteId: text("site_id")
		.notNull()
		.references(() => sites.id),
	task: text("task").notNull(),
	steps: text("steps").notNull().default("[]"),
	endpointIds: text("endpoint_ids").notNull().default("[]"),
	status: text("status").notNull().default("active"),
	createdAt: text("created_at").notNull(),
	lastUsedAt: text("last_used_at"),
	failCount: integer("fail_count").notNull().default(0),
	healCount: integer("heal_count").notNull().default(0),
});

export const runs = sqliteTable("runs", {
	id: text("id").primaryKey(),
	pathId: text("path_id").references(() => paths.id),
	tool: text("tool").notNull(),
	status: text("status").notNull(),
	input: text("input").notNull(),
	output: text("output"),
	error: text("error"),
	durationMs: integer("duration_ms"),
	harKey: text("har_key"),
	createdAt: text("created_at").notNull(),
});

export type Site = typeof sites.$inferSelect;
export type NewSite = typeof sites.$inferInsert;
export type Endpoint = typeof endpoints.$inferSelect;
export type NewEndpoint = typeof endpoints.$inferInsert;
export type Path = typeof paths.$inferSelect;
export type NewPath = typeof paths.$inferInsert;
export type Run = typeof runs.$inferSelect;
export type NewRun = typeof runs.$inferInsert;

Generate migrations after schema changes:

Terminal window
bunx drizzle-kit generate

Migrations are applied automatically by Alchemy on deploy.

CommandDescription
bun run devLocal development via Alchemy
bun run deployDeploy to Cloudflare
bun run generateGenerate Drizzle migrations
bun run testRun test suite
bun run checkLint and format (Biome)
bun run typecheckTypeScript type checking