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:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View 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),
};
}
}

View 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;
}

View 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,
);
}
}

View 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");
}
}

View 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;
}

View 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;
}

View 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}`));
}

View 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&hellip;</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,
});
}

View 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;
}

View 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";

View 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&hellip;</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>`;
}

View 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,
});
});

View 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, "&amp;")
.replace(RE_QUOT, "&quot;")
.replace(RE_LT, "&lt;")
.replace(RE_GT, "&gt;");
}

View 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";

View 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, "&amp;")
.replace(RE_QUOT, "&quot;")
.replace(RE_LT, "&lt;")
.replace(RE_GT, "&gt;");
}