Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
304 changes: 279 additions & 25 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions services/deploy-infra/builder/drizzle.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineConfig } from 'drizzle-kit';

export default defineConfig({
out: './drizzle',
schema: './src/db/sqlite-schema.ts',
dialect: 'sqlite',
driver: 'durable-sqlite',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
CREATE TABLE IF NOT EXISTS `html_deployments` (
`slug` text PRIMARY KEY NOT NULL,
`expires_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS `idx_html_dep_expires_at` ON `html_deployments` (`expires_at`);
48 changes: 48 additions & 0 deletions services/deploy-infra/builder/drizzle/meta/0000_snapshot.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
{
"version": "6",
"dialect": "sqlite",
"id": "3f2a1b4c-8e7d-4c9a-b5f0-1d2e3c4a5b6f",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"html_deployments": {
"name": "html_deployments",
"columns": {
"slug": {
"name": "slug",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"idx_html_dep_expires_at": {
"name": "idx_html_dep_expires_at",
"columns": ["expires_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
13 changes: 13 additions & 0 deletions services/deploy-infra/builder/drizzle/meta/_journal.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1747076668000,
"tag": "0000_html_deployments",
"breakpoints": true
}
]
}
28 changes: 28 additions & 0 deletions services/deploy-infra/builder/drizzle/migrations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Auto-generated migration barrel — do not hand-edit.
// Regenerate with: pnpm drizzle-kit generate (from the builder directory)

const m0000 = `
CREATE TABLE IF NOT EXISTS \`html_deployments\` (
\t\`slug\` text PRIMARY KEY NOT NULL,
\t\`expires_at\` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS \`idx_html_dep_expires_at\` ON \`html_deployments\` (\`expires_at\`);
`;

export default {
journal: {
version: '7',
dialect: 'sqlite',
entries: [
{
idx: 0,
version: '6',
when: 1747076668000,
tag: '0000_html_deployments',
breakpoints: true,
},
],
},
migrations: { m0000 },
};
2 changes: 2 additions & 0 deletions services/deploy-infra/builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@kilocode/encryption": "workspace:*",
"@kilocode/worker-utils": "workspace:*",
"drizzle-orm": "catalog:",
"zod": "catalog:",
"@sentry/cloudflare": "^10.43.0",
"hono": "catalog:",
Expand All @@ -32,6 +33,7 @@
"@types/node": ">=24 <25",
"@types/tar-stream": "^3.1.4",
"@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:",
"jest": "^29.7.0",
"ts-jest": "^29.4.6",
"tsx": "^4.21.0",
Expand Down
10 changes: 10 additions & 0 deletions services/deploy-infra/builder/src/db/sqlite-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core';

export const htmlDeployments = sqliteTable(
'html_deployments',
{
slug: text('slug').primaryKey(),
expiresAt: integer('expires_at').notNull(),
},
table => [index('idx_html_dep_expires_at').on(table.expiresAt)]
);
122 changes: 122 additions & 0 deletions services/deploy-infra/builder/src/html-deploy/handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import type { Context } from 'hono';
import type { HonoEnv, HtmlDeployResponse, DeploymentArtifacts } from '../types';
import { verifyKiloBearerAgainstCurrentPepper } from '@kilocode/worker-utils/kilo-token-auth';
import { CloudflareAPI } from '../cloudflare-api';
import { Deployer } from '../deployer';
import { validateStaticAssets, parseMultipartFiles, parseTtlHeader } from './validator';
import { generateDeploymentSlug } from './slug';
import * as Sentry from '@sentry/cloudflare';
import staticWorkerContent from '../assets/static.worker.js';

const MAX_TOTAL_BYTES = 10 * 1024 * 1024; // 10 MB
const DEFAULT_HOSTNAME_BASE = 'd.kiloapps.io';
const DEFAULT_TTL_SECONDS = 24 * 60 * 60; // 24 hours
const MAX_TTL_SECONDS = 7 * 24 * 60 * 60; // 7 days

export async function htmlDeployHandler(c: Context<HonoEnv>): Promise<Response> {
const authHeader = c.req.header('Authorization');
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;

const authResult = await verifyKiloBearerAgainstCurrentPepper({
token,
nextAuthSecret: c.env.NEXTAUTH_SECRET,
workerEnv: c.env.WORKER_ENV,
connectionString: c.env.HYPERDRIVE.connectionString,
});

if (!authResult) {
return c.json({ error: 'Invalid or expired token' }, 401);
}

const contentType = c.req.header('Content-Type') ?? '';
let assets;

if (contentType.includes('multipart/form-data')) {
try {
assets = await parseMultipartFiles(c);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to parse multipart body';
return c.json({ error: msg }, 400);
}
} else {
const html = await c.req.text();
if (!html || html.length === 0) {
return c.json({ error: 'Empty body' }, 400);
}
if (html.length > MAX_TOTAL_BYTES) {
return c.json({ error: `Body exceeds the ${MAX_TOTAL_BYTES / (1024 * 1024)} MB limit` }, 400);
}
assets = [{ path: 'index.html', content: Buffer.from(html, 'utf-8'), mimeType: 'text/html' }];
}

if (assets.length === 0) {
return c.json({ error: 'No files provided' }, 400);
}

const validationError = validateStaticAssets(assets);
if (validationError) {
return c.json({ error: validationError }, 400);
}

const slug = generateDeploymentSlug(null);
const hostnameBase = c.env.DEPLOY_HOSTNAME_BASE || DEFAULT_HOSTNAME_BASE;
const ttlSeconds = parseTtlHeader(c.req.header('X-Expires-In') ?? null, {
defaultTtl: DEFAULT_TTL_SECONDS,
maxTtl: MAX_TTL_SECONDS,
});
const expiresAt = Date.now() + ttlSeconds * 1000;

const artifacts: DeploymentArtifacts = {
workerScript: {
path: 'index.js',
content: Buffer.from(staticWorkerContent, 'utf-8'),
mimeType: 'application/javascript+module',
},
artifacts: [],
assets,
};

const cloudflareApi = new CloudflareAPI(c.env.CLOUDFLARE_ACCOUNT_ID, c.env.CLOUDFLARE_API_TOKEN);
const deployer = new Deployer(cloudflareApi);

try {
await deployer.deploy({
artifacts,
workerName: slug,
logger: (msg: string) => console.log(`[deploy-html ${slug}] ${msg}`),
});
} catch (error) {
Sentry.captureException(error, {
extra: { slug, path: '/deploy-html', method: 'POST' },
});
const msg = error instanceof Error ? error.message : 'Unknown deployment error';
return c.json({ error: `Deployment failed: ${msg}` }, 500);
}

const registryId = c.env.HtmlDeployRegistry.idFromName('singleton');
const registry = c.env.HtmlDeployRegistry.get(registryId);

try {
await registry.register(slug, expiresAt);
} catch (registryError) {
Sentry.captureException(registryError, {
extra: { slug, action: 'html-deploy-registry-write' },
});
try {
await cloudflareApi.deleteWorker(slug, 'kilo-deploy');
} catch (rollbackError) {
Sentry.captureException(rollbackError, {
extra: { slug, action: 'html-deploy-rollback' },
});
}
return c.json({ error: 'Failed to record deployment — site rolled back' }, 500);
}

const response: HtmlDeployResponse = {
slug,
url: `https://${slug}.${hostnameBase}`,
expires_at: new Date(expiresAt).toISOString(),
};

return c.json(response, 200);
}
97 changes: 97 additions & 0 deletions services/deploy-infra/builder/src/html-deploy/registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* HtmlDeployRegistry — singleton Durable Object that tracks ephemeral HTML deployments.
*
* Replaces the KV-based tracking approach with a SQLite-backed registry that:
* - Stores deployment records with their expiry timestamp
* - Uses DO alarms to trigger cleanup exactly when the next deployment expires
* - Exposes an RPC method so the hourly cron can trigger cleanup as a safety net
*/

import { DurableObject } from 'cloudflare:workers';
import { drizzle } from 'drizzle-orm/durable-sqlite';
import type { DrizzleSqliteDODatabase } from 'drizzle-orm/durable-sqlite';
import { migrate } from 'drizzle-orm/durable-sqlite/migrator';
import { eq, lte, asc } from 'drizzle-orm';
import migrations from '../../drizzle/migrations';
import { htmlDeployments } from '../db/sqlite-schema';
import type { Env } from '../types';
import { CloudflareAPI } from '../cloudflare-api';
import * as Sentry from '@sentry/cloudflare';

const DISPATCH_NAMESPACE = 'kilo-deploy';

export class HtmlDeployRegistry extends DurableObject<Env> {
private db: DrizzleSqliteDODatabase;

constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.db = drizzle(ctx.storage, { logger: false });
void ctx.blockConcurrencyWhile(() => migrate(this.db, migrations));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: void ctx.blockConcurrencyWhile(...) discards the Promise — migrations may not complete before the first RPC arrives

Using void here means the blockConcurrencyWhile call is fire-and-forget. Cloudflare's DO documentation requires await so that the runtime holds off all incoming requests until the callback resolves. Without await, a concurrent register() or alarm() call could execute against an uninitialized (pre-migration) database and either fail or silently corrupt state.

Suggested change
void ctx.blockConcurrencyWhile(() => migrate(this.db, migrations));
await ctx.blockConcurrencyWhile(() => migrate(this.db, migrations));

}

/** Record a new deployment and arm (or advance) the cleanup alarm. */
async register(slug: string, expiresAt: number): Promise<void> {
await this.db.insert(htmlDeployments).values({ slug, expiresAt });

const currentAlarm = await this.ctx.storage.getAlarm();
if (currentAlarm === null || expiresAt < currentAlarm) {
await this.ctx.storage.setAlarm(expiresAt);
}
}

/** Remove a single deployment record (e.g. on manual delete). */
async deleteDeployment(slug: string): Promise<void> {
await this.db.delete(htmlDeployments).where(eq(htmlDeployments.slug, slug));
}

/**
* Delete all expired deployments.
* Called by the alarm and also exposed as an RPC for the hourly cron safety net.
*/
async triggerCleanup(): Promise<void> {
await this.cleanupExpiredAt(Date.now());
}

async alarm(): Promise<void> {
await this.cleanupExpiredAt(Date.now());

// Re-arm for the next soonest expiry, if any remain.
const [next] = await this.db
.select({ expiresAt: htmlDeployments.expiresAt })
.from(htmlDeployments)
.orderBy(asc(htmlDeployments.expiresAt))
.limit(1);

if (next) {
await this.ctx.storage.setAlarm(next.expiresAt);
}
}

private async cleanupExpiredAt(now: number): Promise<void> {
const expired = await this.db
.select({ slug: htmlDeployments.slug })
.from(htmlDeployments)
.where(lte(htmlDeployments.expiresAt, now));

if (expired.length === 0) return;

const cloudflareApi = new CloudflareAPI(
this.env.CLOUDFLARE_ACCOUNT_ID,
this.env.CLOUDFLARE_API_TOKEN
);

for (const { slug } of expired) {
try {
await cloudflareApi.deleteWorker(slug, DISPATCH_NAMESPACE);
console.log(`[html-deploy-registry] Deleted expired deployment: ${slug}`);
} catch (error) {
Sentry.captureException(error, { extra: { slug, action: 'html-deploy-cleanup' } });
console.error(
`[html-deploy-registry] Failed to delete worker ${slug}: ${error instanceof Error ? error.message : String(error)}`
);
}
// Always remove from registry, even if CF delete fails, to avoid retrying broken workers forever.
await this.db.delete(htmlDeployments).where(eq(htmlDeployments.slug, slug));
}
}
}
1 change: 1 addition & 0 deletions services/deploy-infra/builder/src/html-deploy/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { generateDeploymentSlug } from '../../../../../apps/web/src/lib/user-deployments/slug-generator';
Loading