Skip to content

Commit d332a65

Browse files
committed
Remove the shared default instance behind service hosts
A service host like github.emulators.dev used to be a stateful shared instance: a well-known URL with world-readable state and a world-writable control plane, which is exactly the polling target the unguessable instance names were introduced to prevent. Service hosts are now control plane only (manifest, quickstart, specs, coverage, connections, openapi, and instance creation); provider routes return a 404 that points at POST /_emulate/instances. Named instances, the path form, and the shared MCP preset instances are unchanged. The console no longer pins service hosts to a default instance and addresses instances via the apex path form.
1 parent 7644372 commit d332a65

12 files changed

Lines changed: 104 additions & 85 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,7 @@ github:
159159
All services are available on host-based routing when deployed: `github`, `gitlab`, `mcp`, `vercel`, `google`, `okta`, `microsoft`, `spotify`, `slack`, `apple`, `aws`, `resend`, `stripe`, `mongoatlas`, `clerk`, `x`, `workos`, `autumn`, and `posthog`. Each one supports three addressing forms:
160160

161161
```text
162-
https://github.emulators.dev # service host (no instance)
162+
https://github.emulators.dev # service host (control plane only)
163163
https://github.my-instance.emulators.dev # instance host
164164
https://emulators.dev/github/my-instance # local/path form
165165
```
@@ -170,7 +170,7 @@ Create instances with `POST /_emulate/instances`: the server generates an ungues
170170

171171
### Useful without an instance
172172

173-
The bare service host (for example `https://github.emulators.dev`) serves a service-level control plane so a human or agent can learn what the service is and connect without first creating an instance. It responds to `GET /_emulate`, `/_emulate/manifest`, `/_emulate/quickstart`, `/_emulate/specs`, `/_emulate/coverage`, `/_emulate/connections`, `/_emulate/openapi`, and `POST /_emulate/instances`.
173+
The bare service host (for example `https://github.emulators.dev`) serves a service-level control plane so a human or agent can learn what the service is and connect without first creating an instance. It responds to `GET /_emulate`, `/_emulate/manifest`, `/_emulate/quickstart`, `/_emulate/specs`, `/_emulate/coverage`, `/_emulate/connections`, `/_emulate/openapi`, and `POST /_emulate/instances`. There is no shared instance behind the service host: provider routes return a 404 pointing at instance creation, so all provider traffic happens on an instance of your own.
174174

175175
A global catalog lists every hosted service from any host, including the apex:
176176

apps/console/src/App.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useState } from "react";
22
import { Link, Route, Routes } from "react-router-dom";
3-
import { DEFAULT_INSTANCE, hostRoute } from "./api";
3+
import { hostRoute } from "./api";
44
import Home from "./views/Home";
55
import Service from "./views/Service";
66

@@ -53,11 +53,7 @@ export default function App() {
5353
<Route
5454
path="/"
5555
element={
56-
route.service ? (
57-
<Service serviceOverride={route.service} instanceOverride={route.instance ?? DEFAULT_INSTANCE} />
58-
) : (
59-
<Home />
60-
)
56+
route.service ? <Service serviceOverride={route.service} instanceOverride={route.instance} /> : <Home />
6157
}
6258
/>
6359
<Route path="/:service" element={<Service />} />

apps/console/src/api.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,6 @@ export const ORIGIN = typeof window !== "undefined" ? window.location.origin : "
77

88
const HOST_SUFFIX = "emulators.dev";
99

10-
// The service host (e.g. github.emulators.dev) is itself a default, stateful
11-
// instance. Named instances use the cert-safe path form on the apex.
12-
export const DEFAULT_INSTANCE = "default";
13-
1410
const onEmulatorsHost = (): boolean => {
1511
if (typeof window === "undefined") return false;
1612
const h = window.location.hostname;
@@ -55,10 +51,10 @@ export const serviceHost = (service: string): string => {
5551
export const base = (service: string, instance: string): string => {
5652
if (typeof window === "undefined") return "";
5753
const route = hostRoute();
58-
// On this service's own host, talk same-origin: the service host is the default
59-
// instance, and a matching named-instance host serves itself.
60-
if (route.service === service && (!route.instance || route.instance === instance)) return ORIGIN;
61-
// Otherwise address via the cert-safe path form on the apex.
54+
// A matching named-instance host serves itself same-origin. A bare service
55+
// host does not: it is control plane only (no shared default instance), so
56+
// instances are addressed via the cert-safe path form on the apex.
57+
if (route.service === service && route.instance === instance) return ORIGIN;
6258
return `${apexOrigin()}/${service}/${encodeURIComponent(instance)}`;
6359
};
6460

apps/web/app/docs/architecture/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ The core provides shared middleware:
8080
The `@emulators/cloudflare` package runs the same plugins on Cloudflare Workers with host-based routing. The `emulate-hosts` worker serves `emulators.dev/*` and `*.emulators.dev/*`:
8181

8282
- The apex `emulators.dev` is the emulator catalog, a links-out landing built from `GET /_emulate/services`. It is not the docs site.
83-
- A service host such as `github.emulators.dev` serves a service-level control plane and is useful before any instance exists.
83+
- A service host such as `github.emulators.dev` serves a service-level control plane and is useful before any instance exists. It has no shared instance of its own; provider traffic requires a created instance.
8484
- An instance host such as `github.my-run.emulators.dev` (or the compatibility path form `emulators.dev/github/my-run`) serves one stateful instance.
8585

8686
Each instance is backed by one Cloudflare Durable Object. The `EmulatorDurableObject` class is declared through a wrangler `migrations` entry (`new_classes`), and both the instance state and the request ledger are snapshotted to Durable Object storage so an instance survives eviction. Instances are created lazily through `POST /_emulate/instances`.

apps/web/app/docs/deployment/page.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Hosted services are available on host-based routing. There are four address form
2323
<tr>
2424
<td>Service host</td>
2525
<td><code>https://github.emulators.dev</code></td>
26-
<td>A single service, useful before you create an instance. Serves a service-level control plane</td>
26+
<td>A single service, control plane only. Serves the service-level control plane and instance creation; provider routes require an instance</td>
2727
</tr>
2828
<tr>
2929
<td>Instance host</td>
@@ -70,7 +70,7 @@ curl -s https://emulators.dev/_emulate/services
7070

7171
## Service host control plane
7272

73-
A bare service host such as `https://github.emulators.dev`, with no instance, serves a service-level control plane so it is useful before you create an instance:
73+
A bare service host such as `https://github.emulators.dev`, with no instance, serves a service-level control plane so it is useful before you create an instance. There is no shared instance behind it; provider routes return a 404 that points at instance creation:
7474

7575
- `GET /_emulate`, `/_emulate/manifest`, `/_emulate/quickstart`, `/_emulate/specs`, `/_emulate/coverage`, `/_emulate/connections`, `/_emulate/openapi`
7676
- `POST /_emulate/instances` to create an instance and get its URLs

packages/@emulators/cloudflare/src/__tests__/worker.test.ts

Lines changed: 39 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -102,56 +102,45 @@ describe("cloudflare worker routing", () => {
102102
expect(created.instance).toMatch(/^[0-9a-f]{24}$/);
103103
});
104104

105-
it("routes the service host to a default instance over the valid 1-label cert", async () => {
106-
const seen: Array<{
107-
idName: string;
108-
url: string;
109-
service: string | null;
110-
instance: string | null;
111-
baseUrl: string | null;
112-
}> = [];
105+
it("serves the service host as control plane only, with no shared default instance", async () => {
106+
let doHits = 0;
113107
const env: Env = {
114108
EMULATE_HOST_SUFFIX: "emulators.dev",
115109
EMULATOR: {
116110
idFromName: (n) => n,
117-
get: (id) => ({
118-
async fetch(request) {
119-
seen.push({
120-
idName: String(id),
121-
url: request.url,
122-
service: request.headers.get("x-emulator-service"),
123-
instance: request.headers.get("x-emulator-instance"),
124-
baseUrl: request.headers.get("x-emulator-base-url"),
125-
});
111+
get: () => ({
112+
async fetch() {
113+
doHits++;
126114
return Response.json({ ok: true });
127115
},
128116
}),
129117
},
130118
};
131119

132-
// Provider API and /_emulate both resolve to the default instance.
133-
await worker.fetch(
120+
// The service-level control plane answers without any instance (or DO call).
121+
const manifestRes = await worker.fetch(new Request("https://github.emulators.dev/_emulate/manifest"), env);
122+
expect(manifestRes.status).toBe(200);
123+
const manifest = (await manifestRes.json()) as { manifest: { id: string }; instance: unknown };
124+
expect(manifest.manifest.id).toBe("github");
125+
expect(manifest.instance).toBeNull();
126+
127+
// Provider routes have no shared instance behind the well-known host: they
128+
// point at instance creation instead of serving world-readable state.
129+
const provider = await worker.fetch(
134130
new Request("https://github.emulators.dev/user", { headers: { accept: "application/json" } }),
135131
env,
136132
);
137-
await worker.fetch(new Request("https://github.emulators.dev/_emulate/manifest"), env);
133+
expect(provider.status).toBe(404);
134+
await expect(provider.json()).resolves.toMatchObject({
135+
error: "instance_required",
136+
createInstance: "https://github.emulators.dev/_emulate/instances",
137+
});
138138

139-
expect(seen).toEqual([
140-
{
141-
idName: "github:default",
142-
url: "https://github.emulators.dev/user",
143-
service: "github",
144-
instance: "default",
145-
baseUrl: "https://github.emulators.dev",
146-
},
147-
{
148-
idName: "github:default",
149-
url: "https://github.emulators.dev/_emulate/manifest",
150-
service: "github",
151-
instance: "default",
152-
baseUrl: "https://github.emulators.dev",
153-
},
154-
]);
139+
// Same for instance-scoped control-plane routes like /_emulate/state.
140+
const state = await worker.fetch(new Request("https://github.emulators.dev/_emulate/state"), env);
141+
expect(state.status).toBe(404);
142+
143+
expect(doHits).toBe(0);
155144
});
156145

157146
it("serves the SPA to browser navigations but the no-JS landing to agents", async () => {
@@ -174,12 +163,23 @@ describe("cloudflare worker routing", () => {
174163
env,
175164
);
176165
expect(browser.headers.get("content-type")).toContain("text/html");
177-
expect(doHits).toBe(0); // SPA served directly, no DO call
178166

167+
// Agent root gets the server-rendered service landing; agents asking for
168+
// JSON get the service-level manifest. Neither touches a Durable Object.
179169
const agent = await worker.fetch(new Request("https://github.emulators.dev/", { headers: { accept: "*/*" } }), env);
180170
expect(agent.status).toBe(200);
181-
expect(doHits).toBe(1); // agent root forwarded to the default instance's /_emulate landing
182-
await expect(agent.json()).resolves.toMatchObject({ path: "/_emulate" });
171+
expect(agent.headers.get("content-type")).toContain("text/html");
172+
expect(await agent.text()).toContain("Create an instance");
173+
174+
const agentJson = await worker.fetch(
175+
new Request("https://github.emulators.dev/", { headers: { accept: "application/json" } }),
176+
env,
177+
);
178+
expect(agentJson.status).toBe(200);
179+
const body = (await agentJson.json()) as { manifest: { id: string } };
180+
expect(body.manifest.id).toBe("github");
181+
182+
expect(doHits).toBe(0);
183183
});
184184

185185
it("lists the deployed service catalog from any host", async () => {

packages/@emulators/cloudflare/src/console-html.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

packages/@emulators/cloudflare/src/worker.ts

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { buildInstanceCreation, randomInstanceName, renderCatalogPage, servicesCatalog } from "@emulators/core";
1+
import {
2+
buildInstanceCreation,
3+
randomInstanceName,
4+
renderCatalogPage,
5+
serviceHostControlPlane,
6+
servicesCatalog,
7+
} from "@emulators/core";
28
import { EmulatorDurableObject } from "./durable-object.js";
39
import { SERVICES } from "./services.js";
410
import { SERVICE_ICONS } from "./icons.js";
@@ -37,9 +43,6 @@ export interface Env {
3743
}
3844

3945
const DEFAULT_HOST_SUFFIX = "emulators.dev";
40-
// The service host (e.g. github.emulators.dev) IS a usable, stateful instance.
41-
// Named, isolated instances use the cert-safe path form emulators.dev/<svc>/<id>.
42-
const DEFAULT_INSTANCE = "default";
4346
// The instance segment selects the MCP surface's auth/scope preset:
4447
// - oauth | bearer | query: how `/mcp` authenticates.
4548
// - scope-discovery: oauth, but the protected-resource metadata stays silent on
@@ -86,7 +89,7 @@ async function forwardToDurableObject(
8689
}
8790

8891
// Router:
89-
// - `<service>.<suffix>` -> the service host == a default stateful instance.
92+
// - `<service>.<suffix>` -> the service-level control plane (no shared instance).
9093
// - `<service>.<instance>.<suffix>` -> a named instance host (requires a 2-label cert).
9194
// - `/<service>/<instance>/...` -> the cert-safe path form (named instances, local dev).
9295
// - apex / bare `/<service>` -> the catalog (SPA for browsers, server-rendered for agents).
@@ -121,8 +124,11 @@ export default {
121124
});
122125
}
123126

124-
// SERVICE HOST: <service>.<suffix> with no instance label. The host itself is a
125-
// default, stateful instance, served same-origin over the valid 1-label cert.
127+
// SERVICE HOST: <service>.<suffix> with no instance label. Control plane
128+
// only — there is deliberately NO shared default instance behind it. A
129+
// well-known host with world-readable state and a world-writable control
130+
// plane is exactly the polling target the unguessable instance names exist
131+
// to prevent, so provider traffic requires an instance of your own.
126132
if (hostRoute?.service && !hostRoute.instance) {
127133
const service = hostRoute.service;
128134
const entry = SERVICES[service];
@@ -148,26 +154,45 @@ export default {
148154
if (!entry) return html(consoleHtml);
149155

150156
// Root: browsers get the interactive console; agents/raw fetches get the
151-
// server-rendered default-instance landing (no JS) or its JSON manifest.
157+
// server-rendered service landing (no JS) or the service-level manifest.
152158
if (url.pathname === "/" || url.pathname === "") {
153159
if (isBrowserNavigation(request)) return html(consoleHtml);
154-
return forwardToDurableObject(env, request, {
160+
const landing = serviceHostControlPlane(wantsJson(request) ? "/_emulate/manifest" : "/_emulate", "GET", {
161+
manifest: entry.manifest,
155162
service,
156-
instance: DEFAULT_INSTANCE,
157-
baseUrl: `${url.protocol}//${service}.${suffix}`,
158-
innerPath: wantsJson(request) ? "/_emulate/manifest" : "/_emulate",
159-
search: "",
163+
origin: apexOrigin,
164+
protocol: url.protocol,
165+
hostSuffix: suffix,
166+
ledgerPersistent: true,
160167
});
168+
if (landing) return landing;
161169
}
162170

163-
// Everything else (provider API + /_emulate/*) → the default instance.
164-
return forwardToDurableObject(env, request, {
171+
// Service-level control plane (manifest, quickstart, specs, coverage,
172+
// connections, openapi) — answerable without any instance.
173+
const controlPlane = serviceHostControlPlane(url.pathname, request.method, {
174+
manifest: entry.manifest,
165175
service,
166-
instance: DEFAULT_INSTANCE,
167-
baseUrl: `${url.protocol}//${service}.${suffix}`,
168-
innerPath: url.pathname,
169-
search: url.search,
176+
origin: apexOrigin,
177+
protocol: url.protocol,
178+
hostSuffix: suffix,
179+
ledgerPersistent: true,
170180
});
181+
if (controlPlane) return controlPlane;
182+
183+
// Browsers exploring other paths still get the console SPA.
184+
if (isBrowserNavigation(request)) return html(consoleHtml);
185+
186+
// Provider routes have no shared instance to serve: point the caller at
187+
// instance creation instead.
188+
return Response.json(
189+
{
190+
error: "instance_required",
191+
message: `${service}.${suffix} is a service host with no shared instance. Create one with POST ${url.protocol}//${service}.${suffix}/_emulate/instances and use the returned providerBaseUrl.`,
192+
createInstance: `${url.protocol}//${service}.${suffix}/_emulate/instances`,
193+
},
194+
{ status: 404 },
195+
);
171196
}
172197

173198
// NAMED INSTANCE HOST: <service>.<instance>.<suffix> (needs a 2-label cert).

packages/@emulators/core/src/service-host.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ export function renderCatalogPage(
187187
<div class="s-card">
188188
<div class="section-heading">For agents</div>
189189
<p class="info-text">Machine-readable catalog: <a href="/_emulate/services">/_emulate/services</a></p>
190-
<p class="info-text">Each service host serves <code>/_emulate/manifest</code>, <code>/_emulate/quickstart</code>, and <code>/_emulate/connections</code>, and the provider API directly against a default instance.</p>
190+
<p class="info-text">Each service host serves <code>/_emulate/manifest</code>, <code>/_emulate/quickstart</code>, and <code>/_emulate/connections</code>, plus <code>POST /_emulate/instances</code> to create a private instance for provider traffic.</p>
191191
</div>
192192
`,
193193
);

packages/emulate/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ Global catalog:
6262
autumn, posthog, and mcp.
6363
Microsoft Graph includes OneDrive file content upload/download routes under
6464
/v1.0/me/drive and /v1.0/drives/:driveId.
65-
Service host: <service>.emulators.dev (useful without an instance; serves
66-
a service-level /_emulate control plane)
65+
Service host: <service>.emulators.dev (control plane only, no shared
66+
instance; create one via POST /_emulate/instances)
6767
Instance host: <service>.<instance>.emulators.dev
6868
Local/path form: <origin>/<service>/<instance>
6969

0 commit comments

Comments
 (0)