Emdash source with visual editor image upload fix
Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
120
packages/cloudflare/src/db/d1-introspector.ts
Normal file
120
packages/cloudflare/src/db/d1-introspector.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* D1-compatible SQLite Introspector
|
||||
*
|
||||
* D1 doesn't allow the correlated cross-join pattern that Kysely's default
|
||||
* SqliteIntrospector uses: `FROM tl, pragma_table_info(tl.name)`
|
||||
*
|
||||
* This introspector queries tables individually instead.
|
||||
*/
|
||||
|
||||
import type { DatabaseIntrospector, DatabaseMetadata, SchemaMetadata, TableMetadata } from "kysely";
|
||||
import { sql } from "kysely";
|
||||
|
||||
// Kysely's default migration table names
|
||||
const DEFAULT_MIGRATION_TABLE = "kysely_migration";
|
||||
const DEFAULT_MIGRATION_LOCK_TABLE = "kysely_migration_lock";
|
||||
|
||||
// Kysely's DatabaseIntrospector.createIntrospector receives Kysely<any>.
|
||||
// We must use `any` here to match Kysely's own interface contract —
|
||||
// it needs untyped schema access to query sqlite_master dynamically.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type AnyKysely = any;
|
||||
|
||||
// Regex patterns for parsing CREATE TABLE statements
|
||||
const SPLIT_PARENS_PATTERN = /[(),]/;
|
||||
const WHITESPACE_PATTERN = /\s+/;
|
||||
const QUOTES_PATTERN = /["`]/g;
|
||||
|
||||
export class D1Introspector implements DatabaseIntrospector {
|
||||
readonly #db: AnyKysely;
|
||||
|
||||
constructor(db: AnyKysely) {
|
||||
this.#db = db;
|
||||
}
|
||||
|
||||
async getSchemas(): Promise<SchemaMetadata[]> {
|
||||
// SQLite doesn't support schemas
|
||||
return [];
|
||||
}
|
||||
|
||||
async getTables(options: { withInternalKyselyTables?: boolean } = {}): Promise<TableMetadata[]> {
|
||||
// Get table names from sqlite_master
|
||||
let query = this.#db
|
||||
.selectFrom("sqlite_master")
|
||||
.where("type", "in", ["table", "view"])
|
||||
.where("name", "not like", "sqlite_%")
|
||||
.where("name", "not like", "_cf_%") // Skip Cloudflare internal tables
|
||||
.select(["name", "sql", "type"])
|
||||
.orderBy("name");
|
||||
|
||||
if (!options.withInternalKyselyTables) {
|
||||
query = query
|
||||
.where("name", "!=", DEFAULT_MIGRATION_TABLE)
|
||||
.where("name", "!=", DEFAULT_MIGRATION_LOCK_TABLE);
|
||||
}
|
||||
|
||||
const tables = await query.execute();
|
||||
|
||||
// Query each table's columns individually (avoiding the problematic cross-join)
|
||||
const result: TableMetadata[] = [];
|
||||
|
||||
for (const table of tables) {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely's DatabaseIntrospector returns untyped results
|
||||
const tableName = table.name as string;
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely's DatabaseIntrospector returns untyped results
|
||||
const tableType = table.type as string;
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely's DatabaseIntrospector returns untyped results
|
||||
const tableSql = table.sql as string | null;
|
||||
|
||||
// Get columns for this specific table
|
||||
// Use sql.raw() to insert table name directly into query string
|
||||
// D1 doesn't allow parameterized table names in pragma_table_info()
|
||||
// Note: tableName comes from sqlite_master so it's safe
|
||||
const columns = await sql<{
|
||||
cid: number;
|
||||
name: string;
|
||||
type: string;
|
||||
notnull: number;
|
||||
dflt_value: string | null;
|
||||
pk: number;
|
||||
}>`SELECT * FROM pragma_table_info('${sql.raw(tableName)}')`.execute(this.#db);
|
||||
|
||||
// Try to find autoincrement column from CREATE TABLE statement
|
||||
let autoIncrementCol = tableSql
|
||||
?.split(SPLIT_PARENS_PATTERN)
|
||||
?.find((it) => it.toLowerCase().includes("autoincrement"))
|
||||
?.trimStart()
|
||||
?.split(WHITESPACE_PATTERN)?.[0]
|
||||
?.replace(QUOTES_PATTERN, "");
|
||||
|
||||
// Otherwise, check for INTEGER PRIMARY KEY (implicit autoincrement)
|
||||
if (!autoIncrementCol) {
|
||||
const pkCols = columns.rows.filter((r) => r.pk > 0);
|
||||
if (pkCols.length === 1 && pkCols[0]!.type.toLowerCase() === "integer") {
|
||||
autoIncrementCol = pkCols[0]!.name;
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
name: tableName,
|
||||
isView: tableType === "view",
|
||||
columns: columns.rows.map((col) => ({
|
||||
name: col.name,
|
||||
dataType: col.type,
|
||||
isNullable: !col.notnull,
|
||||
isAutoIncrementing: col.name === autoIncrementCol,
|
||||
hasDefaultValue: col.dflt_value != null,
|
||||
comment: undefined,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async getMetadata(options?: { withInternalKyselyTables?: boolean }): Promise<DatabaseMetadata> {
|
||||
return {
|
||||
tables: await this.getTables(options),
|
||||
};
|
||||
}
|
||||
}
|
||||
196
packages/cloudflare/src/db/d1.ts
Normal file
196
packages/cloudflare/src/db/d1.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Cloudflare D1 runtime adapter - RUNTIME ENTRY
|
||||
*
|
||||
* Creates a Kysely dialect for D1 and, when read replication is enabled,
|
||||
* a per-request Kysely bound to a D1 Sessions-API session.
|
||||
*
|
||||
* This module imports directly from cloudflare:workers to access the D1 binding.
|
||||
* Do NOT import this at config time - use { d1 } from "@emdash-cms/cloudflare" instead.
|
||||
*/
|
||||
|
||||
import { env } from "cloudflare:workers";
|
||||
import { kyselyLogOption } from "emdash/database/instrumentation";
|
||||
import { type DatabaseIntrospector, type Dialect, Kysely } from "kysely";
|
||||
import { D1Dialect } from "kysely-d1";
|
||||
|
||||
import { D1Introspector } from "./d1-introspector.js";
|
||||
|
||||
/**
|
||||
* D1 configuration (runtime type — matches the config-time type in index.ts)
|
||||
*/
|
||||
interface D1Config {
|
||||
binding: string;
|
||||
session?: "disabled" | "auto" | "primary-first";
|
||||
bookmarkCookie?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_BOOKMARK_COOKIE = "__em_d1_bookmark";
|
||||
|
||||
/**
|
||||
* D1 bookmarks are opaque, minted by Cloudflare. We don't validate the shape
|
||||
* (a tighter regex risks rejecting a format change and silently degrading
|
||||
* read-your-writes), but we do cap length and reject control characters so a
|
||||
* malicious or corrupt cookie can't smuggle anything weird into `withSession`.
|
||||
*/
|
||||
// D1 bookmarks observed in the wild are ~60 chars, but the format is opaque
|
||||
// and future encodings (e.g. signed envelopes) could be longer. Err on the
|
||||
// generous side — cookie values max out at ~4 KB anyway.
|
||||
const MAX_BOOKMARK_LENGTH = 1024;
|
||||
|
||||
function hasControlChars(value: string): boolean {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const code = value.charCodeAt(i);
|
||||
if (code < 0x20 || code === 0x7f) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom D1 Dialect that uses our D1-compatible introspector
|
||||
*
|
||||
* The default kysely-d1 dialect uses SqliteIntrospector which does a
|
||||
* cross-join with pragma_table_info() that D1 doesn't allow.
|
||||
*/
|
||||
class EmDashD1Dialect extends D1Dialect {
|
||||
override createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new D1Introspector(db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a D1 dialect from config. Used for the singleton Kysely instance
|
||||
* (no session — queries go through the raw binding).
|
||||
*/
|
||||
export function createDialect(config: D1Config): Dialect {
|
||||
const db = getBinding(config);
|
||||
if (!db) {
|
||||
const example = JSON.stringify(
|
||||
{
|
||||
d1_databases: [
|
||||
{
|
||||
binding: config.binding,
|
||||
database_name: "your-database-name",
|
||||
database_id: "your-database-id",
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
throw new Error(
|
||||
`D1 binding "${config.binding}" not found in environment. ` +
|
||||
`Check your wrangler.jsonc configuration:\n\n${example}`,
|
||||
);
|
||||
}
|
||||
return new EmDashD1Dialect({ database: db });
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// D1 Read Replica Session Support
|
||||
//
|
||||
// createRequestScopedDb is called by the core middleware on each request.
|
||||
// When sessions are enabled it returns a per-request Kysely bound to a
|
||||
// D1 Sessions API session, plus a `commit()` callback that persists the
|
||||
// resulting bookmark as a cookie for authenticated users.
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* A cookie interface minimally compatible with Astro's AstroCookies. Declared
|
||||
* here (not imported from astro) so this module stays free of astro types.
|
||||
*/
|
||||
interface CookieJar {
|
||||
get(name: string): { value: string } | undefined;
|
||||
set(name: string, value: string, options: Record<string, unknown>): void;
|
||||
}
|
||||
|
||||
export interface RequestScopedDbOpts {
|
||||
config: D1Config;
|
||||
isAuthenticated: boolean;
|
||||
isWrite: boolean;
|
||||
cookies: CookieJar;
|
||||
url: URL;
|
||||
}
|
||||
|
||||
export interface RequestScopedDb {
|
||||
/** Per-request Kysely instance backed by a D1 Sessions API session. */
|
||||
db: Kysely<any>;
|
||||
/**
|
||||
* Persist any per-request session state (e.g. the resulting D1 bookmark)
|
||||
* as a cookie. Idempotent; safe to call once after next() returns.
|
||||
*/
|
||||
commit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-request session-backed Kysely, or null when D1 sessions are
|
||||
* disabled or the binding is missing. Core middleware calls this once per
|
||||
* request, stashes `db` in ALS for the duration of next(), then invokes
|
||||
* `commit()` on the response path.
|
||||
*/
|
||||
export function createRequestScopedDb(opts: RequestScopedDbOpts): RequestScopedDb | null {
|
||||
if (!isSessionEnabled(opts.config)) return null;
|
||||
const binding = getBinding(opts.config);
|
||||
if (!binding || typeof binding.withSession !== "function") return null;
|
||||
|
||||
const cookieName = opts.config.bookmarkCookie ?? DEFAULT_BOOKMARK_COOKIE;
|
||||
const configConstraint =
|
||||
opts.config.session === "primary-first" ? "first-primary" : "first-unconstrained";
|
||||
|
||||
// Any write — authenticated or not (e.g. an anonymous comment POST) — must
|
||||
// hit primary; we don't want a write plus a follow-up read racing across
|
||||
// replicas. Authenticated reads resume from a prior bookmark when the client
|
||||
// sent a valid one. Everything else (anonymous reads — the whole point of
|
||||
// read replicas) uses the config default, typically "first-unconstrained"
|
||||
// for nearest-replica routing.
|
||||
let constraint: string = configConstraint;
|
||||
if (opts.isWrite) {
|
||||
constraint = "first-primary";
|
||||
} else if (opts.isAuthenticated) {
|
||||
const bookmark = opts.cookies.get(cookieName)?.value;
|
||||
if (
|
||||
bookmark &&
|
||||
bookmark.length > 0 &&
|
||||
bookmark.length <= MAX_BOOKMARK_LENGTH &&
|
||||
!hasControlChars(bookmark)
|
||||
) {
|
||||
constraint = bookmark;
|
||||
}
|
||||
}
|
||||
|
||||
const session = binding.withSession(constraint);
|
||||
// kysely-d1 only touches .prepare() and .batch() on the database argument,
|
||||
// both of which D1DatabaseSession implements.
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- session is structurally compatible with the subset D1Dialect uses
|
||||
const sessionAsDatabase = session as unknown as D1Database;
|
||||
const db = new Kysely<any>({
|
||||
dialect: new EmDashD1Dialect({ database: sessionAsDatabase }),
|
||||
log: kyselyLogOption(),
|
||||
});
|
||||
|
||||
return {
|
||||
db,
|
||||
commit() {
|
||||
// Anonymous sessions can't resume across requests, so there's no
|
||||
// value in persisting a bookmark for them.
|
||||
if (!opts.isAuthenticated) return;
|
||||
const newBookmark = session.getBookmark?.();
|
||||
if (!newBookmark) return;
|
||||
opts.cookies.set(cookieName, newBookmark, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: opts.url.protocol === "https:",
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isSessionEnabled(config: D1Config): boolean {
|
||||
return !!config.session && config.session !== "disabled";
|
||||
}
|
||||
|
||||
function getBinding(config: D1Config): D1Database | null {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
|
||||
const db = (env as Record<string, unknown>)[config.binding] as D1Database | undefined;
|
||||
return db ?? null;
|
||||
}
|
||||
288
packages/cloudflare/src/db/do-class.ts
Normal file
288
packages/cloudflare/src/db/do-class.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
/**
|
||||
* EmDashPreviewDB — Durable Object for preview databases
|
||||
*
|
||||
* Each preview session gets its own DO with isolated SQLite storage.
|
||||
* The DO is populated from a snapshot of the source EmDash site
|
||||
* and serves read-only queries until its TTL expires.
|
||||
*
|
||||
* Not used in production — preview only.
|
||||
*/
|
||||
|
||||
import { DurableObject } from "cloudflare:workers";
|
||||
|
||||
/** Default TTL for preview data (1 hour) */
|
||||
const DEFAULT_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/** Valid identifier pattern for snapshot table/column names */
|
||||
const SAFE_IDENTIFIER = /^[a-z_][a-z0-9_]*$/;
|
||||
|
||||
/** SQL command prefixes that indicate read-only statements */
|
||||
const READ_PREFIXES = ["SELECT", "PRAGMA", "EXPLAIN", "WITH"];
|
||||
|
||||
/** Result shape returned by query() */
|
||||
export interface QueryResult {
|
||||
rows: Record<string, unknown>[];
|
||||
/** Number of rows written. Undefined for read-only queries. */
|
||||
changes?: number;
|
||||
}
|
||||
|
||||
/** A single statement for batch execution */
|
||||
export interface BatchStatement {
|
||||
sql: string;
|
||||
params?: unknown[];
|
||||
}
|
||||
|
||||
/** Snapshot shape received from the source site */
|
||||
interface Snapshot {
|
||||
tables: Record<string, Record<string, unknown>[]>;
|
||||
schema?: Record<
|
||||
string,
|
||||
{
|
||||
columns: string[];
|
||||
types?: Record<string, string>;
|
||||
}
|
||||
>;
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
export class EmDashPreviewDB extends DurableObject {
|
||||
/**
|
||||
* Execute a single SQL statement.
|
||||
*
|
||||
* Called via RPC from the Kysely driver connection.
|
||||
*/
|
||||
query(sql: string, params?: unknown[]): QueryResult {
|
||||
const cursor = params?.length
|
||||
? this.ctx.storage.sql.exec(sql, ...params)
|
||||
: this.ctx.storage.sql.exec(sql);
|
||||
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (const row of cursor) {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SqlStorageCursor yields record-like objects
|
||||
rows.push(row as Record<string, unknown>);
|
||||
}
|
||||
|
||||
const isRead = READ_PREFIXES.some((p) => sql.trimStart().toUpperCase().startsWith(p));
|
||||
|
||||
return {
|
||||
rows,
|
||||
changes: isRead ? undefined : cursor.rowsWritten,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple statements in a single synchronous transaction.
|
||||
*
|
||||
* Used for snapshot import.
|
||||
*/
|
||||
batch(statements: BatchStatement[]): void {
|
||||
this.ctx.storage.transactionSync(() => {
|
||||
for (const stmt of statements) {
|
||||
if (stmt.params?.length) {
|
||||
this.ctx.storage.sql.exec(stmt.sql, ...stmt.params);
|
||||
} else {
|
||||
this.ctx.storage.sql.exec(stmt.sql);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached snapshot so the next populateFromSnapshot call
|
||||
* re-fetches from the source site.
|
||||
*/
|
||||
invalidateSnapshot(): void {
|
||||
try {
|
||||
this.ctx.storage.sql.exec("DELETE FROM _emdash_do_meta WHERE key = 'snapshot_fetched_at'");
|
||||
} catch {
|
||||
// Table doesn't exist — nothing to invalidate
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get snapshot metadata (generated-at timestamp).
|
||||
* Returns null if the DO has no snapshot loaded.
|
||||
*/
|
||||
getSnapshotMeta(): { generatedAt: string } | null {
|
||||
try {
|
||||
const row = this.ctx.storage.sql
|
||||
.exec("SELECT value FROM _emdash_do_meta WHERE key = 'snapshot_generated_at'")
|
||||
.one();
|
||||
const value = row.value;
|
||||
if (typeof value !== "string") return null;
|
||||
return { generatedAt: value };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate from a snapshot (preview mode).
|
||||
*
|
||||
* Fetches content from a source EmDash site and loads it into
|
||||
* this DO's SQLite. Sets a TTL alarm for cleanup.
|
||||
*/
|
||||
async populateFromSnapshot(
|
||||
sourceUrl: string,
|
||||
signature: string,
|
||||
options?: { drafts?: boolean; ttl?: number },
|
||||
): Promise<{ generatedAt: string }> {
|
||||
const ttlMs = (options?.ttl ?? DEFAULT_TTL_MS / 1000) * 1000;
|
||||
|
||||
// Check if already populated and fresh
|
||||
try {
|
||||
const meta = this.ctx.storage.sql
|
||||
.exec("SELECT value FROM _emdash_do_meta WHERE key = 'snapshot_fetched_at'")
|
||||
.one();
|
||||
const fetchedAt = Number(meta.value);
|
||||
if (Date.now() - fetchedAt < ttlMs) {
|
||||
// Refresh alarm so active sessions aren't killed
|
||||
void this.ctx.storage.setAlarm(Date.now() + ttlMs);
|
||||
const gen = this.ctx.storage.sql
|
||||
.exec("SELECT value FROM _emdash_do_meta WHERE key = 'snapshot_generated_at'")
|
||||
.one();
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SqlStorageCursor yields loosely-typed rows
|
||||
return { generatedAt: String(gen.value as string | number) };
|
||||
}
|
||||
} catch (error) {
|
||||
// Only swallow "no such table" — surface all other errors
|
||||
if (!(error instanceof Error) || !error.message.includes("no such table")) {
|
||||
throw error;
|
||||
}
|
||||
// _emdash_do_meta doesn't exist yet — first population
|
||||
}
|
||||
|
||||
// Fetch snapshot with timeout
|
||||
const url = `${sourceUrl}/_emdash/api/snapshot${options?.drafts ? "?drafts=true" : ""}`;
|
||||
const response = await fetch(url, {
|
||||
headers: { "X-Preview-Signature": signature },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Snapshot fetch failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`,
|
||||
);
|
||||
}
|
||||
const snapshot: Snapshot = await response.json();
|
||||
|
||||
// Wipe and repopulate in a single transaction so partial applies
|
||||
// can't leave the database in an inconsistent state.
|
||||
// ctx.storage.deleteAll() only clears KV storage, not SQLite.
|
||||
this.ctx.storage.transactionSync(() => {
|
||||
this.dropAllTables();
|
||||
this.applySnapshot(snapshot);
|
||||
});
|
||||
|
||||
// Set cleanup alarm
|
||||
void this.ctx.storage.setAlarm(Date.now() + ttlMs);
|
||||
|
||||
return { generatedAt: snapshot.generatedAt };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a cleanup alarm after the given number of seconds.
|
||||
*
|
||||
* Used by the playground middleware to set TTL after initialization
|
||||
* is complete (initialization runs on the Worker side via RPC).
|
||||
*/
|
||||
setTtlAlarm(ttlSeconds: number): void {
|
||||
void this.ctx.storage.setAlarm(Date.now() + ttlSeconds * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alarm handler — clean up expired preview/playground data.
|
||||
*
|
||||
* Drops all user tables to reclaim storage.
|
||||
*/
|
||||
override alarm(): void {
|
||||
this.dropAllTables();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all user tables in the DO's SQLite database.
|
||||
* Preserves SQLite and Cloudflare internal tables.
|
||||
*
|
||||
* Disables foreign key enforcement before dropping to avoid cascade
|
||||
* errors when tables are dropped in an order that violates FK
|
||||
* dependencies (e.g. child dropped first, then parent's implicit
|
||||
* CASCADE delete references the already-dropped child table).
|
||||
*/
|
||||
private dropAllTables(): void {
|
||||
// Disable FK enforcement so DROP order doesn't matter.
|
||||
// Cloudflare DO SQLite enforces foreign keys by default.
|
||||
this.ctx.storage.sql.exec("PRAGMA foreign_keys = OFF");
|
||||
|
||||
try {
|
||||
const tables = [
|
||||
...this.ctx.storage.sql.exec(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'",
|
||||
),
|
||||
];
|
||||
for (const row of tables) {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- SqlStorageCursor yields loosely-typed rows
|
||||
const name = String(row.name as string);
|
||||
if (!SAFE_IDENTIFIER.test(name)) {
|
||||
// Skip tables with unsafe names rather than interpolating them
|
||||
continue;
|
||||
}
|
||||
this.ctx.storage.sql.exec(`DROP TABLE IF EXISTS "${name}"`);
|
||||
}
|
||||
} finally {
|
||||
this.ctx.storage.sql.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
}
|
||||
|
||||
private applySnapshot(snapshot: Snapshot): void {
|
||||
const validateSnapshotIdentifier = (name: string, context: string) => {
|
||||
if (!SAFE_IDENTIFIER.test(name)) {
|
||||
throw new Error(`Invalid ${context} in snapshot: ${JSON.stringify(name)}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Create meta table
|
||||
this.ctx.storage.sql.exec(`
|
||||
CREATE TABLE IF NOT EXISTS _emdash_do_meta (key TEXT PRIMARY KEY, value TEXT)
|
||||
`);
|
||||
|
||||
// Create tables and insert data from snapshot
|
||||
for (const [tableName, rows] of Object.entries(snapshot.tables)) {
|
||||
if (tableName === "_emdash_do_meta") continue;
|
||||
if (!rows.length) continue;
|
||||
|
||||
validateSnapshotIdentifier(tableName, "table name");
|
||||
|
||||
const schemaInfo = snapshot.schema?.[tableName];
|
||||
const columns = schemaInfo?.columns ?? Object.keys(rows[0]!);
|
||||
columns.forEach((c) => validateSnapshotIdentifier(c, `column name in ${tableName}`));
|
||||
|
||||
const colDefs = columns
|
||||
.map((c) => {
|
||||
const colType = schemaInfo?.types?.[c] ?? "TEXT";
|
||||
const safeType = ["TEXT", "INTEGER", "REAL", "BLOB", "JSON"].includes(
|
||||
colType.toUpperCase(),
|
||||
)
|
||||
? colType.toUpperCase()
|
||||
: "TEXT";
|
||||
return `"${c}" ${safeType}`;
|
||||
})
|
||||
.join(", ");
|
||||
this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`);
|
||||
|
||||
// Batch insert
|
||||
const placeholders = columns.map(() => "?").join(", ");
|
||||
const insertSql = `INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders})`;
|
||||
for (const row of rows) {
|
||||
const values = columns.map((c) => row[c] ?? null);
|
||||
this.ctx.storage.sql.exec(insertSql, ...values);
|
||||
}
|
||||
}
|
||||
|
||||
// Record metadata
|
||||
this.ctx.storage.sql.exec(
|
||||
`INSERT OR REPLACE INTO _emdash_do_meta VALUES ('snapshot_fetched_at', ?), ('snapshot_generated_at', ?)`,
|
||||
String(Date.now()),
|
||||
snapshot.generatedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
125
packages/cloudflare/src/db/do-dialect.ts
Normal file
125
packages/cloudflare/src/db/do-dialect.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Kysely dialect for Durable Object preview databases
|
||||
*
|
||||
* Proxies all queries to an EmDashPreviewDB DO instance via RPC.
|
||||
* Preview mode is read-only — no transaction support needed.
|
||||
*/
|
||||
|
||||
import type {
|
||||
CompiledQuery,
|
||||
DatabaseConnection,
|
||||
DatabaseIntrospector,
|
||||
Dialect,
|
||||
Driver,
|
||||
Kysely,
|
||||
QueryResult,
|
||||
} from "kysely";
|
||||
import { SqliteAdapter, SqliteQueryCompiler } from "kysely";
|
||||
|
||||
import { D1Introspector } from "./d1-introspector.js";
|
||||
import type { QueryResult as DOQueryResult } from "./do-class.js";
|
||||
|
||||
/**
|
||||
* Minimal interface for the DO stub's RPC methods.
|
||||
*
|
||||
* We define this instead of using DurableObjectStub<EmDashPreviewDB> directly
|
||||
* because Rpc.Result<T> resolves to `never` when the return type contains
|
||||
* `unknown` (Record<string, unknown> in QueryResult.rows). This interface
|
||||
* gives us clean typing without fighting the Rpc type system.
|
||||
*/
|
||||
export interface PreviewDBStub {
|
||||
query(sql: string, params?: unknown[]): Promise<DOQueryResult>;
|
||||
}
|
||||
|
||||
export interface PreviewDODialectConfig {
|
||||
/**
|
||||
* Factory that returns a fresh DO stub on each call.
|
||||
*
|
||||
* DO stubs are bound to the request context that created them.
|
||||
* Since the Kysely instance may be cached across requests, we can't
|
||||
* hold a single stub — each connection must get a fresh one via
|
||||
* namespace.get(id), which is cheap (no RPC, just a local ref).
|
||||
*/
|
||||
getStub: () => PreviewDBStub;
|
||||
}
|
||||
|
||||
export class PreviewDODialect implements Dialect {
|
||||
readonly #config: PreviewDODialectConfig;
|
||||
|
||||
constructor(config: PreviewDODialectConfig) {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
createAdapter(): SqliteAdapter {
|
||||
return new SqliteAdapter();
|
||||
}
|
||||
|
||||
createDriver(): Driver {
|
||||
return new PreviewDODriver(this.#config);
|
||||
}
|
||||
|
||||
createQueryCompiler(): SqliteQueryCompiler {
|
||||
return new SqliteQueryCompiler();
|
||||
}
|
||||
|
||||
createIntrospector(db: Kysely<any>): DatabaseIntrospector {
|
||||
return new D1Introspector(db);
|
||||
}
|
||||
}
|
||||
|
||||
class PreviewDODriver implements Driver {
|
||||
readonly #config: PreviewDODialectConfig;
|
||||
|
||||
constructor(config: PreviewDODialectConfig) {
|
||||
this.#config = config;
|
||||
}
|
||||
|
||||
async init(): Promise<void> {}
|
||||
|
||||
async acquireConnection(): Promise<DatabaseConnection> {
|
||||
return new PreviewDOConnection(this.#config.getStub());
|
||||
}
|
||||
|
||||
async beginTransaction(): Promise<void> {
|
||||
// No-op. Preview is read-only.
|
||||
}
|
||||
|
||||
async commitTransaction(): Promise<void> {
|
||||
// No-op.
|
||||
}
|
||||
|
||||
async rollbackTransaction(): Promise<void> {
|
||||
// No-op.
|
||||
}
|
||||
|
||||
async releaseConnection(): Promise<void> {}
|
||||
|
||||
async destroy(): Promise<void> {}
|
||||
}
|
||||
|
||||
class PreviewDOConnection implements DatabaseConnection {
|
||||
readonly #stub: PreviewDBStub;
|
||||
|
||||
constructor(stub: PreviewDBStub) {
|
||||
this.#stub = stub;
|
||||
}
|
||||
|
||||
async executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
|
||||
const sqlText = compiledQuery.sql;
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- CompiledQuery.parameters is ReadonlyArray<unknown>, stub expects unknown[]
|
||||
const params = compiledQuery.parameters as unknown[];
|
||||
|
||||
const result = await this.#stub.query(sqlText, params);
|
||||
|
||||
return {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely generic O is the caller's row type; we trust the DB returned matching rows
|
||||
rows: result.rows as O[],
|
||||
numAffectedRows: result.changes !== undefined ? BigInt(result.changes) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-yield -- interface requires AsyncIterableIterator but DO doesn't support streaming
|
||||
async *streamQuery<O>(): AsyncIterableIterator<QueryResult<O>> {
|
||||
throw new Error("Preview DO dialect does not support streaming");
|
||||
}
|
||||
}
|
||||
65
packages/cloudflare/src/db/do-playground-routes.ts
Normal file
65
packages/cloudflare/src/db/do-playground-routes.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Playground mode route gating.
|
||||
*
|
||||
* Unlike preview mode (which blocks everything except read-only API routes),
|
||||
* playground mode allows most routes including the admin UI and write APIs.
|
||||
* Only auth, setup, and abuse-prone routes are blocked.
|
||||
*
|
||||
* Pure function -- no Worker or Cloudflare dependencies.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Routes blocked in playground mode.
|
||||
*
|
||||
* These are either security-sensitive (auth, setup, tokens, OAuth),
|
||||
* abuse-prone (media upload, plugin install), or pointless in a
|
||||
* temporary playground (snapshot export, user management).
|
||||
*/
|
||||
/**
|
||||
* Auth routes that ARE allowed in playground mode.
|
||||
* /auth/me is needed by the admin UI to identify the current user.
|
||||
*/
|
||||
const AUTH_ALLOWLIST = new Set(["/_emdash/api/auth/me"]);
|
||||
|
||||
const BLOCKED_PREFIXES = [
|
||||
// Auth -- playground has no real auth (except /auth/me for admin UI)
|
||||
"/_emdash/api/auth/",
|
||||
// Setup -- playground is pre-configured
|
||||
"/_emdash/api/setup/",
|
||||
// OAuth provider routes
|
||||
"/_emdash/api/oauth/",
|
||||
// API token management
|
||||
"/_emdash/api/tokens/",
|
||||
// User management (can't invite/create real users)
|
||||
"/_emdash/api/users/invite",
|
||||
// Plugin installation (security boundary)
|
||||
"/_emdash/api/plugins/install",
|
||||
"/_emdash/api/plugins/marketplace",
|
||||
// Media uploads (abuse vector -- no storage in playground)
|
||||
"/_emdash/api/media/upload",
|
||||
// Snapshot export (no point exporting a playground)
|
||||
"/_emdash/api/snapshot",
|
||||
];
|
||||
|
||||
/**
|
||||
* Check whether a request should be blocked in playground mode.
|
||||
*
|
||||
* Playground allows most CMS functionality: content CRUD, schema editing,
|
||||
* taxonomies, menus, widgets, search, settings, and the full admin UI.
|
||||
* Only auth, setup, user management, media uploads, and plugin
|
||||
* installation are blocked.
|
||||
*/
|
||||
export function isBlockedInPlayground(pathname: string): boolean {
|
||||
// Check allowlist first -- specific routes that must work despite
|
||||
// their parent prefix being blocked (e.g. /auth/me for admin UI)
|
||||
if (AUTH_ALLOWLIST.has(pathname)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const prefix of BLOCKED_PREFIXES) {
|
||||
if (pathname === prefix || pathname.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
48
packages/cloudflare/src/db/do-preview-routes.ts
Normal file
48
packages/cloudflare/src/db/do-preview-routes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Preview mode route gating.
|
||||
*
|
||||
* Pure function — no Worker or Cloudflare dependencies.
|
||||
* Extracted so it can be tested without mocking cloudflare:workers.
|
||||
*/
|
||||
|
||||
/**
|
||||
* API route prefixes allowed in preview mode (read-only).
|
||||
* Everything else under /_emdash/ is blocked.
|
||||
*/
|
||||
const ALLOWED_API_PREFIXES = [
|
||||
"/_emdash/api/content/",
|
||||
"/_emdash/api/schema",
|
||||
"/_emdash/api/manifest",
|
||||
"/_emdash/api/dashboard",
|
||||
"/_emdash/api/search",
|
||||
"/_emdash/api/media",
|
||||
"/_emdash/api/taxonomies",
|
||||
"/_emdash/api/menus",
|
||||
"/_emdash/api/snapshot",
|
||||
];
|
||||
|
||||
/**
|
||||
* Check whether a request should be blocked in preview mode.
|
||||
*
|
||||
* Preview is read-only with no authenticated user. All /_emdash/
|
||||
* routes are blocked by default (admin UI, auth, setup, write APIs).
|
||||
* Only specific read-only API prefixes are allowlisted.
|
||||
*
|
||||
* Non-emdash routes (site pages, assets) are always allowed.
|
||||
*/
|
||||
export function isBlockedInPreview(pathname: string): boolean {
|
||||
// Non-emdash routes are always allowed (site pages, assets, etc.)
|
||||
if (!pathname.startsWith("/_emdash/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check allowlist for API routes
|
||||
for (const prefix of ALLOWED_API_PREFIXES) {
|
||||
if (pathname === prefix || pathname.startsWith(prefix)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else under /_emdash/ is blocked
|
||||
return true;
|
||||
}
|
||||
100
packages/cloudflare/src/db/do-preview-sign.ts
Normal file
100
packages/cloudflare/src/db/do-preview-sign.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Preview URL signing utilities.
|
||||
*
|
||||
* Pure functions using Web Crypto — no Worker or Cloudflare dependencies.
|
||||
* Used by the source site to generate signed preview URLs and by the
|
||||
* preview service to verify them.
|
||||
*/
|
||||
|
||||
/** Matches a lowercase hex string */
|
||||
const HEX_PATTERN = /^[0-9a-f]+$/;
|
||||
|
||||
/**
|
||||
* Compute HMAC-SHA256 over a message and return the hex-encoded signature.
|
||||
*/
|
||||
async function hmacSign(message: string, secret: string): Promise<string> {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
|
||||
return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a signed preview URL.
|
||||
*
|
||||
* The source site calls this to create a link that opens the preview service.
|
||||
* The preview service validates the signature and populates the DO from a
|
||||
* snapshot of the source site.
|
||||
*
|
||||
* @param previewBase - Base URL of the preview service (e.g. "https://theme-x.preview.emdashcms.com")
|
||||
* @param source - URL of the source site providing the snapshot (e.g. "https://mysite.com")
|
||||
* @param secret - Shared HMAC secret (same value configured on both sides)
|
||||
* @param ttl - Link validity in seconds (default: 3600 = 1 hour)
|
||||
* @returns Fully signed preview URL
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const url = await signPreviewUrl(
|
||||
* "https://theme-x.preview.emdashcms.com",
|
||||
* "https://mysite.com",
|
||||
* import.meta.env.PREVIEW_SECRET,
|
||||
* );
|
||||
* // => "https://theme-x.preview.emdashcms.com/?source=https%3A%2F%2Fmysite.com&exp=1709164800&sig=abc123..."
|
||||
* ```
|
||||
*/
|
||||
export async function signPreviewUrl(
|
||||
previewBase: string,
|
||||
source: string,
|
||||
secret: string,
|
||||
ttl = 3600,
|
||||
): Promise<string> {
|
||||
const exp = Math.floor(Date.now() / 1000) + ttl;
|
||||
const sig = await hmacSign(`${source}:${exp}`, secret);
|
||||
|
||||
const url = new URL(previewBase);
|
||||
url.searchParams.set("source", source);
|
||||
url.searchParams.set("exp", String(exp));
|
||||
url.searchParams.set("sig", sig);
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an HMAC-SHA256 signature on a preview URL.
|
||||
*
|
||||
* Uses crypto.subtle.verify for constant-time comparison.
|
||||
*
|
||||
* @returns true if the signature is valid
|
||||
*/
|
||||
export async function verifyPreviewSignature(
|
||||
source: string,
|
||||
exp: number,
|
||||
sig: string,
|
||||
secret: string,
|
||||
): Promise<boolean> {
|
||||
// Decode hex signature to ArrayBuffer
|
||||
if (sig.length !== 64 || !HEX_PATTERN.test(sig)) return false;
|
||||
const sigBytes = new Uint8Array(32);
|
||||
for (let i = 0; i < 64; i += 2) {
|
||||
sigBytes[i / 2] = parseInt(sig.substring(i, i + 2), 16);
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
encoder.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(`${source}:${exp}`));
|
||||
}
|
||||
269
packages/cloudflare/src/db/do-preview.ts
Normal file
269
packages/cloudflare/src/db/do-preview.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Preview middleware for Durable Object-backed preview databases.
|
||||
*
|
||||
* This middleware intercepts requests to a preview service, validates
|
||||
* signed preview URLs, creates/resolves DO sessions, populates snapshots,
|
||||
* and overrides the request-context DB so all queries route to the
|
||||
* isolated DO database.
|
||||
*
|
||||
* Designed to be registered as Astro middleware in a preview Worker.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // src/middleware.ts (in the preview Worker)
|
||||
* import { createPreviewMiddleware } from "@emdash-cms/cloudflare/db/do";
|
||||
*
|
||||
* export const onRequest = createPreviewMiddleware({
|
||||
* binding: "PREVIEW_DB",
|
||||
* secret: import.meta.env.PREVIEW_SECRET,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type { MiddlewareHandler } from "astro";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { runWithContext } from "emdash/request-context";
|
||||
import { Kysely } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import type { EmDashPreviewDB } from "./do-class.js";
|
||||
import { PreviewDODialect } from "./do-dialect.js";
|
||||
import type { PreviewDBStub } from "./do-dialect.js";
|
||||
import { isBlockedInPreview } from "./do-preview-routes.js";
|
||||
import { verifyPreviewSignature } from "./do-preview-sign.js";
|
||||
import { renderPreviewToolbar } from "./preview-toolbar.js";
|
||||
|
||||
/** Configuration for the preview middleware */
|
||||
export interface PreviewMiddlewareConfig {
|
||||
/** Durable Object binding name (from wrangler.jsonc) */
|
||||
binding: string;
|
||||
/** HMAC secret for validating signed preview URLs */
|
||||
secret: string;
|
||||
/** TTL for preview data in seconds (default: 3600 = 1 hour) */
|
||||
ttl?: number;
|
||||
/** Cookie name for session token (default: "emdash_preview") */
|
||||
cookieName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple loading interstitial HTML.
|
||||
* Auto-reloads after a short delay to check if the snapshot is ready.
|
||||
*/
|
||||
function loadingPage(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="refresh" content="2">
|
||||
<title>Loading preview...</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'><rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23pb)' stroke-width='6'/><rect x='18' y='34' width='39.366' height='6.561' fill='url(%23pd)'/><defs><linearGradient id='pb' x1='-43' y1='124' x2='92.42' y2='-41.75' gradientUnits='userSpaceOnUse'><stop stop-color='%230F006B'/><stop offset='.08' stop-color='%23281A81'/><stop offset='.17' stop-color='%235D0C83'/><stop offset='.25' stop-color='%23911475'/><stop offset='.33' stop-color='%23CE2F55'/><stop offset='.42' stop-color='%23FF6633'/><stop offset='.5' stop-color='%23F6821F'/><stop offset='.58' stop-color='%23FBAD41'/><stop offset='.67' stop-color='%23FFCD89'/><stop offset='.75' stop-color='%23FFE9CB'/><stop offset='.83' stop-color='%23FFF7EC'/><stop offset='.92' stop-color='%23FFF8EE'/><stop offset='1' stop-color='white'/></linearGradient><linearGradient id='pd' x1='91.5' y1='27.5' x2='28.12' y2='54.18' gradientUnits='userSpaceOnUse'><stop stop-color='white'/><stop offset='.13' stop-color='%23FFF8EE'/><stop offset='.62' stop-color='%23FBAD41'/><stop offset='.85' stop-color='%23F6821F'/><stop offset='1' stop-color='%23FF6633'/></linearGradient></defs></svg>" />
|
||||
<style>
|
||||
body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa; color: #333; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid #e0e0e0; border-top-color: #333; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 16px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="spinner"></div>
|
||||
<p>Loading preview…</p>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an Astro-compatible preview middleware.
|
||||
*
|
||||
* Returns a middleware function that can be used in `defineMiddleware()`
|
||||
* or composed via `sequence()`.
|
||||
*/
|
||||
export function createPreviewMiddleware(config: PreviewMiddlewareConfig): MiddlewareHandler {
|
||||
const { binding, secret, ttl = 3600, cookieName = "emdash_preview" } = config;
|
||||
|
||||
return async function previewMiddleware(context, next) {
|
||||
const { url, cookies } = context;
|
||||
|
||||
// --- 0a. Reload endpoint ---
|
||||
// The toolbar POSTs here to clear the httpOnly session cookie and
|
||||
// redirect back with the original signed params for a fresh snapshot.
|
||||
if (url.pathname === "/_preview/reload") {
|
||||
cookies.delete(cookieName, { path: "/" });
|
||||
let redirectTo = "/";
|
||||
const paramsCookie = cookies.get(`${cookieName}_params`)?.value;
|
||||
if (paramsCookie) {
|
||||
const parts = decodeURIComponent(paramsCookie).split("\n");
|
||||
if (parts.length === 3) {
|
||||
const reloadUrl = new URL("/", url.origin);
|
||||
reloadUrl.searchParams.set("source", parts[0]!);
|
||||
reloadUrl.searchParams.set("exp", parts[1]!);
|
||||
reloadUrl.searchParams.set("sig", parts[2]!);
|
||||
redirectTo = reloadUrl.pathname + reloadUrl.search;
|
||||
}
|
||||
}
|
||||
return context.redirect(redirectTo);
|
||||
}
|
||||
|
||||
// --- 0b. Route gating ---
|
||||
// Block admin UI, auth, and setup routes. These depend on state
|
||||
// (users, sessions, credentials) that doesn't exist in preview snapshots.
|
||||
if (isBlockedInPreview(url.pathname)) {
|
||||
return Response.json(
|
||||
{ error: { code: "PREVIEW_MODE", message: "Not available in preview mode" } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// --- 1. Resolve session token ---
|
||||
let sessionToken: string | undefined = cookies.get(cookieName)?.value;
|
||||
let sourceUrl: string | null = null;
|
||||
let snapshotSignature: string | null = null;
|
||||
|
||||
if (!sessionToken) {
|
||||
// No cookie — must have a signed URL
|
||||
const source = url.searchParams.get("source");
|
||||
const exp = url.searchParams.get("exp");
|
||||
const sig = url.searchParams.get("sig");
|
||||
|
||||
if (!source || !exp || !sig) {
|
||||
return new Response("Missing preview parameters", { status: 400 });
|
||||
}
|
||||
|
||||
const expNum = parseInt(exp, 10);
|
||||
if (isNaN(expNum) || expNum < Date.now() / 1000) {
|
||||
return new Response("Preview link expired", { status: 403 });
|
||||
}
|
||||
|
||||
const valid = await verifyPreviewSignature(source, expNum, sig, secret);
|
||||
if (!valid) {
|
||||
return new Response("Invalid preview signature", { status: 403 });
|
||||
}
|
||||
|
||||
// Generate session
|
||||
sessionToken = ulid();
|
||||
sourceUrl = source;
|
||||
// Build the signature header value for snapshot fetch: "source:exp:sig"
|
||||
snapshotSignature = `${source}:${exp}:${sig}`;
|
||||
|
||||
cookies.set(cookieName, sessionToken, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: ttl,
|
||||
});
|
||||
// Store the signed params so the toolbar can trigger a reload.
|
||||
// Not httpOnly — the toolbar script needs to read them.
|
||||
cookies.set(`${cookieName}_params`, `${source}\n${exp}\n${sig}`, {
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: ttl,
|
||||
});
|
||||
}
|
||||
|
||||
// --- 2. Get DO stub ---
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
|
||||
const ns = (env as Record<string, unknown>)[binding];
|
||||
if (!ns) {
|
||||
console.error(`Preview binding "${binding}" not found in environment`);
|
||||
return new Response("Preview service misconfigured", { status: 500 });
|
||||
}
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
|
||||
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
||||
const doId = namespace.idFromName(sessionToken);
|
||||
const stub = namespace.get(doId);
|
||||
|
||||
// --- 3. Populate from snapshot if needed ---
|
||||
let snapshotGeneratedAt: string | undefined;
|
||||
let snapshotError: string | undefined;
|
||||
|
||||
if (!sourceUrl) {
|
||||
// Returning session — get metadata from the DO
|
||||
try {
|
||||
const meta = await stub.getSnapshotMeta();
|
||||
snapshotGeneratedAt = meta?.generatedAt;
|
||||
} catch {
|
||||
// DO may have expired or been cleaned up
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceUrl && snapshotSignature) {
|
||||
try {
|
||||
// Pass the full signature header value (source:exp:sig) so the DO
|
||||
// can send it as X-Preview-Signature when fetching the snapshot.
|
||||
const result = await stub.populateFromSnapshot(sourceUrl, snapshotSignature, { ttl });
|
||||
snapshotGeneratedAt = result.generatedAt;
|
||||
|
||||
// Snapshot loaded — redirect to strip signed params from the URL.
|
||||
// Astro's cookie buffer flushes on context.redirect().
|
||||
const cleanUrl = new URL(url);
|
||||
cleanUrl.searchParams.delete("source");
|
||||
cleanUrl.searchParams.delete("exp");
|
||||
cleanUrl.searchParams.delete("sig");
|
||||
return context.redirect(cleanUrl.pathname + cleanUrl.search);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("Failed to populate preview snapshot:", message);
|
||||
snapshotError = message;
|
||||
|
||||
// If this is the initial load (no session yet), show a loading page.
|
||||
// If we already have a session, continue with stale data and show the error in the toolbar.
|
||||
if (!cookies.get(cookieName)?.value) {
|
||||
return new Response(loadingPage(), {
|
||||
status: 503,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
"Retry-After": "2",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. Create Kysely dialect pointing at the DO ---
|
||||
const getStub = (): PreviewDBStub => {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RPC type limitation
|
||||
return stub as unknown as PreviewDBStub;
|
||||
};
|
||||
const dialect = new PreviewDODialect({ getStub });
|
||||
|
||||
// --- 5. Create Kysely instance and override request-context DB ---
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const previewDb = new Kysely<any>({ dialect });
|
||||
|
||||
return runWithContext(
|
||||
{
|
||||
editMode: false,
|
||||
db: previewDb,
|
||||
},
|
||||
async () => {
|
||||
const response = await next();
|
||||
return injectPreviewToolbar(response, {
|
||||
generatedAt: snapshotGeneratedAt,
|
||||
source: sourceUrl ?? undefined,
|
||||
error: snapshotError,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject preview toolbar HTML into an HTML response.
|
||||
* Returns the original response unchanged for non-HTML responses.
|
||||
*/
|
||||
async function injectPreviewToolbar(
|
||||
response: Response,
|
||||
config: { generatedAt?: string; source?: string; error?: string },
|
||||
): Promise<Response> {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType?.includes("text/html")) return response;
|
||||
|
||||
const html = await response.text();
|
||||
if (!html.includes("</body>")) return new Response(html, response);
|
||||
|
||||
const toolbarHtml = renderPreviewToolbar(config);
|
||||
const injected = html.replace("</body>", `${toolbarHtml}</body>`);
|
||||
return new Response(injected, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
12
packages/cloudflare/src/db/do-types.ts
Normal file
12
packages/cloudflare/src/db/do-types.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Shared Durable Object config types (preview-only)
|
||||
*
|
||||
* Imported by both the config-time entry (index.ts) and the runtime entry (do.ts).
|
||||
* This module must NOT import from cloudflare:workers so it stays safe at config time.
|
||||
*/
|
||||
|
||||
/** Durable Object preview database configuration */
|
||||
export interface PreviewDOConfig {
|
||||
/** Wrangler binding name for the DO namespace */
|
||||
binding: string;
|
||||
}
|
||||
62
packages/cloudflare/src/db/do.ts
Normal file
62
packages/cloudflare/src/db/do.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Durable Object preview database — RUNTIME ENTRY
|
||||
*
|
||||
* Creates a Kysely dialect backed by a preview Durable Object.
|
||||
* Loaded at runtime via virtual module when preview database queries are needed.
|
||||
*
|
||||
* This module imports directly from cloudflare:workers to access the DO binding.
|
||||
* Do NOT import this at config time.
|
||||
*/
|
||||
|
||||
import { env } from "cloudflare:workers";
|
||||
import type { Dialect } from "kysely";
|
||||
|
||||
import type { EmDashPreviewDB } from "./do-class.js";
|
||||
import { PreviewDODialect } from "./do-dialect.js";
|
||||
import type { PreviewDBStub } from "./do-dialect.js";
|
||||
import type { PreviewDOConfig } from "./do-types.js";
|
||||
|
||||
/**
|
||||
* Create a preview DO dialect from config.
|
||||
*
|
||||
* The caller is responsible for resolving the DO name (session token).
|
||||
* This is passed as `config.name` by the preview middleware.
|
||||
*/
|
||||
export function createDialect(config: PreviewDOConfig & { name: string }): Dialect {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
|
||||
const ns = (env as Record<string, unknown>)[config.binding];
|
||||
|
||||
if (!ns) {
|
||||
throw new Error(
|
||||
`Durable Object binding "${config.binding}" not found in environment. ` +
|
||||
`Check your wrangler.jsonc configuration:\n\n` +
|
||||
`[durable_objects]\n` +
|
||||
`bindings = [\n` +
|
||||
` { name = "${config.binding}", class_name = "EmDashPreviewDB" }\n` +
|
||||
`]\n\n` +
|
||||
`[[migrations]]\n` +
|
||||
`tag = "v1"\n` +
|
||||
`new_sqlite_classes = ["EmDashPreviewDB"]`,
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace binding from untyped env object
|
||||
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
||||
const id = namespace.idFromName(config.name);
|
||||
|
||||
// Return a factory that creates a fresh stub per connection.
|
||||
const getStub = (): PreviewDBStub => {
|
||||
const stub = namespace.get(id);
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc type limitation with unknown in return types
|
||||
return stub as unknown as PreviewDBStub;
|
||||
};
|
||||
|
||||
return new PreviewDODialect({ getStub });
|
||||
}
|
||||
|
||||
// Re-export the DO class and preview middleware for user convenience
|
||||
export { EmDashPreviewDB } from "./do-class.js";
|
||||
export { createPreviewMiddleware } from "./do-preview.js";
|
||||
export type { PreviewMiddlewareConfig } from "./do-preview.js";
|
||||
export { isBlockedInPreview } from "./do-preview-routes.js";
|
||||
export { signPreviewUrl, verifyPreviewSignature } from "./do-preview-sign.js";
|
||||
267
packages/cloudflare/src/db/playground-loading.ts
Normal file
267
packages/cloudflare/src/db/playground-loading.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Playground Loading Page
|
||||
*
|
||||
* Rendered when a user first hits /playground. Shows an animated loading state
|
||||
* while the client-side JS calls /_playground/init to create the DO, run
|
||||
* migrations, and apply the seed. Once init completes, redirects to the admin.
|
||||
*
|
||||
* No dependencies -- plain HTML with inline styles and a <script> tag.
|
||||
*/
|
||||
|
||||
export function renderPlaygroundLoadingPage(): string {
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>EmDash Playground</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg width='75' height='75' viewBox='0 0 75 75' fill='none' xmlns='http://www.w3.org/2000/svg'><rect x='3' y='3' width='69' height='69' rx='10.518' stroke='url(%23pb)' stroke-width='6'/><rect x='18' y='34' width='39.366' height='6.561' fill='url(%23pd)'/><defs><linearGradient id='pb' x1='-43' y1='124' x2='92.42' y2='-41.75' gradientUnits='userSpaceOnUse'><stop stop-color='%230F006B'/><stop offset='.08' stop-color='%23281A81'/><stop offset='.17' stop-color='%235D0C83'/><stop offset='.25' stop-color='%23911475'/><stop offset='.33' stop-color='%23CE2F55'/><stop offset='.42' stop-color='%23FF6633'/><stop offset='.5' stop-color='%23F6821F'/><stop offset='.58' stop-color='%23FBAD41'/><stop offset='.67' stop-color='%23FFCD89'/><stop offset='.75' stop-color='%23FFE9CB'/><stop offset='.83' stop-color='%23FFF7EC'/><stop offset='.92' stop-color='%23FFF8EE'/><stop offset='1' stop-color='white'/></linearGradient><linearGradient id='pd' x1='91.5' y1='27.5' x2='28.12' y2='54.18' gradientUnits='userSpaceOnUse'><stop stop-color='white'/><stop offset='.13' stop-color='%23FFF8EE'/><stop offset='.62' stop-color='%23FBAD41'/><stop offset='.85' stop-color='%23F6821F'/><stop offset='1' stop-color='%23FF6633'/></linearGradient></defs></svg>" />
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #0a0a0a;
|
||||
color: #e0e0e0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.pg-loading {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.pg-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.pg-logo svg {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pg-spinner-wrap {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.pg-spinner {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.08);
|
||||
border-top-color: #facc15;
|
||||
border-radius: 50%;
|
||||
animation: pg-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes pg-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.pg-message {
|
||||
font-size: 15px;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pg-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.pg-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #555;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.pg-step.active {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.pg-step.done {
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.pg-step-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #333;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.pg-step.active .pg-step-dot {
|
||||
background: #facc15;
|
||||
box-shadow: 0 0 6px rgba(250, 204, 21, 0.4);
|
||||
}
|
||||
|
||||
.pg-step.done .pg-step-dot {
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
.pg-error {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.pg-error.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.pg-error-message {
|
||||
font-size: 14px;
|
||||
color: #f87171;
|
||||
max-width: 360px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pg-retry-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(250, 204, 21, 0.12);
|
||||
color: #facc15;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pg-retry-btn:hover {
|
||||
background: rgba(250, 204, 21, 0.22);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="pg-loading">
|
||||
<div class="pg-logo"><svg viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="3" y="3" width="69" height="69" rx="10.518" stroke="url(#pl-b)" stroke-width="6"/><rect x="18" y="34" width="39.366" height="6.561" fill="url(#pl-d)"/><defs><linearGradient id="pl-b" x1="-43" y1="124" x2="92.42" y2="-41.75" gradientUnits="userSpaceOnUse"><stop stop-color="#0F006B"/><stop offset=".08" stop-color="#281A81"/><stop offset=".17" stop-color="#5D0C83"/><stop offset=".25" stop-color="#911475"/><stop offset=".33" stop-color="#CE2F55"/><stop offset=".42" stop-color="#FF6633"/><stop offset=".5" stop-color="#F6821F"/><stop offset=".58" stop-color="#FBAD41"/><stop offset=".67" stop-color="#FFCD89"/><stop offset=".75" stop-color="#FFE9CB"/><stop offset=".83" stop-color="#FFF7EC"/><stop offset=".92" stop-color="#FFF8EE"/><stop offset="1" stop-color="#fff"/></linearGradient><linearGradient id="pl-d" x1="91.5" y1="27.5" x2="28.12" y2="54.18" gradientUnits="userSpaceOnUse"><stop stop-color="#fff"/><stop offset=".13" stop-color="#FFF8EE"/><stop offset=".62" stop-color="#FBAD41"/><stop offset=".85" stop-color="#F6821F"/><stop offset="1" stop-color="#FF6633"/></linearGradient></defs></svg>EmDash</div>
|
||||
|
||||
<div class="pg-spinner-wrap">
|
||||
<div class="pg-spinner" id="pg-spinner"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="pg-message" id="pg-message">Creating your playground…</div>
|
||||
<div class="pg-steps" id="pg-steps">
|
||||
<div class="pg-step active" id="step-db">
|
||||
<span class="pg-step-dot"></span>
|
||||
Setting up database
|
||||
</div>
|
||||
<div class="pg-step" id="step-content">
|
||||
<span class="pg-step-dot"></span>
|
||||
Loading demo content
|
||||
</div>
|
||||
<div class="pg-step" id="step-ready">
|
||||
<span class="pg-step-dot"></span>
|
||||
Almost ready
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pg-error" id="pg-error">
|
||||
<div class="pg-error-message" id="pg-error-message"></div>
|
||||
<button class="pg-retry-btn" id="pg-retry">Try again</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var steps = ["step-db", "step-content", "step-ready"];
|
||||
var currentStep = 0;
|
||||
|
||||
function setStep(index) {
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
var el = document.getElementById(steps[i]);
|
||||
if (!el) continue;
|
||||
el.className = "pg-step" + (i < index ? " done" : i === index ? " active" : "");
|
||||
}
|
||||
currentStep = index;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
document.getElementById("pg-spinner").style.display = "none";
|
||||
document.getElementById("pg-message").textContent = "Something went wrong";
|
||||
document.getElementById("pg-steps").style.display = "none";
|
||||
var errorEl = document.getElementById("pg-error");
|
||||
var errorMsg = document.getElementById("pg-error-message");
|
||||
if (errorEl) errorEl.className = "pg-error visible";
|
||||
if (errorMsg) errorMsg.textContent = message;
|
||||
}
|
||||
|
||||
function init() {
|
||||
setStep(0);
|
||||
document.getElementById("pg-spinner").style.display = "";
|
||||
document.getElementById("pg-message").textContent = "Creating your playground\\u2026";
|
||||
document.getElementById("pg-steps").style.display = "";
|
||||
var errorEl = document.getElementById("pg-error");
|
||||
if (errorEl) errorEl.className = "pg-error";
|
||||
|
||||
// Advance steps on a timer for visual feedback while init runs.
|
||||
// The actual init is a single server call -- these steps are cosmetic.
|
||||
var stepTimer = setTimeout(function() { setStep(1); }, 800);
|
||||
var stepTimer2 = setTimeout(function() { setStep(2); }, 2000);
|
||||
|
||||
fetch("/_playground/init", { method: "POST", credentials: "same-origin" })
|
||||
.then(function(res) {
|
||||
clearTimeout(stepTimer);
|
||||
clearTimeout(stepTimer2);
|
||||
if (!res.ok) {
|
||||
return res.json().then(function(body) {
|
||||
throw new Error(body.error?.message || "Initialization failed");
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then(function() {
|
||||
// Mark all steps done
|
||||
setStep(steps.length);
|
||||
document.getElementById("pg-message").textContent = "Ready!";
|
||||
// Brief pause so the user sees "Ready!" before navigating
|
||||
setTimeout(function() {
|
||||
location.replace("/_emdash/admin");
|
||||
}, 400);
|
||||
})
|
||||
.catch(function(err) {
|
||||
clearTimeout(stepTimer);
|
||||
clearTimeout(stepTimer2);
|
||||
showError(err.message || "Failed to create playground. Please try again.");
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById("pg-retry").addEventListener("click", init);
|
||||
|
||||
init();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
380
packages/cloudflare/src/db/playground-middleware.ts
Normal file
380
packages/cloudflare/src/db/playground-middleware.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
/**
|
||||
* Playground middleware — injected by the EmDash integration as order: "pre".
|
||||
*
|
||||
* Runs BEFORE the EmDash runtime init middleware. Creates a per-session
|
||||
* Durable Object database, runs migrations, applies the seed, creates an
|
||||
* anonymous admin user, and sets the DB in ALS via runWithContext().
|
||||
*
|
||||
* By the time the runtime middleware runs, the ALS-scoped DB is ready.
|
||||
* The runtime's `db` getter checks ALS first, so all init queries
|
||||
* (migrations, FTS, cron, manifest) operate on the real DO database.
|
||||
*
|
||||
* This module is registered via `addMiddleware({ entrypoint: "..." })` in
|
||||
* the integration, NOT in the user's src/middleware.ts.
|
||||
*/
|
||||
|
||||
import { defineMiddleware } from "astro:middleware";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { Kysely, sql } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
// @ts-ignore - virtual module populated by EmDash integration at build time
|
||||
import virtualConfig from "virtual:emdash/config";
|
||||
|
||||
import type { EmDashPreviewDB } from "./do-class.js";
|
||||
import { PreviewDODialect } from "./do-dialect.js";
|
||||
import type { PreviewDBStub } from "./do-dialect.js";
|
||||
import { isBlockedInPlayground } from "./do-playground-routes.js";
|
||||
import { renderPlaygroundLoadingPage } from "./playground-loading.js";
|
||||
import { renderPlaygroundToolbar } from "./playground-toolbar.js";
|
||||
|
||||
/** Default TTL for playground data (1 hour) */
|
||||
const DEFAULT_TTL = 3600;
|
||||
|
||||
/** Cookie name for playground session */
|
||||
const COOKIE_NAME = "emdash_playground";
|
||||
|
||||
/** Playground admin user constants */
|
||||
const PLAYGROUND_USER_ID = "playground-admin";
|
||||
const PLAYGROUND_USER_EMAIL = "playground@emdashcms.com";
|
||||
const PLAYGROUND_USER_NAME = "Playground User";
|
||||
const PLAYGROUND_USER_ROLE = 50; // Admin
|
||||
|
||||
const PLAYGROUND_USER = {
|
||||
id: PLAYGROUND_USER_ID,
|
||||
email: PLAYGROUND_USER_EMAIL,
|
||||
name: PLAYGROUND_USER_NAME,
|
||||
role: PLAYGROUND_USER_ROLE,
|
||||
};
|
||||
|
||||
/** Track which DOs have been initialized this Worker lifetime */
|
||||
const initializedSessions = new Set<string>();
|
||||
|
||||
/**
|
||||
* Read the DO binding name from the virtual config.
|
||||
* The database config has the binding in `config.database.config.binding`.
|
||||
*/
|
||||
function getBindingName(): string {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import
|
||||
const config = virtualConfig as { database?: { config?: { binding?: string } } } | null;
|
||||
const binding = config?.database?.config?.binding;
|
||||
if (!binding) {
|
||||
throw new Error(
|
||||
"Playground middleware: no database binding found in config. " +
|
||||
"Ensure database: playgroundDatabase({ binding: '...' }) is set.",
|
||||
);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PreviewDBStub for the given session token.
|
||||
*/
|
||||
function getStub(binding: string, token: string): PreviewDBStub {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
|
||||
const ns = (env as Record<string, unknown>)[binding];
|
||||
if (!ns) {
|
||||
throw new Error(`Playground binding "${binding}" not found in environment`);
|
||||
}
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
|
||||
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
||||
const doId = namespace.idFromName(token);
|
||||
const stub = namespace.get(doId);
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RPC type limitation
|
||||
return stub as unknown as PreviewDBStub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full DO stub for direct RPC calls (e.g. setTtlAlarm).
|
||||
*/
|
||||
function getFullStub(binding: string, token: string): DurableObjectStub<EmDashPreviewDB> {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
|
||||
const ns = (env as Record<string, unknown>)[binding];
|
||||
if (!ns) {
|
||||
throw new Error(`Playground binding "${binding}" not found in environment`);
|
||||
}
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
|
||||
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
||||
const doId = namespace.idFromName(token);
|
||||
return namespace.get(doId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a created-at timestamp from the ULID session token.
|
||||
*/
|
||||
function getSessionCreatedAt(token: string): string {
|
||||
try {
|
||||
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
let time = 0;
|
||||
const chars = token.toUpperCase().slice(0, 10);
|
||||
for (const char of chars) {
|
||||
time = time * 32 + ENCODING.indexOf(char);
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a playground DO: run migrations, apply seed, create admin user.
|
||||
*/
|
||||
async function initializePlayground(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: Kysely<any>,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
// Check if already initialized (persisted in the DO)
|
||||
try {
|
||||
const { rows } = await sql<{ value: string }>`
|
||||
SELECT value FROM options WHERE name = ${"emdash:setup_complete"}
|
||||
`.execute(db);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Table doesn't exist yet -- first initialization
|
||||
}
|
||||
|
||||
console.log(`[playground] Initializing session ${token}`);
|
||||
|
||||
// 1. Run all EmDash migrations.
|
||||
// If the DO was previously initialized (persisted state) but somehow the
|
||||
// setup_complete flag is missing, migrations may partially fail on tables
|
||||
// that already exist. Treat migration errors as non-fatal if there are
|
||||
// tables present (i.e. the DO was previously initialized).
|
||||
const { runMigrations } = await import("emdash/db");
|
||||
try {
|
||||
const migrations = await runMigrations(db);
|
||||
console.log(`[playground] Migrations applied: ${migrations.applied.length}`);
|
||||
} catch (migrationError) {
|
||||
// Check if this looks like a "tables already exist" error -- the DO
|
||||
// was probably initialized in a previous Worker lifetime and the
|
||||
// options check above failed for a transient reason.
|
||||
const msg = migrationError instanceof Error ? migrationError.message : String(migrationError);
|
||||
if (msg.includes("already exists")) {
|
||||
console.log(`[playground] Migrations skipped (tables already exist)`);
|
||||
// Mark setup complete if it wasn't (recover from partial init)
|
||||
try {
|
||||
await sql`
|
||||
INSERT OR IGNORE INTO options (name, value)
|
||||
VALUES (${"emdash:setup_complete"}, ${JSON.stringify(true)})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw migrationError;
|
||||
}
|
||||
|
||||
// 2. Load and apply seed with content (skip media downloads)
|
||||
const { loadSeed } = await import("emdash/seed");
|
||||
const { applySeed } = await import("emdash");
|
||||
const seed = await loadSeed();
|
||||
const seedResult = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
onConflict: "skip",
|
||||
skipMediaDownload: true,
|
||||
});
|
||||
console.log(
|
||||
`[playground] Seed applied: ${seedResult.collections.created} collections, ${seedResult.content.created} content entries`,
|
||||
);
|
||||
|
||||
// 3. Create anonymous admin user
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO users (id, email, name, role, email_verified, created_at, updated_at)
|
||||
VALUES (${PLAYGROUND_USER_ID}, ${PLAYGROUND_USER_EMAIL}, ${PLAYGROUND_USER_NAME},
|
||||
${PLAYGROUND_USER_ROLE}, ${1}, ${now}, ${now})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// User might already exist
|
||||
}
|
||||
|
||||
// 4. Mark setup complete
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO options (name, value)
|
||||
VALUES (${"emdash:setup_complete"}, ${JSON.stringify(true)})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// 5. Set site title
|
||||
try {
|
||||
await sql`
|
||||
INSERT OR REPLACE INTO options (name, value)
|
||||
VALUES (${"emdash:site_title"}, ${JSON.stringify("EmDash Playground")})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
console.log(`[playground] Session ${token} initialized`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject playground toolbar HTML into an HTML response.
|
||||
*/
|
||||
async function injectPlaygroundToolbar(
|
||||
response: Response,
|
||||
config: { createdAt: string; ttl: number; editMode: boolean },
|
||||
): Promise<Response> {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType?.includes("text/html")) return response;
|
||||
|
||||
const html = await response.text();
|
||||
if (!html.includes("</body>")) return new Response(html, response);
|
||||
|
||||
const toolbarHtml = renderPlaygroundToolbar(config);
|
||||
const injected = html.replace("</body>", `${toolbarHtml}</body>`);
|
||||
return new Response(injected, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const { url, cookies } = context;
|
||||
const ttl = DEFAULT_TTL;
|
||||
|
||||
// Lazy-load binding name from virtual config
|
||||
const binding = getBindingName();
|
||||
|
||||
// --- Entry point: /playground ---
|
||||
// Show a loading page immediately. The page calls /_playground/init via
|
||||
// fetch to do the actual setup, then redirects to admin when ready.
|
||||
// If the session is already initialized, skip the loading page.
|
||||
if (url.pathname === "/playground") {
|
||||
let token = cookies.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
token = ulid();
|
||||
cookies.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: ttl,
|
||||
});
|
||||
}
|
||||
|
||||
// Already initialized? Skip the loading page and go straight to admin.
|
||||
if (initializedSessions.has(token)) {
|
||||
return context.redirect("/_emdash/admin");
|
||||
}
|
||||
|
||||
return new Response(renderPlaygroundLoadingPage(), {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Init endpoint: called by the loading page ---
|
||||
if (url.pathname === "/_playground/init" && context.request.method === "POST") {
|
||||
const token = cookies.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
return Response.json(
|
||||
{ error: { code: "NO_SESSION", message: "No playground session" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (initializedSessions.has(token)) {
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
|
||||
const stub = getStub(binding, token);
|
||||
const dialect = new PreviewDODialect({ getStub: () => stub });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = new Kysely<any>({ dialect });
|
||||
|
||||
try {
|
||||
await initializePlayground(db, token);
|
||||
console.log(`[playground] Session ${token} initialized`);
|
||||
initializedSessions.add(token);
|
||||
const fullStub = getFullStub(binding, token);
|
||||
console.log(`[playground] Setting TTL alarm for session ${token} (${ttl} seconds)`);
|
||||
await fullStub.setTtlAlarm(ttl);
|
||||
console.log(`[playground] TTL alarm set for session ${token}`);
|
||||
return Response.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error("Playground initialization failed:", error);
|
||||
if (error instanceof Error) {
|
||||
console.error(error.stack);
|
||||
}
|
||||
return Response.json(
|
||||
{ error: { code: "PLAYGROUND_INIT_ERROR", message: "Failed to initialize playground" } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reset endpoint ---
|
||||
// Instead of dropping tables on the old DO (which is fragile and races
|
||||
// with cached state), just clear the cookie and redirect to /playground.
|
||||
// That creates a brand new DO with a fresh session -- clean slate.
|
||||
// The old DO expires via its TTL alarm.
|
||||
if (url.pathname === "/_playground/reset") {
|
||||
cookies.delete(COOKIE_NAME, { path: "/" });
|
||||
return context.redirect("/playground");
|
||||
}
|
||||
|
||||
// --- Route gating ---
|
||||
if (isBlockedInPlayground(url.pathname)) {
|
||||
return Response.json(
|
||||
{ error: { code: "PLAYGROUND_MODE", message: "Not available in playground mode" } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Resolve session ---
|
||||
const token = cookies.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
// No session -- redirect to /playground to create one
|
||||
return context.redirect("/playground");
|
||||
}
|
||||
|
||||
// --- Set up DO database and ALS ---
|
||||
const stub = getStub(binding, token);
|
||||
const dialect = new PreviewDODialect({ getStub: () => stub });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = new Kysely<any>({ dialect });
|
||||
|
||||
// Ensure initialized
|
||||
if (!initializedSessions.has(token)) {
|
||||
try {
|
||||
await initializePlayground(db, token);
|
||||
initializedSessions.add(token);
|
||||
const fullStub = getFullStub(binding, token);
|
||||
await fullStub.setTtlAlarm(ttl);
|
||||
} catch (error) {
|
||||
console.error("Playground initialization failed:", error);
|
||||
return Response.json(
|
||||
{ error: { code: "PLAYGROUND_INIT_ERROR", message: "Failed to initialize playground" } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stash the DO database and user on locals so downstream middleware
|
||||
// (runtime init, request-context) can use them. We can't use ALS directly
|
||||
// because this middleware is in @emdash-cms/cloudflare and resolves to a
|
||||
// different AsyncLocalStorage instance than the emdash core package
|
||||
// (workerd loads dist modules separately from Vite's source modules).
|
||||
// The request-context middleware (same module context as the loader)
|
||||
// detects locals.__playgroundDb and wraps the render in runWithContext().
|
||||
// The __playgroundDb property is declared on App.Locals in emdash's locals.d.ts.
|
||||
Object.assign(context.locals, { __playgroundDb: db, user: PLAYGROUND_USER });
|
||||
|
||||
const editMode = cookies.get("emdash-edit-mode")?.value === "true";
|
||||
|
||||
const response = await next();
|
||||
|
||||
return injectPlaygroundToolbar(response, {
|
||||
createdAt: getSessionCreatedAt(token),
|
||||
ttl,
|
||||
editMode,
|
||||
});
|
||||
});
|
||||
356
packages/cloudflare/src/db/playground-toolbar.ts
Normal file
356
packages/cloudflare/src/db/playground-toolbar.ts
Normal file
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* Playground Toolbar
|
||||
*
|
||||
* A floating pill injected by the playground middleware into HTML responses.
|
||||
* Shows edit toggle, time remaining, reset button, and deploy CTA.
|
||||
* No dependencies -- plain HTML string with inline styles and a <script> tag.
|
||||
*
|
||||
* The edit toggle sets the emdash-edit-mode cookie, same as the normal
|
||||
* visual editing toolbar. The data-edit-mode attribute on the toolbar div
|
||||
* activates the hover outlines on [data-emdash-ref] elements via CSS :has().
|
||||
*/
|
||||
|
||||
export interface PlaygroundToolbarConfig {
|
||||
/** When the playground was created (ISO string) */
|
||||
createdAt: string;
|
||||
/** TTL in seconds */
|
||||
ttl: number;
|
||||
/** Whether edit mode is currently active */
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
const RE_AMP = /&/g;
|
||||
const RE_QUOT = /"/g;
|
||||
const RE_LT = /</g;
|
||||
const RE_GT = />/g;
|
||||
|
||||
export function renderPlaygroundToolbar(config: PlaygroundToolbarConfig): string {
|
||||
const { createdAt, ttl, editMode } = config;
|
||||
|
||||
return `
|
||||
<!-- EmDash Playground Toolbar -->
|
||||
<div id="emdash-playground-toolbar" data-created-at="${escapeAttr(createdAt)}" data-ttl="${ttl}" data-edit-mode="${editMode}">
|
||||
<div class="ec-pg-inner">
|
||||
<span class="ec-pg-badge">Playground</span>
|
||||
|
||||
<div class="ec-pg-divider"></div>
|
||||
|
||||
<label class="ec-pg-toggle" title="Toggle visual editing">
|
||||
<input type="checkbox" id="ec-pg-edit-toggle" ${editMode ? "checked" : ""} />
|
||||
<span class="ec-pg-toggle-track">
|
||||
<span class="ec-pg-toggle-thumb"></span>
|
||||
</span>
|
||||
<span class="ec-pg-toggle-label">Edit</span>
|
||||
</label>
|
||||
|
||||
<div class="ec-pg-divider"></div>
|
||||
|
||||
<span class="ec-pg-status" id="ec-pg-status"></span>
|
||||
|
||||
<div class="ec-pg-divider"></div>
|
||||
|
||||
<button class="ec-pg-btn ec-pg-btn--reset" id="ec-pg-reset" title="Reset playground">
|
||||
<svg class="ec-pg-icon" id="ec-pg-reset-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
|
||||
<a class="ec-pg-btn ec-pg-btn--deploy" href="https://github.com/emdash-cms/emdash" target="_blank" rel="noopener">
|
||||
Deploy your own
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
|
||||
</a>
|
||||
|
||||
<button class="ec-pg-btn ec-pg-close" id="ec-pg-dismiss" title="Dismiss toolbar">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#emdash-playground-toolbar {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 999999;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
#emdash-playground-toolbar {
|
||||
max-width: calc(100vw - 2rem);
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#emdash-playground-toolbar.ec-pg-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ec-pg-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 8px 16px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.ec-pg-inner {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
border-radius: .75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.ec-pg-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(234,179,8,0.2);
|
||||
color: #facc15;
|
||||
}
|
||||
|
||||
.ec-pg-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
/* Edit toggle */
|
||||
.ec-pg-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ec-pg-toggle input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.ec-pg-toggle-track {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.ec-pg-toggle input:checked + .ec-pg-toggle-track {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.ec-pg-toggle-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.ec-pg-toggle input:checked + .ec-pg-toggle-track .ec-pg-toggle-thumb {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
|
||||
.ec-pg-toggle-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.ec-pg-toggle input:checked ~ .ec-pg-toggle-label {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.ec-pg-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ec-pg-status--warning {
|
||||
color: #fbbf24;
|
||||
}
|
||||
|
||||
.ec-pg-status--expired {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.ec-pg-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ec-pg-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.ec-pg-btn--deploy {
|
||||
gap: 5px;
|
||||
padding: 5px 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: #facc15;
|
||||
background: rgba(234,179,8,0.12);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ec-pg-btn--deploy:hover {
|
||||
background: rgba(234,179,8,0.22);
|
||||
color: #fde047;
|
||||
}
|
||||
|
||||
.ec-pg-icon {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.ec-pg-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ec-pg-btn:disabled:hover {
|
||||
color: #888;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@keyframes ec-pg-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ec-pg-spinning .ec-pg-icon {
|
||||
animation: ec-pg-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* Edit mode: editable hover styles (mirrors the visual editing toolbar CSS) */
|
||||
body:has(#emdash-playground-toolbar[data-edit-mode="true"]) [data-emdash-ref] {
|
||||
transition: box-shadow 0.15s, background-color 0.15s;
|
||||
}
|
||||
|
||||
body:has(#emdash-playground-toolbar[data-edit-mode="true"]) [data-emdash-ref]:hover {
|
||||
box-shadow: 0 0 0 2px rgba(59,130,246,0.5);
|
||||
border-radius: 4px;
|
||||
background-color: rgba(59,130,246,0.04);
|
||||
cursor: text;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var toolbar = document.getElementById("emdash-playground-toolbar");
|
||||
var statusEl = document.getElementById("ec-pg-status");
|
||||
var resetBtn = document.getElementById("ec-pg-reset");
|
||||
var dismissBtn = document.getElementById("ec-pg-dismiss");
|
||||
var editToggle = document.getElementById("ec-pg-edit-toggle");
|
||||
if (!toolbar || !statusEl || !resetBtn || !dismissBtn || !editToggle) return;
|
||||
|
||||
var createdAt = toolbar.getAttribute("data-created-at");
|
||||
var ttl = parseInt(toolbar.getAttribute("data-ttl") || "3600", 10);
|
||||
|
||||
function getRemaining() {
|
||||
if (!createdAt) return 0;
|
||||
var created = new Date(createdAt).getTime();
|
||||
var expiresAt = created + ttl * 1000;
|
||||
return Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
function formatRemaining(seconds) {
|
||||
if (seconds <= 0) return "Expired";
|
||||
var m = Math.floor(seconds / 60);
|
||||
if (m >= 60) {
|
||||
var h = Math.floor(m / 60);
|
||||
m = m % 60;
|
||||
return h + "h " + m + "m";
|
||||
}
|
||||
return m + "m remaining";
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
var remaining = getRemaining();
|
||||
statusEl.textContent = formatRemaining(remaining);
|
||||
if (remaining <= 0) {
|
||||
statusEl.className = "ec-pg-status ec-pg-status--expired";
|
||||
} else if (remaining < 300) {
|
||||
statusEl.className = "ec-pg-status ec-pg-status--warning";
|
||||
} else {
|
||||
statusEl.className = "ec-pg-status";
|
||||
}
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
// Update every 30s -- no seconds shown so no need for frequent updates
|
||||
var interval = setInterval(updateStatus, 30000);
|
||||
|
||||
// Edit mode toggle -- sets cookie and reloads
|
||||
editToggle.addEventListener("change", function() {
|
||||
if (editToggle.checked) {
|
||||
document.cookie = "emdash-edit-mode=true;path=/;samesite=lax";
|
||||
toolbar.setAttribute("data-edit-mode", "true");
|
||||
} else {
|
||||
document.cookie = "emdash-edit-mode=;path=/;expires=Thu, 01 Jan 1970 00:00:00 GMT";
|
||||
toolbar.setAttribute("data-edit-mode", "false");
|
||||
}
|
||||
if (document.startViewTransition) {
|
||||
document.startViewTransition(function() { location.replace(location.href); });
|
||||
} else {
|
||||
location.replace(location.href);
|
||||
}
|
||||
});
|
||||
|
||||
resetBtn.addEventListener("click", function() {
|
||||
resetBtn.disabled = true;
|
||||
resetBtn.classList.add("ec-pg-spinning");
|
||||
statusEl.className = "ec-pg-status";
|
||||
statusEl.textContent = "Resetting\\u2026";
|
||||
location.href = "/_playground/reset";
|
||||
});
|
||||
|
||||
dismissBtn.addEventListener("click", function() {
|
||||
toolbar.classList.add("ec-pg-hidden");
|
||||
clearInterval(interval);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeAttr(str: string): string {
|
||||
return str
|
||||
.replace(RE_AMP, "&")
|
||||
.replace(RE_QUOT, """)
|
||||
.replace(RE_LT, "<")
|
||||
.replace(RE_GT, ">");
|
||||
}
|
||||
49
packages/cloudflare/src/db/playground.ts
Normal file
49
packages/cloudflare/src/db/playground.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Durable Object playground database -- RUNTIME ENTRY
|
||||
*
|
||||
* Provides a createDialect() that the virtual module system expects,
|
||||
* plus re-exports the DO class and playground middleware.
|
||||
*
|
||||
* In playground mode, the actual DB connection is always set by the
|
||||
* playground middleware via ALS (runWithContext). The createDialect
|
||||
* here creates a "dummy" dialect that will be overridden per-request.
|
||||
* If a query somehow runs without the middleware's ALS override,
|
||||
* the dialect throws a clear error.
|
||||
*
|
||||
* This module imports from cloudflare:workers transitively.
|
||||
* Do NOT import this at config time.
|
||||
*/
|
||||
|
||||
import type { Dialect } from "kysely";
|
||||
|
||||
import { PreviewDODialect } from "./do-dialect.js";
|
||||
import type { PreviewDBStub } from "./do-dialect.js";
|
||||
import type { PreviewDOConfig } from "./do-types.js";
|
||||
|
||||
/**
|
||||
* Create a playground DO dialect from config.
|
||||
*
|
||||
* Returns a dialect that throws if any query is executed outside of
|
||||
* the playground middleware's ALS context. In normal operation, the
|
||||
* middleware overrides this DB via runWithContext() on every request.
|
||||
*
|
||||
* This factory exists to satisfy the virtual module system's
|
||||
* createDialect() contract. The EmDash runtime creates a singleton
|
||||
* DB from it, but all actual queries go through the ALS-scoped DB.
|
||||
*/
|
||||
export function createDialect(_config: PreviewDOConfig): Dialect {
|
||||
const notInitialized: PreviewDBStub = {
|
||||
async query(): Promise<{ rows: Record<string, unknown>[] }> {
|
||||
throw new Error(
|
||||
"Playground database not initialized. " +
|
||||
"Ensure the playground middleware is registered in src/middleware.ts " +
|
||||
"and all requests go through it.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return new PreviewDODialect({ getStub: () => notInitialized });
|
||||
}
|
||||
|
||||
export { EmDashPreviewDB } from "./do-class.js";
|
||||
export { isBlockedInPlayground } from "./do-playground-routes.js";
|
||||
220
packages/cloudflare/src/db/preview-toolbar.ts
Normal file
220
packages/cloudflare/src/db/preview-toolbar.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Preview Toolbar
|
||||
*
|
||||
* A floating pill injected by the preview middleware into HTML responses.
|
||||
* Shows preview status, snapshot age, reload button, and errors.
|
||||
* No dependencies — plain HTML string with inline styles and a <script> tag.
|
||||
*/
|
||||
|
||||
export interface PreviewToolbarConfig {
|
||||
/** When the snapshot was generated (ISO string) */
|
||||
generatedAt?: string;
|
||||
/** Source site URL */
|
||||
source?: string;
|
||||
/** Error message if snapshot failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const RE_AMP = /&/g;
|
||||
const RE_QUOT = /"/g;
|
||||
const RE_LT = /</g;
|
||||
const RE_GT = />/g;
|
||||
|
||||
export function renderPreviewToolbar(config: PreviewToolbarConfig): string {
|
||||
const { generatedAt, source, error } = config;
|
||||
|
||||
const generatedAtAttr = generatedAt ? ` data-generated-at="${escapeAttr(generatedAt)}"` : "";
|
||||
const sourceAttr = source ? ` data-source="${escapeAttr(source)}"` : "";
|
||||
const errorAttr = error ? ` data-error="${escapeAttr(error)}"` : "";
|
||||
|
||||
return `
|
||||
<!-- EmDash Preview Toolbar -->
|
||||
<div id="emdash-preview-toolbar"${generatedAtAttr}${sourceAttr}${errorAttr}>
|
||||
<div class="ec-ptb-inner">
|
||||
<span class="ec-ptb-badge">Preview</span>
|
||||
|
||||
<div class="ec-ptb-divider"></div>
|
||||
|
||||
<span class="ec-ptb-status" id="ec-ptb-status"></span>
|
||||
|
||||
<button class="ec-ptb-btn" id="ec-ptb-reload" title="Reload snapshot">
|
||||
<svg class="ec-ptb-icon" id="ec-ptb-reload-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
||||
</button>
|
||||
|
||||
<button class="ec-ptb-btn ec-ptb-close" id="ec-ptb-dismiss" title="Dismiss toolbar">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#emdash-preview-toolbar {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 999999;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#emdash-preview-toolbar.ec-ptb-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ec-ptb-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px 8px 16px;
|
||||
background: #1a1a1a;
|
||||
color: #e0e0e0;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ec-ptb-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
background: rgba(139,92,246,0.2);
|
||||
color: #a78bfa;
|
||||
}
|
||||
|
||||
.ec-ptb-divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
background: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.ec-ptb-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.ec-ptb-status--error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.ec-ptb-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ec-ptb-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
|
||||
.ec-ptb-icon {
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.ec-ptb-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ec-ptb-btn:disabled:hover {
|
||||
color: #888;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@keyframes ec-ptb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.ec-ptb-spinning .ec-ptb-icon {
|
||||
animation: ec-ptb-spin 0.8s linear infinite;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var toolbar = document.getElementById("emdash-preview-toolbar");
|
||||
var statusEl = document.getElementById("ec-ptb-status");
|
||||
var reloadBtn = document.getElementById("ec-ptb-reload");
|
||||
var dismissBtn = document.getElementById("ec-ptb-dismiss");
|
||||
if (!toolbar || !statusEl || !reloadBtn || !dismissBtn) return;
|
||||
|
||||
var generatedAt = toolbar.getAttribute("data-generated-at");
|
||||
var source = toolbar.getAttribute("data-source");
|
||||
var error = toolbar.getAttribute("data-error");
|
||||
|
||||
function formatAge(isoString) {
|
||||
if (!isoString) return null;
|
||||
var then = new Date(isoString).getTime();
|
||||
var now = Date.now();
|
||||
var seconds = Math.floor((now - then) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
var minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return minutes + "m ago";
|
||||
var hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return hours + "h ago";
|
||||
return Math.floor(hours / 24) + "d ago";
|
||||
}
|
||||
|
||||
function updateStatus() {
|
||||
if (error) {
|
||||
statusEl.className = "ec-ptb-status ec-ptb-status--error";
|
||||
statusEl.textContent = error;
|
||||
return;
|
||||
}
|
||||
var age = formatAge(generatedAt);
|
||||
statusEl.className = "ec-ptb-status";
|
||||
statusEl.textContent = age ? "Snapshot " + age : "Preview mode";
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
|
||||
// Update age display every 30s
|
||||
var ageInterval = setInterval(updateStatus, 30000);
|
||||
|
||||
// Reload: hit the server endpoint which clears the httpOnly session cookie
|
||||
// and redirects back with the original signed params for a fresh snapshot.
|
||||
reloadBtn.addEventListener("click", function() {
|
||||
reloadBtn.disabled = true;
|
||||
reloadBtn.classList.add("ec-ptb-spinning");
|
||||
statusEl.className = "ec-ptb-status";
|
||||
statusEl.textContent = "Reloading\u2026";
|
||||
location.href = "/_preview/reload";
|
||||
});
|
||||
|
||||
// Dismiss
|
||||
dismissBtn.addEventListener("click", function() {
|
||||
toolbar.classList.add("ec-ptb-hidden");
|
||||
clearInterval(ageInterval);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeAttr(str: string): string {
|
||||
return str
|
||||
.replace(RE_AMP, "&")
|
||||
.replace(RE_QUOT, """)
|
||||
.replace(RE_LT, "<")
|
||||
.replace(RE_GT, ">");
|
||||
}
|
||||
Reference in New Issue
Block a user