Before you start
The install and the first shell build take longer than any step in the workshop. Arriving with a working shell is the difference between finishing and not.
00.1 What "external applet" means
An applet is a Module Federation remote. The shell is the host: it fetches your
applet's manifest at startup, imports the module you expose as ./main,
and drives its lifecycle. External means that remote lives in its own
repository, is built by its own yarn build, and is served from its own
origin — the platform has no file that mentions it.
Three names do all the work, and they turn up in every step below:
| Name | Is | Yours will be |
|---|---|---|
| scope | the remote's globally unique name | ws_you |
| url | where the built remote is served from | https://localhost.leap365.com:3001 |
| module | which expose to load from it | ./main, later ./CounterWidget |
00.2 Prerequisites
- Node 22+ and Corepack (
corepack enable). Yarn 4 is pinned per project; Yarn 1 understands none of what we use. - On the LEAP VPN.
@canopus/foundationpulls@leap/*and@leapdev/*, and the React track uses@starlens/react. Both come from LEAP's Artifactory. - Windows only: OpenSSL on
PATH—winget install --id ShiningLight.OpenSSL.Dev -e. Dev certificates are generated with it. - A
canopuscheckout, built. The@canopus/*packages are not published to a registry yet, so your applet resolves them from a sibling working copy. - A
.env.localin that checkout, with the hostname set — 00.4. The committed.envleaves it blank on purpose. - The shell runs, and you can sign in to it with a LEAP test account.
00.3 Build the platform
yarn install
yarn bootstrap # builds @canopus/devcert, @canopus/build and the stacks
yarn bootstrap is the one that matters: @canopus/build is
consumed as compiled output, and it is what gives you the canopus CLI
your own project will run.
00.4 Give the platform checkout its .env.local
The canopus repo commits a .env whose
CANOPUS_HOSTNAME and NODE_ENV are deliberately
empty — those are per-developer, so they belong in the gitignored
.env.local beside it. Create it before you start the shell:
cp .env .env.local
Then set these two in the copy:
NODE_ENV=development
CANOPUS_HOSTNAME=localhost.leap365.com
You do this twice today, once in each repository: here for the shell, and again in 01.4 for your own applet. Both sides have to agree on the hostname — the shell and your applet must be served from the same one, and each generates its dev certificate for whatever it reads here.
the hostname falls back to a default — or, if ENVIRONMENT happens to
be set in your shell, to canopus.<env>.leap365.com. The
certificate is then generated for that, the shell comes up somewhere you did not
expect, and Part 2 fails in a way that looks like a Module Federation problem
rather than an env one.
00.5 Run the shell
yarn start:test # the shell, on https://localhost.leap365.com:3000
The first start may ask for admin/sudo to install the devcert root certificate — say yes, or nothing will load over HTTPS.
https://localhost.leap365.com:3000 loads and you are signed in. If it does not, check that localhost.leap365.com points at 127.0.0.1 in your hosts file.
00.6 Pick your scope name, and put your folder in the right place
Your applet needs a name unique across everyone in the room — it becomes a Module
Federation scope and, in Part 5, a row in a shared catalog. Use
ws_<firstname>. It must be a valid JavaScript identifier:
lowercase, underscores, no dashes. Type yours into the box at the top of this page
and every snippet below will use it.
Everything assumes your applet sits beside the platform checkout, because
the resolutions paths you are about to write are relative:
~/projects/
├── canopus # the platform, built above
└── canopus-applet-you # what you are about to create
Seven files, and nothing you did not type
There is no generator here on purpose. An applet is seven small files, and the only way to find out that none of it is magic is to write them. Every one of them is short enough to read in full.
01.1 Make the project
mkdir canopus-applet-you
cd canopus-applet-you
git init
mkdir -p src
No yarn init, no create-*. The next file is the whole project definition.
01.2 package.json
{
"name": "canopus-applet-you",
"version": "1.0.0",
"private": true,
"type": "module",
"packageManager": "yarn@4.9.2",
"types": "canopus.augment.d.ts",
"scripts": {
"dev": "canopus applet dev",
"build": "canopus applet build",
"typecheck": "tsc -b"
},
"canopus": {
"links": []
},
"resolutions": {
"@canopus/build": "portal:../canopus/packages/build",
"@canopus/build/@canopus/devcert": "portal:../canopus/third-party/devcert",
"@canopus/foundation": "portal:../canopus/packages/foundation",
"@canopus/shared": "portal:../canopus/packages/shared",
"@canopus/tsconfig": "portal:../canopus/packages/tsconfig"
},
"dependencies": {
"@canopus/foundation": "^1.0.22",
"@starlens/react": "0.1.32",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"@canopus/build": "^1.0.28",
"@canopus/tsconfig": "^1.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"typescript": "^5.8"
}
}
scripts—canopusis the CLI that ships in@canopus/build'sbin. Your project never configures webpack, a dev server or certificates itself; it calls the CLI, which does all three.resolutions— the dependencies read like ordinary version ranges, because one day they will be. Until@canopus/*is published, these redirect them at the sibling checkout withportal:, a symlink: edit something incanopusand you get it with no reinstall. Two of them can only ever be resolutions, because nothing here depends on them directly —@canopus/sharedis aworkspace:*dependency of the foundation, and@canopus/devcertone of the build. Aworkspace:range means nothing outside that monorepo, so each has to be redirected by name.packageManager— without it Corepack falls back to Yarn 1, which understands neitherportal:norresolutions. Yarn honoursresolutionsonly in the manifest at the root of the project, and this project is that root.typesandcanopus.links— the type contract, both directions.typespoints at the file declaring what you contribute;linksis where you name other applets whose contributions you want to consume. Empty for now, and Part 6 says what to put in it.
01.3 .yarnrc.yml
nodeLinker: node-modules
npmScopes:
"leap":
npmRegistryServer: https://artifactory.leapaws.com.au/artifactory/api/npm/LEAP/
"starlens":
npmRegistryServer: https://artifactory.leapaws.com.au/artifactory/api/npm/LEAP/
# @canopus/build declares svelte as a peer for its svelte-loader rule, which it only
# installs when stack is "svelte". A React applet must not have that peer, so the one
# warning about it is discarded rather than worked around.
logFilters:
- pattern: "*doesn't provide svelte*"
level: discard
@leap/* and @starlens/* come from LEAP's Artifactory, not
npmjs. Without this file the install fails on the first @leap package
the foundation pulls in — which is the most common five-minute stall of the day.
01.4 .env and .gitignore
CI=false
CLI_DEBUG=false
# Left blank on purpose - these two are per-developer, and belong in .env.local.
# NODE_ENV is the build mode. CANOPUS_HOSTNAME is the hostname the applet is served
# on: the dev certificate is generated for it, and the shell must be on the same one.
NODE_ENV=
CANOPUS_HOSTNAME=
Same shape as the platform's own .env, and for the same reason: what
is committed is the list of variables, not one developer's values. Copy it, then
fill the copy in — before you run the CLI for the first time:
cp .env .env.local
NODE_ENV=development
CANOPUS_HOSTNAME=localhost.leap365.com
Both files are read and .env.local wins, so this is where every value
that is yours alone lives. It is gitignored — nothing you put in it can reach
anyone else, which is also why an API key or token goes here and never in
.env.
With both files blank the CLI falls back to a default hostname — or, if
ENVIRONMENT happens to be set in your shell, to
canopus.<env>.leap365.com. The certificate is generated for
whatever that resolves to, your applet is served somewhere the shell is not
looking, and Part 2 fails looking like a Module Federation problem rather than an
env one.
node_modules/
dist/
# Generated per-applet by the canopus CLI: dev certs, generated types, tmp config
.canopus/
logs/
.tmp.webpack.config.mjs
# Per-developer overrides; .env holds the committed defaults
.env.local
*.tsbuildinfo
# Yarn 4 with nodeLinker: node-modules - keep releases/plugins/patches, ignore the rest
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
.pnp.*
01.5 Install
yarn install
@canopus/build and the stack packages are consumed as compiled output.
If the error mentions a missing dist, you skipped
yarn bootstrap in the platform checkout — run it there and install again.
An ENOTFOUND or 401 on an @leap package means VPN or
Artifactory, not this project.
01.6 tsconfig.json
{
"extends": "@canopus/tsconfig/base",
"compilerOptions": {
"types": ["@canopus/tsconfig/types"],
// composite is what the canopus CLI's type generation needs, and it forces emit -
// so send that emit somewhere ignored. Without an outDir, `tsc -b` writes .js beside
// every source file and webpack then resolves those instead, breaking the build.
"outDir": ".canopus/tsc",
"composite": true,
"jsx": "react-jsx"
},
"include": ["src", "canopus.augment.d.ts", ".canopus/types"]
}
.canopus/types is in include because the CLI generates
types there — the declarations for any applet you link to in Part 6. It does not
exist yet, and that is fine.
01.7 webpack.config.ts — the only build config you write
import { createApplet } from "@canopus/build";
export default createApplet({
// The Module Federation scope: unique across every applet the shell may load, and a
// valid JavaScript identifier. Convention is <teamprefix>_<appletname>.
name: "ws_you",
// "./main" is the entry the shell calls on startup. Every other key you add later is
// a module the host can resolve on its own - that is how widgets are found.
exposes: {
"./main": "./src/main",
},
// The port `yarn dev` serves on. --port overrides it; with neither, applets fall
// back to 4500.
devPort: 3001,
// React comes from the host as a Module Federation shared singleton. Declaring the
// stack is what stops this applet bundling a second copy - two Reacts in one page is
// a broken page.
stack: "react",
});
createApplet returns a webpack config: swc for TypeScript, CSS and
CSS-module rules, the Module Federation plugin with the host's shared singletons,
HMR, an HTTPS dev server on your certificate. You get an escape hatch —
transformBuildTimeConfig — and the Angular track is the one place in
this workshop that needs it.
01.8 src/main.ts — the applet itself
import { applet } from "@canopus/foundation";
/**
* The smallest complete applet: a class extending applet.Applet, exported as a single
* instance from the module the build exposes as "./main".
*
* The shell instantiates nothing. It imports this default export and calls the hooks.
*/
class WorkshopApplet extends applet.Applet {
async onLoad() {
// Your own setup: mount locations, stores, subscriptions. Other applets may not
// have loaded yet, so do not talk to them here.
this.logger.info("onLoad");
}
async onLoaded() {
// Every applet has loaded by now - the place to reach for another applet's API.
this.logger.info("onLoaded");
}
async onUnload() {
// Release anything acquired above. Mountables are torn down by the host.
this.logger.info("onUnload");
}
}
const workshopApplet = new WorkshopApplet();
export default workshopApplet;
this.logger is given to you by the base class, tagged with your applet's
name, so your lines are findable in a console that carries the whole shell.
01.9 canopus.augment.d.ts
import type ws_you from "./src/main";
declare module "@canopus/foundation/registry" {
interface AppletRegistry {
ws_you: typeof ws_you;
}
}
This is your applet's half of the type contract: it declares what you
contribute, and it is the file package.json's
types points at, so it travels with your build. Declare only your own
contributions here, and never import another applet into it — consuming someone
else's contributions is the canopus.links mechanism instead.
Seven files: package.json, .yarnrc.yml, .env, .gitignore, tsconfig.json, webpack.config.ts, src/main.ts, plus canopus.augment.d.ts. yarn typecheck passes.
The shell, loading code off your laptop
Nothing is on screen yet — that is Part 3. What this part proves is the harder half: a remote you built and serve yourself, fetched and executed by the shell.
02.1 Start the dev server
yarn dev
On first run the CLI does three things before webpack starts:
- generates a dev certificate for
CANOPUS_HOSTNAMEinto.canopus/certs— this is the step that may ask for admin/sudo; - creates
.env.localif you have not — you did, in 01.4; - writes logs into
logs/, one file per build, which is where a stack trace goes when the terminal UI is too small for it.
The CLI takes over the terminal, so an ordinary scroll gesture moves the pane, not the scrollback. To page through the build output:
- Terminal and iTerm — fn + ↑ / ↓
- Cursor and VS Code's built-in terminal — fn + cmd + ↑ / ↓
Long errors are easier to read in logs/ anyway — one file per
build, and it holds the whole trace whatever the pane is tall enough to show.
Then it serves your applet over HTTPS on port 3001. Prove it in a browser tab:
https://localhost.leap365.com:3001/ws_you-mf-manifest.json
JSON with your exposes listed in it means the remote is real and reachable. That URL
shape — <url>/<scope>-mf-manifest.json — is the one thing a
host needs to load you, and it is the thing to check first whenever an applet does
not appear.
02.2 Sideload it into the shell
The shell loads a fixed set of applets, plus anything you sideload. Sideloading is a per-browser list, so you can point the shell at a remote nobody else can see — yours, on localhost.
- Open the shell: https://localhost.leap365.com:3000
- In the appbar, on the right, click the build icon — that is Canopus Tools.
- Go to Sideloaded applets.
- Fill the three fields and press Add — click any value below to copy it:
| Field | Value |
|---|---|
| URL | — no trailing slash |
| Scope | — exactly the name in webpack.config.ts |
| Module |
The row turns up with a status beside it: the panel sends a HEAD for
your manifest, so ok means the shell can see your dev server.
error means it cannot — fix that before going further.
Now reload the shell. Sideloaded applets are fetched once at startup; adding a row does not load it.
Or set it from the console, which is faster when you do it twenty times
The panel is a view over one localStorage key. This is the same thing:
localStorage.setItem(
"canopus_sideloaded_applets",
JSON.stringify({
applets: [
{
url: "https://localhost.leap365.com:3001",
scope: "ws_you",
module: "./main",
enabled: true,
},
],
}),
);
location.reload();
enabled: false keeps the row but skips loading it — the checkbox in the panel.
02.3 What the shell just did
On startup, for every applet it knows about — the built-in list plus your sideloaded row:
- registers the remote as
<url>/<scope>-mf-manifest.json; - imports
<scope>/mainand takes the default export; - calls
onLoadon each of them; - once every applet has loaded, calls
onLoadedon each.
That two-phase load is the whole reason both hooks exist. In onLoad you
may only touch your own things; by onLoaded everyone is present, so
that is where one applet may reach for another.
The shell's console shows ws_you logging
onLoad and then onLoaded. Your code is running inside an
application you did not modify. Everything after this is comparatively easy — if
you are stuck here, get help now rather than reading ahead.
- Nothing in the console at all — the sideload row is disabled, or the shell was not reloaded after adding it.
- Status
errorin the panel, or a certificate warning — open the manifest URL directly in a tab and accept the certificate there, then reload the shell. - 404 on the manifest — the scope in the panel does not match
nameinwebpack.config.ts. The file is named after the scope. - Loaded, but an error about the default export —
src/main.tsmust export an instance, not the class.
A mountable, and where the host will put it
An applet does not render anything itself. It hands the host mountables, and
the host decides when and where they appear. A mountable is a small object saying
what kind of thing it is and how to get at it — type: "react" with a
component, or type: "dom" with a mount function for everything else.
Two ways exist to give one to the host. This part uses the first; Part 5 uses the second.
- Register it at a mount location — a named slot in the shell's chrome. The shell renders every mountable registered there.
- Provide it by name — the host asks your applet for a mountable when something needs it. That is how a dashboard widget is found.
03.1 The shell's mount locations
| Location | Where | Props passed |
|---|---|---|
appbar-left | Appbar, left of centre | none |
appbar-middle | Appbar, centre | none |
appbar-right | Appbar, right — where Canopus Tools puts its icon | none |
pre-main | Above the main outlet, full width | none |
They are declared in MountLocationRegistry in
@canopus/foundation, which maps each name to the props that location
passes — so addToMountLocation is typed, and a name that does not exist
will not compile. An applet can declare a location of its own by augmenting that
same interface in canopus.augment.d.ts.
03.2 A plain React component
import { useState } from "react";
import styles from "./App.module.css";
/**
* Nothing in here knows it is running inside a host. Props arrive from the mount
* location, and the host owns the element this renders into.
*/
export default function App() {
const [count, setCount] = useState(0);
return (
<div className={styles.root}>
<button type="button" className={styles.button} onClick={() => setCount((n) => n + 1)}>
ws_you WorkShop · {count}
</button>
</div>
);
}
/*
* A .module.css is locally scoped by the build's css-modules rule, so nothing in here
* can leak into the shell's DOM - or into another applet's.
*
* This mounts into the appbar, whose background is the shell's navbar colour.
* --stl-navbar-main-heading is the foreground Starlens pairs with it: white in the light
* theme, the legible inverse in dark. The #fff fallback covers a host that has not
* loaded the tokens yet.
*/
.root {
display: flex;
gap: 8px;
color: var(--stl-navbar-main-heading, #fff);
}
.button {
padding: 0 8px;
border: 1px solid currentcolor;
border-radius: 8px;
background: transparent;
font: inherit;
cursor: pointer;
}
.button:hover {
background: rgb(255 255 255 / 12%);
}
Hard-coding a colour here would look right in one theme and wrong in the other.
Reading a --stl-* token means the shell decides, and your applet
follows it into dark mode without knowing dark mode exists.
03.3 Give it to the host
import { applet, layout } from "@canopus/foundation";
import App from "./app/App";
class WorkshopApplet extends applet.Applet {
// The lazy alternative, for when the component is heavy enough to be worth its own
// request: lazyReactMountable code-splits it, so nothing in ./app/App is fetched
// until the host actually mounts it. Swap the two and drop the import above.
//
// readonly #greeting = layout.lazyReactMountable(() => import("./app/App").then((m) => m.default), {
// priority: 10_000,
// });
// Eager, and what this applet uses: App is imported at the top and ships in the
// main chunk. There is no helper for this one, because there is nothing to help with
// - a mountable is a plain object, and addToMountLocation takes a function returning it.
readonly #greeting = (): layout.ReactMountable => ({
type: "react",
component: App,
// Orders mountables within one location, highest first. 10_000-90_000 is the
// range applets may use; outside it is reserved for the host.
priority: 10_000,
});
async onLoad() {
this.logger.info("onLoad");
this.addToMountLocation("appbar-middle", this.#greeting);
}
async onLoaded() {
this.logger.info("onLoaded");
}
async onUnload() {
this.logger.info("onUnload");
}
}
const workshopApplet = new WorkshopApplet();
export default workshopApplet;
Registering in onLoad rather than onLoaded is deliberate:
a mount location is your own contribution, so it belongs in the phase where you set
up your own things.
Eager and lazy differ only in when the component's code is fetched, and both hand the host the same thing: a function returning a mountable. An appbar button is small and always visible, so it may as well ship in the main chunk — a route or a heavy panel is the case for the commented-out version.
Reload the shell. Your button is in the middle of the appbar and counts up when
clicked. Now change the label in App.tsx and save — it updates
without a reload, and without the shell restarting. That is HMR reaching across
the remote boundary, and it is what makes this a pleasant way to work.
Someone else's data, without touching a token
Two foundation modules cover almost every call an applet makes.
http wraps fetch and injects the shell's auth token and
the X-Application-Id header, so an applet never handles credentials.
environment resolves endpoints for whichever brand, environment and
region the shell started in, so the same build runs against dev, test and live.
04.1 A component that fetches
import { environment, http } from "@canopus/foundation";
import { use } from "react";
import styles from "./MatterCount.module.css";
interface Matter {
matterId: string;
fileNumber: string;
firstDescription: string;
}
interface RecentMatters {
totalCount: number;
matters: Matter[];
}
// Module scope is fine here: the shell sets the environment during bootstrap, before it
// imports a single applet. Never hard-code a host, though - this is what makes one build
// run against dev, test and live.
const apiUrl = `${environment.env().endpoints.docsCoreApi}/api/v2/recentmatters?maxRecordsToReturn=100`;
const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const fetchRecentMatters = async () => {
// The three seconds are only so the fallback is on screen long enough to watch.
// Take the delay out once you have seen it.
await delay(3_000);
// No response.ok check: http.get returns a spread of the Response, and ok/status
// live on Response's prototype, so they do not survive the spread. A failed request
// rejects here instead - and use() throws that to the nearest error boundary.
const response = await http.get(apiUrl);
return response.json<RecentMatters>();
};
// Called once, here - not inside the component. use() reads the same promise on every
// render, and a fresh one per render would suspend for ever.
const recentMatters = fetchRecentMatters();
// One component, two mountables. The appbar has room for a number and nothing else, so
// the list is opt-in: the widget asks for it, the appbar does not.
export function MatterCount({ showList = false }: { showList?: boolean }) {
// Suspends until the promise settles. No loading state, no useEffect, no
// setState-after-unmount to worry about.
const { totalCount, matters } = use(recentMatters);
return (
<>
<span>Matters: {totalCount}</span>
{showList && (
<ul className={styles.list}>
{matters.map((matter) => (
// matterId, not the index: it is the stable identity the API already gives you.
<li key={matter.matterId} className={styles.item}>
{matter.fileNumber} — {matter.firstDescription}
</li>
))}
</ul>
)}
</>
);
}
/*
* The list fills the width of the column it is in and takes whatever height is left
* under the counter, scrolling inside itself rather than stretching its container. A
* widget owns a cell the dashboard sized; nothing an applet renders may decide how much
* room it gets.
*
* flex: 1 claims the remaining height. min-height: 0 is what makes it *scroll* rather
* than grow past the cell - a flex item's floor is its content height until you say
* otherwise, and that one line is the difference between a scrollbar and an overflow.
* align-self: stretch fills the width even though the column centres its other children.
*/
.list {
flex: 1;
min-height: 0;
align-self: stretch;
/* Definite width to truncate against, padding counted inside it. */
width: 100%;
box-sizing: border-box;
/* min-width: 0 is the one that is easy to miss. A flex item will not shrink below
its own content by default (min-width: auto), so a single long description widens
this list, then the column, then pushes the widget out of its cell - and the
ellipsis below never fires, because there is nothing constraining the text. */
min-width: 0;
overflow-x: hidden;
overflow-y: auto;
margin: 0;
padding-left: 18px;
text-align: left;
}
/*
* Truncate the long ones rather than letting them run: nowrap keeps a matter on one
* line, the constrained width above gives ellipsis something to measure against, and
* min-width: 0 stops this item claiming its content width in turn.
*/
.item {
min-width: 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
The host renders every React mountable inside a <Suspense> and
an error boundary of its own, so a suspending component and a rejected request
are both already handled — the mountable's fallback field is what
that <Suspense> shows. The next step adds a
<Suspense> of your own anyway, because the useful boundary is
narrower than the whole mountable: the button should render immediately, and only
the count should wait.
- No token.
httpasks the shell's auth for one and sets theAuthorizationheader. PassexcludeAuthToken: truefor the rare call that must go out unauthenticated. - No base URL, no
NODE_ENVswitch, no per-environment build.environment.env().endpointsis resolved at runtime by the host. - No CORS config in your project — you are same-origin with the shell as far as it matters, because both are served from
localhost.leap365.com.
04.2 Put it in the appbar
import { Suspense, useState } from "react";
import styles from "./App.module.css";
import { MatterCount } from "./MatterCount";
export default function App() {
const [count, setCount] = useState(0);
return (
<div className={styles.root}>
{/* Only the count waits on the request - the button renders straight away. */}
<Suspense fallback={<span>Loading...</span>}>
<MatterCount />
</Suspense>
<button type="button" className={styles.button} onClick={() => setCount((n) => n + 1)}>
ws_you WorkShop · {count}
</button>
</div>
);
}
The appbar reads Matters: 128 — or whatever your test account has — beside your button.
- 401 — the shell and the API are in different environments. Start the shell with
yarn start:testand sign in with a test account. - Three seconds of "Loading..." every time — that is the
delay. Remove it. - The whole mountable is replaced by an error — the request rejected, and
use()threw it to the host's error boundary. The console has the real reason. - It never resolves —
fetchRecentMatters()is being called inside the component, so every render handsuse()a different promise. Call it once, at module scope. - Network error, no response — VPN.
The other way the host finds your code
A widget is not a different kind of object. It is an Applet subclass in
its own exposed module that provides a mountable by name instead of
registering it at a location. The dashboard looks your applet up in its catalog,
registers the remote itself, imports that one module and asks it for its mountable.
Which means a widget does not need to be sideloaded at all. Sideloading is how
./main gets loaded at startup; a widget is resolved on demand from a
catalog row that already carries your scope and URL.
05.1 The component, this time with Starlens
import { StlButton, StlFlex } from "@starlens/react";
import { Suspense, useState } from "react";
import { MatterCount } from "../../app/MatterCount";
import styles from "./Counter.module.css";
/**
* Starlens is a separate concern from Canopus: an applet needs none of it to run, and
* the host shares @starlens/* as Module Federation singletons exactly as it shares
* React - so using it costs you no bundle weight and cannot fork the design system.
*/
export default function Counter() {
const [count, setCount] = useState(0);
// height: 100% on the column is what gives the list a height to take the rest of.
return (
<StlFlex direction="column" gap="medium" align="center" className={styles.root}>
<p>Count: {count}</p>
<StlButton variant="primary" onClick={() => setCount((n) => n + 1)}>
Increment
</StlButton>
{/* MatterCount suspends, so it needs a boundary here too - the one in App.tsx
belongs to that mountable and does nothing for this one. */}
<Suspense fallback={<span>Loading...</span>}>
{/* The widget has room for the matters themselves; the appbar asks for the
count alone. */}
<MatterCount showList />
</Suspense>
</StlFlex>
);
}
/*
* Fill the cell the dashboard gave this widget. Without a height here the column is only
* as tall as its contents, and "the rest of the height" is nothing to divide up.
*
* min-width: 0 for the same reason it appears in MatterCount.module.css: this column is
* itself a flex item, and without it the longest line inside decides how wide the widget
* is - which is how a truncated list still ends up overflowing its cell.
*/
.root {
height: 100%;
min-width: 0;
}
Both mountables now import the same MatterCount, and it holds one
module-scope promise — so whichever renders first pays the three seconds, and the
other reads a promise that has already settled. That is the shared-module boundary
doing its job: one applet, one chunk, one request, two very different renderings of
it.
The list fills the width and takes the height left under the counter, scrolling inside itself — because a widget occupies a cell the dashboard sized, not one it can push open.
05.2 The widget module
import { applet, layout } from "@canopus/foundation";
import Counter from "./components/Counter";
/**
* Its own Applet subclass in its own exposed module. The difference from ./src/main is
* only how the host finds it: provideMountable offers the mountable *by name* rather
* than registering it at a shell mount location.
*
* The name defaults to this module, which is what the dashboard looked it up by - pass
* one explicitly only if a single module offers several mountables.
*/
class CounterWidget extends applet.Applet {
private handleWidgetMount(): layout.ReactMountable {
return {
type: "react",
component: Counter,
priority: 10_000,
};
}
async onLoad() {
this.provideMountable(this.handleWidgetMount);
}
}
const counterWidget = new CounterWidget();
export default counterWidget;
exposes: {
"./main": "./src/main",
"./CounterWidget": "./src/widgets/CounterWidget",
},
Restart yarn dev — the exposes are read when the build config is
loaded. Then check the manifest again: ./CounterWidget should be listed
beside ./main. If it is not there, the dashboard cannot possibly find it.
05.3 Ask the workshop host to register it
The dashboard reads its widget catalog from a backend, not from whichever applets happen to be loaded — a row for the remote (scope and url) and a row for the module (which expose, what it is called, how wide it sits on the grid). Writing to it is the host's job today, so hand these over and they will add yours:
| Tell them | Yours |
|---|---|
| Scope | |
| URL | |
| Module | |
| Widget name | — how it appears in the widget list |
The catalog is shared by the room, which is why your scope has to be yours alone —
and why you will see everyone else's widget in the list. The URL is the same for
all of you, because localhost on each machine means that machine:
drag someone else's widget onto your dashboard and it loads from your port
3001. Worth trying once; it is the clearest demonstration of what a remote is.
05.4 Place it
- Reload the shell and go to the dashboard.
- Enter edit mode — Edit your dashboard on an empty one, or the edit control on a populated one.
- Your widget is in the carousel of available widgets, under the name you gave it. Drag it onto the grid.
- Save.
Your widget renders on the dashboard: a Starlens button that counts, and the matter count from Part 4, served from your laptop into a page you have still not modified a line of. You have used both routes into the host, the platform's HTTP and environment layers, and the design system.
- "provided no mountable" — the module name in the catalog does not match the expose key exactly, including the
./. - "has no default export" — the widget module must default-export an instance of its
Appletsubclass, like./maindoes. - Fails to load at all — the remote row's URL, or your dev server is not running. The dashboard registers the remote from that row, so a typo there is invisible everywhere else.
Four things the workshop stopped short of
Build it for real
yarn build
Production output lands in dist/ws_you/:
the hashed chunks, the manifest the host fetches, and
applet_types.zip — your canopus.augment.d.ts packaged so
other teams can consume your contributions with types. A deployed applet is served
from a versioned path rather than your laptop, which is the only thing that changes:
the scope, the module names and the manifest work identically.
Consume another applet
Name it in canopus.links in package.json, and the build
generates .canopus/types/links.d.ts — which your tsconfig
already includes. Then in onLoaded, when everyone is present:
import { applets } from "@canopus/foundation";
async onLoaded() {
const dashboard = applets.getApplet("canopus_dashboard");
// Do not store the reference - ask again when you need it.
}
canopus applet link <path-or-url> pulls a built applet's
applet_types.zip in for you. Never import another applet's source
directly — the link is the supported edge between two remotes.
Own a route, not a slot
getRoutePlan() on your applet returns routes with an
outlet, which is how an applet takes over the main area of the shell
instead of decorating its chrome — a full page rather than an appbar button.
Canopus Tools itself is written that way.
Talk between applets
Props flow host → guest only. For anything going the other way, or between applets,
there is the message bus: this.emit(type, detail) and
this.on(type, handler) on the applet base class.
And there are three more example applets to read — svelte,
vanilla and the Angular one below — in
canopus-applets-examples. The vanilla one is worth reading whatever
your stack: it is a mostly headless applet that publishes an API for other applets
to use, with a hand-written DomMountable so you can see what
lazyReactMountable is doing for you.
The same applet, in a framework the host does not share
Everything in Parts 1 to 5 holds: same CLI, same lifecycle, same
addToMountLocation, same provideMountable, same
http and environment. The shell has no Angular dependency
and never learns one is involved.
What changes is the build — and it changes because of one decision worth understanding, since it applies to any framework you might want to bring.
Build this as a second project beside the first, so both can run at once:
scope ws_you_ng, folder
canopus-applet-you-angular, port 3004.
ng.1 Why there is no stack: "angular"
React works as a Module Federation shared singleton because two copies of React in
one page is simply a bug — everyone wants the same one. Angular is the opposite:
its dependency injection keys off class and InjectionToken
identity, so @canopus/angular and the components it mounts must
resolve the same copy of @angular/core. Share it across applets and DI
silently stops matching.
So each Angular applet bundles its own Angular. The stack stays at the default
"vanilla", and — the upside — two Angular applets can run different
Angular versions side by side in one shell.
file:, not portal:
The same reasoning decides how @canopus/angular is resolved.
portal: is a symlink, and webpack resolves a symlinked module's
imports from its real path — which would bind @canopus/angular
to a different @angular/core than your components use, and
createComponent would not be able to see your component's definition.
file: copies instead, so both resolve the same one.
ng.2 package.json, changed lines only
{
"name": "canopus-applet-you-angular",
"resolutions": {
// A copy, not a symlink - see above. Everything else stays portal:.
"@canopus/angular": "file:../canopus/packages/angular",
"@canopus/build": "portal:../canopus/packages/build",
"@canopus/build/@canopus/devcert": "portal:../canopus/third-party/devcert",
"@canopus/foundation": "portal:../canopus/packages/foundation",
"@canopus/shared": "portal:../canopus/packages/shared",
"@canopus/tsconfig": "portal:../canopus/packages/tsconfig"
},
"dependencies": {
"@angular/common": "^21.2.21",
"@angular/core": "^21.2.21",
"@angular/platform-browser": "^21.2.21",
"@canopus/angular": "^1.0.0",
"@canopus/foundation": "^1.0.22",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@angular/compiler": "^21.2.21",
"@angular/compiler-cli": "^21.2.21",
"@babel/core": "^7",
"@canopus/build": "^1.0.28",
"@canopus/tsconfig": "^1.0.0",
"@ngtools/webpack": "^21.0.0",
"babel-loader": "^9",
"typescript": "^5.8",
"webpack": "^5"
}
}
No React, no Starlens — see the note at the end of this track about why.
ng.3 tsconfig.json
{
"extends": "@canopus/tsconfig/base",
"compilerOptions": {
"types": ["@canopus/tsconfig/types"],
"outDir": ".canopus/tsc",
"composite": true,
// Angular's field semantics predate the ES class-fields spec, and target es2022 in
// the base config would otherwise turn this on. Every Angular CLI tsconfig sets it
// false; decorated fields misbehave without it. experimentalDecorators is already
// true in @canopus/tsconfig/base.
"useDefineForClassFields": false
},
"angularCompilerOptions": {
"strictTemplates": true,
"strictInjectionParameters": true
},
"include": ["src", "canopus.augment.d.ts", ".canopus/types"]
}
ng.4 webpack.config.ts — the one file that carries the cost
@canopus/build compiles TypeScript with swc, configured with
decorators: false — it cannot parse @Component at all. So
the transform hands TypeScript to the Angular compiler and patches four things back
up around it. This is the whole of what Angular costs; nothing in the platform
changes for it.
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import { angularHmrBridgeLoader, createApplet } from "@canopus/build";
import { AngularWebpackPlugin } from "@ngtools/webpack";
import type { Compiler, RuleSetRule } from "webpack";
const here = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
// True for the two swc-loader rules @canopus/build installs for .ts/.tsx.
const isSwcRule = (rule: RuleSetRule | "..." | null | undefined | false | 0 | ""): boolean => {
if (!rule || rule === "...") {
return false;
}
const use = (rule as RuleSetRule).use;
return (
typeof use === "object" && use !== null && !Array.isArray(use) && "loader" in use && use.loader === "swc-loader"
);
};
// The query @ngtools/webpack appends when it rewrites a component's styleUrl into an
// import. It reads back that module's exported *string*, so a component stylesheet has
// to be built differently from the applet's own CSS.
const NG_RESOURCE = /\?ngResource/;
/**
* Makes the child compilation behind a styleUrl able to have an entry at all.
*
* webpack overwrites a child compiler's `compilation` taps with its parent's, throwing
* away the dependency factory EntryPlugin registered. In an ordinary build the parent
* has entries of its own and registers the same one anyway - but an applet's entry is
* {}, because Module Federation exposes the modules instead. Without this the build
* fails with "No dependency factory available for this dependency type: EntryDependency".
*/
class EntryDependencyFactoryPlugin {
apply(compiler: Compiler) {
// Must come from the same copy of webpack the build is running, which a plain
// import cannot promise - hence the throwaway dependency.
const EntryDependency = compiler.webpack.EntryPlugin.createDependency("", {}).constructor;
compiler.hooks.compilation.tap("EntryDependencyFactoryPlugin", (compilation, { normalModuleFactory }) => {
compilation.dependencyFactories.set(EntryDependency as never, normalModuleFactory);
});
}
}
export default createApplet({
name: "ws_you_ng",
exposes: {
"./main": "./src/main",
"./CounterWidget": "./src/widgets/counter-widget",
},
devPort: 3004,
// Deliberately left as the default "vanilla" - there is no stack: "angular".
transformBuildTimeConfig: (config) => ({
...config,
module: {
...config.module,
rules: [
// 1. Angular's compiler replaces swc for TypeScript. Right to left:
// @ngtools/webpack compiles, then the bridge adds the HMR boundary it needs
// to find. Without it an edit reloads the whole page - shell included.
{
test: /\.[cm]?tsx?$/,
use: [angularHmrBridgeLoader, "@ngtools/webpack"],
},
// 2. Angular ships its packages in "partial" Ivy form. AngularWebpackPlugin only
// compiles *your* TypeScript, so without the linker those declarations survive
// into the bundle and Angular falls back to JIT - which fails, because AOT
// deliberately keeps @angular/compiler out.
{
test: /\.m?js$/,
include: /node_modules/,
use: {
loader: "babel-loader",
options: {
compact: false,
babelrc: false,
configFile: false,
plugins: [require.resolve("@angular/compiler-cli/linker/babel")],
},
},
},
// 3. A component's styleUrl, handed back to the compiler as a plain string. The
// platform's css rules end in style-loader, which exports nothing - a styleUrl
// would silently compile to no styles at all. templateUrl needs no rule: the
// plugin reads .html straight off disk.
{
test: /\.css$/,
resourceQuery: NG_RESOURCE,
use: [{ loader: "css-loader", options: { exportType: "string" } }],
},
// 4. Everything @canopus/build set up, narrowed so it stops matching component
// resources.
...(config.module?.rules ?? [])
.filter((rule) => !isSwcRule(rule))
.map((rule) => (!rule || rule === "..." ? rule : { ...rule, resourceQuery: { not: [NG_RESOURCE] } })),
],
},
plugins: [
...(config.plugins ?? []),
new EntryDependencyFactoryPlugin(),
// AOT: templates compile at build time, so @angular/compiler stays out of the bundle.
new AngularWebpackPlugin({
tsconfig: path.join(here, "tsconfig.json"),
jitMode: false,
}),
],
}),
});
Inline template and styles need neither rule 3 nor
rule 4. If you would rather keep this file short than keep components in three
files, that is the trade available to you.
ng.5 src/main.ts
import { applet } from "@canopus/foundation";
import { configureAngularApplication } from "@canopus/angular";
import { lazyAngularMountable } from "@canopus/angular/lazy";
/**
* @canopus/angular implements layout.DomMountable - the same framework-agnostic contract
* the Svelte and vanilla examples use. One ApplicationRef per applet, created lazily on
* first mount: every mountable in this applet shares a DI root, and applets never share
* one with each other.
*/
class AngularWorkshopApplet extends applet.Applet {
readonly #app = lazyAngularMountable(() => import("./app/app.component").then((m) => m.AppComponent), {
priority: 10_000,
fallback: (el) => {
el.textContent = "loading…";
},
});
async onLoad() {
this.logger.info("onLoad");
// Root-injector providers for this applet's ApplicationRef, shared by every
// mountable in it. It must run before the first mount: the application is created
// lazily and its providers are fixed from that point, so a later call throws rather
// than silently doing nothing.
configureAngularApplication([]);
this.addToMountLocation("appbar-middle", this.#app);
}
async onUnload() {
this.logger.info("onUnload");
}
}
const angularWorkshopApplet = new AngularWorkshopApplet();
export default angularWorkshopApplet;
ng.6 The component
import { ChangeDetectionStrategy, Component, input, signal } from "@angular/core";
@Component({
selector: "ws_you_ng-app",
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: "./app.component.html",
styleUrl: "./app.component.css",
})
export class AppComponent {
// A signal input with a default: "appbar-middle" declares no props, so nothing is
// bound here. A *required* input would break at a location like that.
readonly label = input("ws_you_ng");
protected readonly count = signal(0);
protected increment(): void {
this.count.update((n) => n + 1);
}
}
<button type="button" class="greeting" (click)="increment()">{{ label() }} · {{ count() }}</button>
A mount location pushes its props at every mountable in it, so components are routinely handed props they never declared — harmless, they are ignored. Props flow host → guest only; for guest → host, use the applet message bus.
ng.7 The widget, and the rest
import { applet } from "@canopus/foundation";
import { lazyAngularMountable } from "@canopus/angular/lazy";
/**
* No configureAngularApplication call here: the whole applet shares one ApplicationRef,
* and ./src/main configures its providers before anything mounts.
*/
class CounterWidget extends applet.Applet {
readonly #counter = lazyAngularMountable(
() => import("./components/counter.component").then((m) => m.CounterComponent),
{
priority: 10_000,
fallback: (el) => {
el.textContent = "loading…";
},
},
);
async onLoad() {
this.provideMountable(this.#counter);
}
}
const counterWidget = new CounterWidget();
export default counterWidget;
Sideloading is identical — scope ws_you_ng,
URL https://localhost.leap365.com:3004, module ./main.
The catalog commands from 05.3 are identical too, with that scope and that URL.
For the API call, the Angular counterpart of Part 4 is a
resource() whose loader calls the same http.get with the
same environment.env() endpoint, and passes the resource's
abortSignal through. Note that you use the foundation's
http, not Angular's HttpClient: it is a plain module
import rather than a DI service, so nothing has to be provided for it.
@starlens/angular ships partial-compiled fesm2022
output, which needs the Angular linker to be consumed outside the Angular CLI —
a second build hurdle beyond Angular itself. The Angular example in
canopus-applets-examples leaves it out for the same reason, and
points at starlens/examples/example-angular as the reference if you
need it.
Reference
The words
| Term | Means |
|---|---|
| Shell | The Canopus host application. Owns routing, chrome, auth, environment and the shared singletons. |
| Applet | A Module Federation remote whose ./main default-exports one instance of an applet.Applet subclass. |
| Scope | The remote's unique name, e.g. ws_you. Set by name in createApplet; names the manifest file. |
| Expose / module | A key in exposes that the host may import on its own, like ./main or ./CounterWidget. |
| Manifest | <url>/<scope>-mf-manifest.json. The one URL a host needs to load you. |
| Mountable | What you hand the host to render: ReactMountable (a component) or DomMountable (mount/unmount functions). |
| Mount location | A named slot in the shell's chrome. Typed in MountLocationRegistry; an applet may declare its own. |
| Widget | A role, not an artifact: an exposed module providing a mountable, placed on a dashboard tab via the catalog. |
| Sideload | A per-browser list of extra remotes the shell loads at startup, stored under canopus_sideloaded_applets. |
| Stack | Which framework the host shares with you: react, svelte, or vanilla (the default, and what Angular uses). |
The applet base class
| Member | When / what |
|---|---|
onLoad() | Your own setup. Other applets may not exist yet. |
onLoaded() | Every applet has loaded. Reach for another applet's API here. |
onUnload() | Release what you acquired. Mountables are torn down by the host. |
addToMountLocation(name, mountable) | Register a mountable at a shell location. Typed against the registry. |
provideMountable(mountable, name?) | Offer a mountable by name; defaults to the current module. How widgets are found. |
getRoutePlan() | Return routes with an outlet to own a page rather than a slot. |
this.logger | A logger tagged with your applet's name. |
this.emit(type, detail) / this.on(type, handler) | The applet message bus — guest → host, and applet → applet. |
The CLI
| Command | Does |
|---|---|
canopus applet dev | Dev server with HMR, on devPort. --port overrides; --appletDir points elsewhere; default port 4500. |
canopus applet build | Production build into dist/<scope>/, plus applet_types.zip. |
canopus applet link <path-or-url> | Pull another applet's applet_types.zip in for type-safe getApplet. |
canopus shell dev | Run the shell. In the platform repo: yarn start:test and friends. |
Generated, not written
| Path | What |
|---|---|
.canopus/certs/ | Your dev certificate and key, generated for CANOPUS_HOSTNAME. |
.canopus/types/ | Declarations for the applets in canopus.links. Included by tsconfig. |
.canopus/tsc/ | Ignored emit from composite: true. Never import from it. |
.tmp.webpack.config.mjs | The compiled form of your webpack.config.ts. |
logs/ | One log file per build — where a stack trace goes when the terminal UI is too small. |
.env.local | Your machine's own values; wins over .env. Created empty on first run if you have not made one. Never committed. |
What goes wrong, and what it means
| Symptom | Usually | Fix |
|---|---|---|
Install fails on an @leap or @starlens package |
Artifactory unreachable, or .yarnrc.yml missing the scopes |
Connect the VPN; check npmScopes in .yarnrc.yml |
Install fails on a missing dist in @canopus/* |
The platform's build tools were never built | yarn bootstrap in the canopus checkout |
portal: or resolutions ignored |
Corepack fell back to Yarn 1 | Keep packageManager in package.json; corepack enable |
| Shell will not load at all | Hosts file, or the devcert root certificate was declined | Point localhost.leap365.com at 127.0.0.1; rerun and accept the prompt |
Sideload row shows error |
Dev server down, wrong port, or an untrusted certificate | Open the manifest URL in a tab, accept the certificate, reload the shell |
| 404 on the manifest | Scope in the panel ≠ name in createApplet |
The manifest is named after the scope; make them match |
| Applet loads, nothing renders | addToMountLocation never ran, or ran in the wrong hook |
Register in onLoad; check the logger line appears |
| Two Reacts / hooks error | stack: "react" missing, so the applet bundled its own |
Set the stack, delete node_modules/.cache, rebuild |
| An edit reloads the whole page | No HMR boundary — on the Angular track, the bridge loader is missing | Keep angularHmrBridgeLoader before @ngtools/webpack |
env() called before the environment was set |
Code that ran outside the shell's bootstrap — a test, or a module the host evaluates before setupEnvironment |
Inside an applet, just call it: the shell sets the environment before it imports any applet. In tests, stub it |
| API call returns 401 | Shell environment and API environment differ | yarn start:test in the shell, sign in with a test account |
| Widget card: "provided no mountable" | Catalog module name ≠ expose key | Match exactly, ./ included |
| Widget card: "has no default export" | The widget module exports the class, not an instance | export default new CounterWidget(), as ./main does |
| Angular: "No dependency factory … EntryDependency" | EntryDependencyFactoryPlugin missing, with a styleUrl in play |
Add the plugin, or inline styles in the component |
Angular: a styleUrl produces no styles |
The ?ngResource css rule is missing |
Add rule 3 from ng.4 |
| Angular: DI cannot find a provider that clearly exists | Two copies of @angular/core — usually a portal: link |
Resolve @canopus/angular with file: |
Facilitator notes
Timing
| Time | Part |
|---|---|
| 0:00 | Welcome, and the one diagram: shell, remote, mountable |
| 0:15 | 01 · The empty project |
| 0:50 | 02 · Run it, load it → checkpoint 1 |
| 1:15 | Break |
| 1:25 | 03 · Something on screen → checkpoint 2 |
| 1:55 | 04 · Talking to LEAP |
| 2:15 | 05 · A dashboard widget → checkpoint 3 |
| 2:45 | Break |
| 2:55 | The Angular track |
| 3:40 | Questions, where to go next |
What to arrange beforehand
- Send the prerequisites out a week early, and run
yarn bootstrapplusyarn start:testwith the room at the door while you do the intro. Artifactory over VPN is the slow part, not the workshop. - Collect scope names on the sign-up. They must be unique, they end up in a shared catalog, and collecting them late costs you Part 5.
- You write the catalog rows. Step 05.3 sends attendees to you with their scope; writing a module is an internal mutation, so it needs CLI access to the dashboard's Convex deployment. Two commands per attendee, from the
canopus-dashboard-apirepo — pre-register them from the sign-up list if you would rather not do it live. - Ports. 3001 for React, 3004 for Angular, 3000 for each attendee's own shell. Collisions only happen on one machine running two applets, which is exactly what the Angular track does — hence the different port.
Registering a widget
Per attendee, with their scope in place of ws_name:
# 1. The remote: their scope, and where it is served from. Prints the remote id.
npx convex run remotes:upsertRemote '{"scope":"ws_name","url":"https://localhost.leap365.com:3001"}'
# 2. The module: which expose, its name in the widget list, and its grid span.
# cols is measured in 340px tracks - 1 is one column, 2 is 696px including the gap.
npx convex run widgets:upsertModule '{"name":"ws_name counter","remoteId":"PASTE_ID_FROM_STEP_1","module":"./CounterWidget","cols":1,"rows":1}'
Both are idempotent on re-run with the same scope and module, so a typo is fixed by running it again with the corrected value rather than by deleting anything.
Where the day is won or lost
Checkpoint 1. Everything after it is enjoyable; getting to it is where certificates, hosts files and VPN bite, and someone stuck there quietly will stay stuck all afternoon. Budget the whole first hour, walk the room, and treat a person debugging alone as the thing to interrupt.
If someone falls badly behind, the four finished examples in
canopus-applets-examples are runnable — react,
angular, svelte, vanilla. Copy one out and
carry on; the only thing to change is the depth of the resolutions
paths. Nobody should start there, though: the point of the workshop is that an
applet has no magic in it, and you only learn that by typing the files.