Skip to content

Provide and host an embedded surface

A surface is a piece of one app’s UI that another app (or the shell dashboard) can host inside itself. The shell brokers a nested iframe; you write two ordinary components — one that embeds, one that is embedded.

A host app contains a nested embedded surface; the provider app's surface is piped into it, brokered by the shell.

A host that shows one surface and swaps it as the selection changes wants surfaceHost: call it with the provider’s id, surface name, and a mount element, then render(params). The first render embeds the provider’s view; later renders re-parameterise it in place — no remount. Pass onError to catch a denied or torn-down embed.

invoice-panel.ts
import { Component, ElementRef, OnDestroy, inject, viewChild } from "@angular/core";
import { surfaceHost, type SurfaceHost } from "@platform/sdk";
import { PLATFORM } from "../shared/platform.token";
// Hosting another app's surface. surfaceHost wraps embedSurface for the common "one slot that swaps as
// the selection changes" case: the first render() embeds the provider's view into our mount; later
// render()s re-parameterise it in place (no remount). onError captures the easy-to-miss ready
// rejection — a denied embed, or a teardown before first paint.
@Component({
selector: "invoice-panel",
template: `<div #mount></div>`,
})
export class InvoicePanel implements OnDestroy {
private readonly mount = viewChild.required<ElementRef<HTMLElement>>("mount");
private readonly platform = inject(PLATFORM);
private host: SurfaceHost | null = null;
show(customerEmail: string): void {
this.host ??= surfaceHost(this.platform, {
providerId: "billing",
surfaceName: "invoice-list",
mount: this.mount().nativeElement,
onError: (e) =>
this.platform.log("warn", "invoice surface failed to embed", { code: e.code }),
});
this.host.render({ customerEmail });
}
ngOnDestroy(): void {
this.host?.close();
}
}

A provider declares the surface in its contract and registers a loader for it in bootstrap. Declare it under provides:

// in contract.ts
provides: [{ kind: "surface", name: "invoice-list", contexts: ["embed", "dashboard"] }],

Register the loader in bootstrap and mount it with the SurfaceConnection injected through a token. bootstrap runs only the path taken — a top-level load runs mountApp, an embedded-surface load runs mountSurface:

src/main.ts
import { reflectComponentType, type Type } from "@angular/core";
import { bootstrapApplication } from "@angular/platform-browser";
import { bootstrap } from "@platform/sdk";
import { standalonePlatform } from "@platform/dev-harness";
import { PLATFORM } from "../shared/platform.token";
import { SURFACE_CONN } from "../shared/surface.token";
import { contract } from "../quickstart/contract";
// An app that PROVIDES a surface registers a loader for it under the surface's contract name. bootstrap
// invokes only the path taken: a top-level load runs mountApp; an embedded-surface load runs
// mountSurface with the connected SurfaceConnection injected through SURFACE_CONN.
void bootstrap<Type<unknown>, Type<unknown>>({
contract,
app: () => import("../quickstart/app").then((m) => m.App),
surfaces: {
"invoice-list": () => import("./embed-surface.provider").then((m) => m.InvoiceListSurface),
},
fallback: () => standalonePlatform({ appId: "billing" }),
mountApp: (app, platform) =>
bootstrapApplication(app, { providers: [{ provide: PLATFORM, useValue: platform }] }),
mountSurface: (surface, conn) => {
const selector = reflectComponentType(surface)?.selector;
if (!selector) {
throw new Error(`surface "${conn.surfaceName}" is not a component`);
}
document.body.appendChild(document.createElement(selector));
return bootstrapApplication(surface, {
providers: [{ provide: SURFACE_CONN, useValue: conn }],
});
},
}).catch((err) => console.error(err));

The surface component reads its SurfaceConnection instead of the top-level Platform. Call conn.activate(el) with its root element: that observes the element so the shell can size the iframe, then signals ready() one frame later — once content (including <ds-*> custom elements) has settled, so the first height the shell sees is the final one. (observe() and ready() remain available if you need to drive them separately.)

invoice-list-surface.ts
import { Component, ElementRef, OnInit, inject, signal } from "@angular/core";
import { SURFACE_CONN } from "../shared/surface.token";
interface Invoice {
id: string;
total: number;
}
// The provider side of a surface: a normal component that reads its SurfaceConnection instead of the
// top-level Platform. conn.activate(el) is the one-call bring-up — it observes the host element (so
// the shell can size the iframe) and calls ready() one frame later, when content has settled. onParams
// re-runs it when the host re-parameterises; onRefresh handles a dashboard refresh.
@Component({
selector: "invoice-list-surface",
template: `
<ul>
@for (inv of invoices(); track inv.id) {
<li>{{ inv.id }} — {{ inv.total }}</li>
}
</ul>
`,
})
export class InvoiceListSurface implements OnInit {
private readonly conn = inject(SURFACE_CONN);
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
protected readonly invoices = signal<Invoice[]>([]);
ngOnInit(): void {
this.conn.activate(this.host.nativeElement);
this.conn.onParams((params) => void this.load(params as { customerEmail?: string }));
this.conn.onRefresh(() => void this.load(this.conn.params as { customerEmail?: string }));
void this.load(this.conn.params as { customerEmail?: string });
}
private async load(params: { customerEmail?: string }): Promise<void> {
const res = await fetch(
`/api/invoices?customer=${encodeURIComponent(params.customerEmail ?? "")}`,
{
credentials: "same-origin",
},
);
this.invoices.set((await res.json()) as Invoice[]);
}
}