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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/console/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Emulate</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
2 changes: 1 addition & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default function App() {
<div className="shell">
<div className="row">
<Link to="/" className="brand">
<span className="dot" /> Emulate
emulate
</Link>
<nav className="nav">{route.service && <a href="https://emulators.dev">All emulators</a>}</nav>
</div>
Expand Down
12 changes: 11 additions & 1 deletion apps/console/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,17 @@ async function getJson<T>(url: string): Promise<T> {

// Control-plane fetchers -------------------------------------------------------

export const fetchServices = (): Promise<{ services: CatalogEntry[] }> => getJson(`${ORIGIN}/_emulate/services`);
// The catalog is a static registry the worker inlines into the served HTML
// (window.__EMULATE_SERVICES__), so the console has it on first paint with no
// fetch. Read it synchronously; only fall back to the endpoint in dev.
export const injectedServices = (): CatalogEntry[] | null =>
(typeof window !== "undefined" && window.__EMULATE_SERVICES__?.services) || null;

export const fetchServices = (): Promise<{ services: CatalogEntry[] }> => {
const inlined = injectedServices();
if (inlined) return Promise.resolve({ services: inlined });
return getJson(`${ORIGIN}/_emulate/services`);
};

export const fetchManifest = (service: string, instance: string): Promise<ManifestResponse> =>
getJson(`${controlBase(service, instance)}/manifest`);
Expand Down
149 changes: 85 additions & 64 deletions apps/console/src/styles.css
Original file line number Diff line number Diff line change
@@ -1,99 +1,120 @@
/* Registry-grade minimal, per executor/design.md (the Useful Software Co brand).
Grayscale only; the single hue is destructive red. Geist for UI/prose, Geist
Mono for all metadata. Hierarchy from tone and hairlines, not shadow or color.
Dark palette (design.md Dark values): the provider brand marks served by the
icon service are white monochrome, so the catalog is dark for them to read. */
:root {
--bg: #0b0e14;
--panel: #11161f;
--panel2: #0d1219;
--line: #222a35;
--fg: #d6dae0;
--muted: #8b949e;
--accent: #7ee787;
--blue: #58a6ff;
--amber: #d29922;
--red: #f85149;
--bg: #0a0a0a; /* page */
--panel: #0f0f0f; /* cards, separated from the page by a hairline */
--panel2: #141414; /* quiet secondary fill */
--line: #1f1f1f; /* default hairline */
--line-strong: #333333; /* field / heavy stroke */
--fg: #ededed; /* primary text */
--muted: #9a9a9a; /* secondary text, metadata */
--muted2: #777777; /* tertiary text, eyebrows */
--accent-surface: #1a1a1a; /* hover / selected tint */
--primary: #ffffff; /* solid fill for the one key action */
--primary-fg: #0a0a0a;
--red: #e0726a; /* destructive, the only hue */
}

* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--fg); font: 15px/1.55 -apple-system, system-ui, sans-serif; }
a { color: var(--blue); text-decoration: none; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font: 15px/1.55 "Geist", ui-sans-serif, system-ui, -apple-system, sans-serif;
letter-spacing: -0.011em;
-webkit-font-smoothing: antialiased;
}
a { color: var(--fg); text-decoration: none; }
a:hover { color: var(--fg); }
code, .mono { font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; }
::selection { background: var(--fg); color: var(--bg); }

/* buttons: secondary (outline) by default, .go is the one primary fill */
button {
background: #1b2230; border: 1px solid var(--line); color: var(--fg);
border-radius: 8px; padding: 7px 12px; cursor: pointer; font-size: 13px;
background: transparent; border: 1px solid var(--line-strong); color: var(--fg);
border-radius: 6px; padding: 7px 12px; cursor: pointer; font-size: 13px; font-weight: 500;
font-family: inherit; transition: background-color .15s, border-color .15s;
}
button:hover { border-color: #3a4658; }
button.go { background: #10371f; border-color: #1f5132; color: var(--accent); }
button:hover { background: var(--accent-surface); }
button.go { background: var(--primary); border-color: var(--primary); color: var(--primary-fg); }
button.go:hover { background: var(--primary); opacity: 0.9; }
button.sm { padding: 4px 9px; font-size: 12px; }
.button {
background: #1b2230; border: 1px solid var(--line); color: var(--fg);
border-radius: 8px; padding: 7px 12px; font-size: 13px; display: inline-flex;
align-items: center; justify-content: center; gap: 6px;
background: transparent; border: 1px solid var(--line-strong); color: var(--fg);
border-radius: 6px; padding: 7px 12px; font-size: 13px; font-weight: 500; display: inline-flex;
align-items: center; justify-content: center; gap: 6px; transition: background-color .15s, border-color .15s;
}
.button:hover { border-color: #3a4658; color: var(--fg); }
.button:hover { background: var(--accent-surface); color: var(--fg); }
.button.sm { padding: 4px 9px; font-size: 12px; }
input, select {
background: #0b0e14; border: 1px solid var(--line); color: var(--fg);
border-radius: 8px; padding: 7px 10px; font-size: 13px;
background: var(--bg); border: 1px solid var(--line-strong); color: var(--fg);
border-radius: 6px; padding: 7px 10px; font-size: 13px; font-family: inherit;
}
input.mono { font-family: ui-monospace, monospace; }
input.mono { font-family: "Geist Mono", ui-monospace, monospace; }

/* layout */
.shell { max-width: 1100px; margin: 0 auto; padding: 0 22px; }
.topbar { border-bottom: 1px solid var(--line); position: sticky; top: 0; background: rgba(11,14,20,.85); backdrop-filter: blur(8px); z-index: 5; }
.topbar { border-bottom: 1px solid var(--line); position: sticky; top: 0; background: color-mix(in srgb, var(--bg) 85%, transparent); backdrop-filter: blur(8px); z-index: 5; }
.topbar .row { display: flex; align-items: center; gap: 14px; padding: 12px 0; }
.brand { font-weight: 700; display: flex; align-items: center; gap: 8px; }
.brand .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--accent); }
.brand { font-family: "Geist Mono", ui-monospace, monospace; font-weight: 500; font-size: 15px; letter-spacing: -0.01em; display: flex; align-items: center; gap: 8px; color: var(--fg); }
.nav { display: flex; gap: 6px; margin-left: auto; flex-wrap: wrap; }
.nav a { padding: 5px 11px; border-radius: 99px; border: 1px solid transparent; color: var(--muted); font-size: 13px; display: flex; gap: 6px; align-items: center; }
.nav a:hover { color: var(--fg); }
.nav a.on { color: var(--fg); border-color: var(--line); background: var(--panel); }
.nav a { padding: 5px 11px; border-radius: 6px; border: 1px solid transparent; color: var(--muted); font-size: 13px; display: flex; gap: 6px; align-items: center; transition: color .15s, background-color .15s; }
.nav a:hover { color: var(--fg); background: var(--accent-surface); }
.nav a.on { color: var(--fg); border-color: var(--line); background: var(--panel2); }
.nav img, .ico { width: 16px; height: 16px; object-fit: contain; }

.content { padding: 26px 0 80px; }
h1 { font-size: 26px; margin: 0 0 4px; }
h2 { font-size: 17px; margin: 26px 0 12px; }
.lead { color: var(--muted); margin: 0 0 22px; max-width: 720px; }
h1 { font-size: 28px; font-weight: 600; letter-spacing: -0.03em; margin: 0 0 6px; }
h2 { font-size: 17px; font-weight: 600; letter-spacing: -0.02em; margin: 26px 0 12px; }
.lead { color: var(--muted); margin: 0 0 22px; max-width: 720px; line-height: 1.6; }
.eyebrow { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted2); margin: 0 0 12px; }
.hero { display: flex; justify-content: space-between; align-items: flex-start; gap: 18px; margin-bottom: 12px; }
.hero-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }

/* cards */
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.service-grid { display: grid; grid-template-columns: 1fr; gap: 14px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; display: block; color: inherit; }
.card.link:hover { border-color: #3a4658; }
.service-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 11px; padding: 16px; display: block; color: inherit; }
.card.link:hover { border-color: var(--line-strong); }
.card .hd { display: flex; align-items: center; gap: 10px; }
.card .hd img, .card .hd .emoji { width: 26px; height: 26px; object-fit: contain; font-size: 22px; line-height: 26px; text-align: center; }
.card h3 { margin: 0; font-size: 16px; }
.card .b { color: var(--muted); font-size: 13px; margin: 10px 0 0; }
.pill { font-size: 11px; border-radius: 99px; padding: 2px 8px; border: 1px solid var(--line); color: var(--muted); margin-left: auto; }
.card h3 { margin: 0; font-size: 16px; font-weight: 600; }
.card .b { color: var(--muted); font-size: 13px; margin: 10px 0 0; line-height: 1.55; }
.pill { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; border-radius: 9999px; padding: 2px 8px; border: 1px solid var(--line); color: var(--muted); margin-left: auto; }
.service-card { display: grid; grid-template-columns: minmax(190px, .55fr) minmax(280px, 1fr); gap: 14px; }
.service-head { grid-column: 1; }
.service-title { color: var(--fg); display: inline-flex; align-items: center; gap: 10px; font-weight: 700; }
.service-title:hover { color: var(--blue); }
.service-title { color: var(--fg); display: inline-flex; align-items: center; gap: 10px; font-weight: 600; }
.service-title:hover { color: var(--fg); }
.service-card .b { grid-column: 1; align-self: start; margin-top: -4px; }
.endpoint-list { grid-column: 2; grid-row: 1 / span 3; display: grid; gap: 6px; }
.endpoint {
display: grid; grid-template-columns: 72px minmax(0, 1fr); gap: 10px; align-items: baseline;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px;
}
.endpoint span { color: var(--muted); }
.endpoint a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.endpoint a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg); }
.service-actions { grid-column: 1; display: flex; gap: 8px; flex-wrap: wrap; align-self: end; }

/* panels / rows */
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 14px; padding: 16px; margin-bottom: 16px; }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 11px; padding: 16px; margin-bottom: 16px; }
.panel .ph { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.panel .ph h2 { margin: 0; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.spread { justify-content: space-between; }
.muted { color: var(--muted); }
.kv { font-family: ui-monospace, monospace; font-size: 12.5px; }
.kv { font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px; }
.kv b { color: var(--muted); font-weight: 500; }
.tag { font-size: 11px; padding: 2px 7px; border-radius: 6px; border: 1px solid var(--line); color: var(--muted); }
.tag.ok { color: var(--accent); border-color: #1f5132; }
.tag { font-family: "Geist Mono", ui-monospace, monospace; font-size: 11px; padding: 2px 7px; border-radius: 5px; border: 1px solid var(--line); color: var(--muted); }
.tag.ok { color: var(--fg); border-color: var(--line-strong); }

/* app rows (spotify creds) */
/* app rows (oauth creds) */
.app { border: 1px solid var(--line); border-radius: 10px; padding: 11px 13px; margin-top: 10px; background: var(--panel2); }
.app .cred { font-family: ui-monospace, monospace; font-size: 12.5px; color: var(--muted); margin-top: 4px; word-break: break-all; }
.app .cred b { color: var(--fg); }
.app.active { border-color: #1f5132; }
.app .cred { font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px; color: var(--muted); margin-top: 4px; word-break: break-all; }
.app .cred b { color: var(--fg); font-weight: 500; }
.app.active { border-color: var(--line-strong); }

/* result cards */
.results { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap: 10px; }
Expand All @@ -103,44 +124,44 @@ h2 { font-size: 17px; margin: 26px 0 12px; }
.res button { margin-top: 8px; }

/* json + log */
pre.json { background: #06080d; border: 1px solid var(--line); border-radius: 10px; padding: 12px; overflow: auto; max-height: 360px; font-size: 12.5px; margin: 0; white-space: pre-wrap; word-break: break-word; }
.req { color: var(--blue); } .okc { color: var(--accent); } .errc { color: var(--red); }
pre.json { background: var(--panel2); border: 1px solid var(--line); border-radius: 10px; padding: 12px; overflow: auto; max-height: 360px; font-family: "Geist Mono", ui-monospace, monospace; font-size: 12.5px; margin: 0; white-space: pre-wrap; word-break: break-word; }
.req { color: var(--fg); } .okc { color: var(--fg); } .errc { color: var(--red); }
.copy { font-size: 11px; }
.empty { color: var(--muted); font-style: italic; padding: 8px 0; }

/* monogram, simple catalog card */
/* monogram fallback (used only when a real brand icon is missing) */
.mono-badge {
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; background: #1b2230; border: 1px solid var(--line);
color: var(--accent); font-weight: 700; flex-shrink: 0;
border-radius: 8px; background: var(--panel2); border: 1px solid var(--line);
color: var(--muted); font-family: "Geist Mono", ui-monospace, monospace; font-weight: 500; flex-shrink: 0;
}
.service-card-simple { transition: border-color .15s; }
.service-card-simple:hover { border-color: #3a4658; }
.service-card-simple .service-title { color: var(--fg); font-weight: 700; }
.service-card-simple:hover { border-color: var(--line-strong); }
.service-card-simple .service-title { color: var(--fg); font-weight: 600; }

/* tabs */
.tabs { display: flex; gap: 4px; flex-wrap: wrap; margin: 6px 0 18px; border-bottom: 1px solid var(--line); }
.tab {
background: transparent; border: 1px solid transparent; border-bottom: none;
border-radius: 8px 8px 0 0; color: var(--muted); padding: 8px 14px; font-size: 13px;
border-radius: 6px 6px 0 0; color: var(--muted); padding: 8px 14px; font-size: 13px; font-weight: 500;
}
.tab:hover { color: var(--fg); border-color: transparent; }
.tab:hover { color: var(--fg); background: transparent; border-color: transparent; }
.tab.on { color: var(--fg); border-color: var(--line); background: var(--panel); margin-bottom: -1px; }

/* data tables */
.data-table { width: 100%; border-collapse: collapse; }
.data-table th, .data-table td {
text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--line); font-size: 13px; vertical-align: top;
}
.data-table th { color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
.data-table th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; font-family: "Geist Mono", ui-monospace, monospace; }
.data-table tbody tr:last-child td { border-bottom: none; }
.tag.err { color: var(--red); border-color: #5b1f1f; }
.tag.err { color: var(--red); border-color: color-mix(in srgb, var(--red) 40%, var(--line)); }
.ledger-table .ledger-row { cursor: pointer; }
.ledger-table .ledger-row:hover td { background: var(--panel2); }

textarea {
background: #0b0e14; border: 1px solid var(--line); color: var(--fg);
border-radius: 8px; padding: 10px; font-size: 12.5px; font-family: ui-monospace, monospace;
background: var(--bg); border: 1px solid var(--line-strong); color: var(--fg);
border-radius: 6px; padding: 10px; font-size: 12.5px; font-family: "Geist Mono", ui-monospace, monospace;
}
select { cursor: pointer; }
@media (max-width: 820px) {
Expand Down
7 changes: 7 additions & 0 deletions apps/console/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export interface CatalogEntry {
manifest: string;
}

declare global {
interface Window {
// Catalog inlined into the served HTML by the worker (static registry).
__EMULATE_SERVICES__?: { services: CatalogEntry[] };
}
}

export interface LedgerEntry {
id: string;
correlationId: string;
Expand Down
15 changes: 11 additions & 4 deletions apps/console/src/views/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,26 @@
import { useEffect, useState } from "react";
import { fetchServices, serviceHost } from "../api";
import { fetchServices, injectedServices, serviceHost } from "../api";
import type { CatalogEntry } from "../types";
import { ServiceIcon } from "../App";

export default function Home() {
const [services, setServices] = useState<CatalogEntry[] | null>(null);
// The worker inlines the static catalog, so it's present on first paint (no
// loading state). Fall back to fetching only when it wasn't injected (dev).
const [services, setServices] = useState<CatalogEntry[] | null>(() => injectedServices());
const [error, setError] = useState<string | null>(null);

useEffect(() => {
if (services) return;
fetchServices()
.then((r) => setServices(r.services))
.catch((e) => setError(String(e)));
}, []);
}, [services]);

return (
<>
<div className="hero">
<div>
<p className="eyebrow">Service catalog</p>
<h1>Emulate</h1>
<p className="lead">
Stateful integration emulators for real developer APIs. Pick a service, create an isolated instance, copy a
Expand All @@ -36,7 +40,10 @@ export default function Home() {
</p>

{error && <div className="empty">Could not load the service catalog ({error}).</div>}
{!services && !error && <div className="empty">Loading services...</div>}

{services && services.length > 0 && (
<p className="eyebrow">All emulators · {services.length}</p>
)}

<div className="service-grid">
{(services ?? []).map((s) => (
Expand Down
3 changes: 1 addition & 2 deletions packages/@emulators/cloudflare/src/console-html.ts

Large diffs are not rendered by default.

16 changes: 15 additions & 1 deletion packages/@emulators/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ function serviceCatalogEntries() {
}));
}

// The catalog is a static registry, so inline it into the served SPA HTML: the
// console renders the grid on first paint, with no client fetch and no loading
// state. JSON `<` is escaped so a description can never break out of the script.
async function serveCatalogConsole(ctx: { origin: string; protocol: string; hostSuffix: string }): Promise<Response> {
const json = (await servicesCatalog(serviceCatalogEntries(), ctx).text()).replace(/</g, "\\u003c");
return html(
consoleHtml.replace(
'<div id="root"></div>',
`<script>window.__EMULATE_SERVICES__=${json}</script><div id="root"></div>`,
),
);
}

interface EmulatorNamespace {
idFromName(name: string): unknown;
get(id: unknown): { fetch(request: Request): Promise<Response> };
Expand Down Expand Up @@ -183,7 +196,8 @@ export default {
// Apex root or bare `/<service>` → the catalog. Browser → SPA; agent → a
// server-rendered list of emulators (readable without running JS).
if (!service || !instance) {
if (isBrowserNavigation(request)) return html(consoleHtml);
if (isBrowserNavigation(request))
return serveCatalogConsole({ origin: apexOrigin, protocol: url.protocol, hostSuffix: suffix });
if (wantsJson(request)) {
return servicesCatalog(serviceCatalogEntries(), {
origin: apexOrigin,
Expand Down
Loading