Skip to content

Commit 34ba31e

Browse files
dwang-CXDOclaude
andcommitted
Star Wars: a Horizon app for browsing characters and adopting one as an avatar
A Module Federation remote built with @netsapiens/horizon-sdk 0.2.x, registered into the /apps menu alongside Example CRM and the Pokemon app. Search ~87 characters and droids by name, species, homeworld or allegiance; a card grid with a detail panel carrying portrait, lineage, affiliations and the BBY/ABY dates; and one write — Set as my avatar — through horizonContext.api. Data is akabab/starwars-api: one static JSON document on raw.githubusercontent .com, which sends CORS headers, holding complete records. So the app makes one request and has no per-character detail call and no detail-loading state. SWAPI was the obvious alternative and carries no images at all; the Databank API has a bigger roster but only name/description on a cold-starting free instance. The avatar path is the part with real engineering in it. The endpoint requires a square 1:1 image and these portraits are tall, so the app crops one in a canvas: horizontally centred, biased toward the top, because a strictly centred square of a tall portrait frames a torso rather than a face. It fetches the image to a Blob and crops from that rather than painting a remote URL into the canvas — a Blob is same-origin, so the canvas cannot be tainted and toBlob cannot throw, leaving exactly one requirement (CORS on the image host) instead of two. Displaying a portrait needs none of this, so the page still works where the upload cannot, and the failure says which of the three causes it was. Unlike the Pokemon app there is no sprite repository to pull from: Star Wars portraits are Lucasfilm's stills, hotlinked from Fandom's CDN by every open dataset, which is why the crop and its CORS caveat exist at all. Verified against the published SDK: typecheck clean, build clean, horizon-verify-bundle PASS on all ten required checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JnPtsHsXzbApqMpY6k4Pm9
0 parents  commit 34ba31e

20 files changed

Lines changed: 8726 additions & 0 deletions

‎.github/workflows/checks.yml‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Checks
2+
3+
# Typecheck on every push and pull request.
4+
#
5+
# tsc is the point of this workflow. The build runs through babel-loader with
6+
# @babel/preset-typescript, which STRIPS types without checking them — so the
7+
# bundle compiles regardless of how wrong the types are, and nothing in the
8+
# deploy path would otherwise notice. The SDK's compile-time guarantees (a
9+
# mistyped scope tier being a build error, for one) only hold if this runs.
10+
11+
on:
12+
push:
13+
branches: [main]
14+
pull_request:
15+
workflow_dispatch:
16+
17+
concurrency:
18+
group: checks-${{ github.ref }}
19+
cancel-in-progress: true
20+
21+
permissions:
22+
contents: read
23+
24+
jobs:
25+
checks:
26+
runs-on: ubuntu-latest
27+
steps:
28+
- uses: actions/checkout@v4
29+
30+
- uses: actions/setup-node@v4
31+
with:
32+
node-version-file: .nvmrc
33+
cache: npm
34+
35+
- name: Install dependencies
36+
run: npm ci
37+
38+
- name: Typecheck
39+
run: npm run typecheck

‎.github/workflows/codeql.yml‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
name: CodeQL
2+
3+
on:
4+
workflow_dispatch:
5+
push:
6+
branches:
7+
- 'main'
8+
pull_request:
9+
branches:
10+
- 'main'
11+
schedule:
12+
- cron: '0 2 * * 0'
13+
14+
jobs:
15+
analyze:
16+
name: Analyze
17+
runs-on: ubuntu-latest
18+
permissions:
19+
actions: read
20+
contents: read
21+
security-events: write
22+
strategy:
23+
matrix:
24+
language: ['javascript']
25+
steps:
26+
- name: Checkout
27+
uses: actions/checkout@v4
28+
29+
- name: Initialize CodeQL
30+
uses: github/codeql-action/init@v3
31+
with:
32+
languages: ${{ matrix.language }}
33+
34+
- name: Perform CodeQL Analysis
35+
uses: github/codeql-action/analyze@v3
36+
with:
37+
category: '/language:${{ matrix.language }}'

‎.github/workflows/deploy-pages.yml‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
name: Deploy to GitHub Pages
2+
3+
# Builds the Module Federation remote and publishes dist/ to GitHub Pages, which
4+
# serves it as a CDN: a Horizon host loads
5+
# https://netsapiens.github.io/horizon-starwars-app/remoteEntry.js
6+
# GitHub Pages sends `Access-Control-Allow-Origin: *`, so cross-origin loading
7+
# by the host works. The page itself renders nothing standalone (headless remote).
8+
#
9+
# Modeled on horizon-sdk-demo's workflow of the same name, minus its zone
10+
# manifest check (this app has no Playwright suite to keep in step).
11+
12+
on:
13+
push:
14+
branches: [main]
15+
workflow_dispatch:
16+
17+
# Allow the deploy job to publish to Pages via OIDC.
18+
permissions:
19+
contents: read
20+
pages: write
21+
id-token: write
22+
23+
# One in-flight deploy at a time; don't cancel a running publish.
24+
concurrency:
25+
group: pages
26+
cancel-in-progress: false
27+
28+
jobs:
29+
build:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
with:
34+
fetch-depth: 2 # the version guard below diffs against the previous commit
35+
36+
- uses: actions/setup-node@v4
37+
with:
38+
node-version-file: .nvmrc
39+
cache: npm
40+
41+
- name: Install dependencies
42+
run: npm ci
43+
44+
# The remote entry URL is stable, so the version field is what tells the
45+
# platform to re-verify. Publishing new bytes under an unchanged version
46+
# means nothing re-verifies: the platform goes on enforcing the previous
47+
# bytes' hash against the new ones at the same URL, every host fails its
48+
# integrity check, and the extension silently stops appearing — no verdict,
49+
# no finding, nothing in the portal explaining it. Hence a guard, not a
50+
# convention. A push that touches only documentation passes untouched.
51+
- name: Refuse to ship changed bytes under an unchanged version
52+
run: |
53+
PREV=$(git show HEAD~1:package.json 2>/dev/null | node -p "try{JSON.parse(require('fs').readFileSync(0,'utf8')).version}catch(e){''}" || echo "")
54+
THIS=$(node -p "require('./package.json').version")
55+
[ -z "$PREV" ] && exit 0
56+
[ "$PREV" != "$THIS" ] && exit 0
57+
git diff --quiet HEAD~1 HEAD -- src webpack.config.js package.json package-lock.json && exit 0
58+
echo "::error::Bundle inputs changed but the version is still $THIS. Bump it."
59+
exit 1
60+
61+
# babel-loader STRIPS types without checking them, so the bundle builds no
62+
# matter how wrong the types are. This is the only gate that reads them.
63+
- name: Typecheck
64+
run: npm run typecheck
65+
66+
- name: Build
67+
run: npm run build
68+
69+
# The same checks the platform runs on submission — fail here, not there.
70+
- name: Verify bundle
71+
run: npm run verify
72+
73+
# Stop Jekyll from stripping webpack's _-prefixed files.
74+
- name: Disable Jekyll
75+
run: touch dist/.nojekyll
76+
77+
- uses: actions/upload-pages-artifact@v3
78+
with:
79+
path: dist
80+
81+
deploy:
82+
needs: build
83+
runs-on: ubuntu-latest
84+
environment:
85+
name: github-pages
86+
url: ${{ steps.deployment.outputs.page_url }}
87+
steps:
88+
- id: deployment
89+
uses: actions/deploy-pages@v4

‎.gitignore‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
node_modules/
2+
dist/
3+
*.log
4+
.DS_Store
5+
.env*

‎.npmrc‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# @netsapiens/horizon-sdk is published to npm with public access — no auth needed.
2+
@netsapiens:registry=https://registry.npmjs.org/

‎.nvmrc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
22.8.0

‎INSTALL.md‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# Installing the Star Wars app on a Horizon instance
2+
3+
The bundle is instance-independent — `publicPath: 'auto'`, no hostnames baked
4+
in — and published once, to GitHub Pages, for every instance. Installing it on
5+
a portal is two API calls and no files: **register**, then **deploy** (which is
6+
what verifies it).
7+
8+
## Prerequisites
9+
10+
- Horizon v46+ with SDK support (`NsApi.horizon_extensions` table exists).
11+
- `*.github.io` in `approved_cdn_origins` (`NsApi.horizon_extension_settings`).
12+
It is what `horizon-sdk-demo` loads through, so on most instances this is
13+
already true. Failure here is silent in the UI — look for
14+
`[HorizonAppsLoader]` in the browser console.
15+
- Browsers reaching the portal can reach `raw.githubusercontent.com` (the
16+
character index) and Fandom's image CDN (the portraits).
17+
- For the **Set as my avatar** button only: the API-write master capability
18+
enabled for SDK apps, and `avatar` absent from `api_write_deny_resources`.
19+
Both are platform-global settings; everything else in the app is read-only
20+
against public data and needs neither.
21+
- A token with write scope. `reseller: *` (platform-global) requires a super
22+
user; a reseller-scoped token can only create within its own reseller.
23+
24+
## 1. Publish the bundle
25+
26+
Pushing to `main` does this — see the README. Confirm what the platform will
27+
fetch:
28+
29+
```bash
30+
curl -sI https://netsapiens.github.io/horizon-starwars-app/remoteEntry.js \
31+
| grep -iE 'http/|content-type|access-control-allow-origin'
32+
# -> 200, application/javascript, access-control-allow-origin: *
33+
```
34+
35+
## 2. Register the app
36+
37+
Portal **Platform -> UI SDK Management -> Registered Apps -> Add App** is the
38+
short path — it registers and gives you the **Deploy** button step 3 needs.
39+
40+
The API equivalent (note the `/registry` segment — `POST /ui-extensions` is a
41+
runtime read surface and returns 405):
42+
43+
```bash
44+
curl -X POST "https://<PORTAL_FQDN>/ns-api/v2/ui-extensions/registry" \
45+
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
46+
-d '{
47+
"name": "Star Wars",
48+
"description": "Star Wars character browser, with avatar adoption",
49+
"version": "1.0.0",
50+
"remote_entry_url": "https://netsapiens.github.io/horizon-starwars-app/remoteEntry.js",
51+
"webpack_module": "horizonStarwars",
52+
"enabled": "yes"
53+
}'
54+
```
55+
56+
Required fields are `name`, `remote_entry_url` and `webpack_module`; everything
57+
else has a default. **Do not send `id`** — the server derives it as the
58+
kebab-case of `webpack_module` (`horizonStarwars` -> `horizon-starwars`).
59+
`webpack_module` is unique platform-wide (409 on a duplicate) and immutable
60+
once registered.
61+
62+
## 3. Deploy the bundle (required — this is what verifies it)
63+
64+
A registered app sits at `verification_status: none`, is filtered out of
65+
`/ui-extensions?purpose=runtime` before the browser sees it, and renders
66+
nowhere. One call fetches, verifies, hashes and promotes:
67+
68+
```bash
69+
curl -X POST "https://<PORTAL_FQDN>/ns-api/v2/ui-extensions/registry/horizon-starwars/deploy?synchronous=yes" \
70+
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
71+
-d '{ "remote_entry_url": "https://netsapiens.github.io/horizon-starwars-app/remoteEntry.js" }'
72+
```
73+
74+
or press **Deploy** on the app's row in Registered Apps, which does the same
75+
thing and picks the next free version itself.
76+
77+
Reading the response:
78+
79+
- `status` — `approved` and `flagged` both load; only `rejected` blocks.
80+
A `size-delta` flag on a first submission is expected.
81+
- `promoted: true` is the bit that matters; `synchronous=yes` adds `landed`,
82+
and `landed: false` means "not observed within the poll budget", not failed.
83+
- `unchanged: true` means the fetched bytes hash to the current pin — a success.
84+
- `429` is the per-extension submission rate limit. Wait a minute.
85+
86+
## 4. Verify in the portal
87+
88+
Hard-refresh: **Star Wars** appears in the **Apps** menu (deep link:
89+
`https://<PORTAL_FQDN>/apps/starwars`).
90+
91+
Then click a character and press **Set as my avatar**. Success says so in the
92+
panel; the top bar shows the new picture on the next page load, because the
93+
host reads the avatar once per load.
94+
95+
## Updating
96+
97+
Bump `version` in `package.json`, push to `main`, wait for the Pages deploy to
98+
finish, then deploy again (step 3). Between the publish and the deploy the
99+
platform still pins the previous hash while the CDN serves new bytes, so keep
100+
the gap short.
101+
102+
## Appendix: if the portrait cannot be read
103+
104+
The avatar button fetches the portrait's bytes so it can crop them to a square.
105+
That read — unlike simply displaying the image — needs the image host to allow
106+
it. Two things can block it, and the panel reports both:
107+
108+
- **The image CDN sends no CORS header** for this origin. The fetch fails with
109+
a bare `TypeError`, with the real reason only in the console.
110+
- **The portal's Content-Security-Policy** restricts `connect-src` or
111+
`img-src`.
112+
113+
Either way, putting the images on the portal's own origin removes the question:
114+
115+
```apache
116+
<Location "/swimages/">
117+
ProxyPass "https://static.wikia.nocookie.net/" timeout=30
118+
ProxyPassReverse "https://static.wikia.nocookie.net/"
119+
</Location>
120+
```
121+
122+
Then rewrite each record's `image` in `src/api/starwarsApi.ts` to that prefix
123+
as the index is mapped, and rebuild. Needs `proxy_http` and proxy SSL support.
124+
Consider caching: these are somebody else's images and somebody else's
125+
bandwidth.
126+
127+
A portal that cannot allow either is still fine for the rest of the app — the
128+
grid, the search and the detail panel all work, and only the avatar button
129+
fails, with an explanation.

‎KIT-GAPS.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Kit gaps — `horizonContext.ui`
2+
3+
Components this app reached for and did not find, what it used instead, and
4+
what that cost. Recorded while building, when the reasoning was still live.
5+
6+
**Please share this file** with the Horizon SDK team — it is the input that
7+
decides what gets added to the host UI kit.
8+
9+
## An image
10+
11+
- **Reached for:** `ui.Image` (a themed `<img>` with object-fit, a loading
12+
state and an `onError` fallback)
13+
- **Used instead:** `<Box component='img' … />` in
14+
`src/components/CharacterCard.tsx` and `src/components/CharacterDetail.tsx`
15+
- **Cost:** the card owns its own broken-image fallback as component state,
16+
about 10 lines. No behaviour lost — `Box` is the sanctioned bare primitive
17+
and an image carries no theme colours — but every app that shows a picture
18+
rewrites this.
19+
- **Would have been solved by:** `<Image src fallback alt fit='cover' />`.
20+
21+
## A link
22+
23+
- **Reached for:** `ui.Link`, for the Wookieepedia reference on each character
24+
- **Used instead:** a `Button variant='text'` calling
25+
`window.open(url, '_blank', 'noopener,noreferrer')`
26+
(`src/components/CharacterDetail.tsx`)
27+
- **Cost:** it is a button pretending to be a link — no href, so no middle
28+
click, no "open in new tab", no status-bar preview, and a screen reader
29+
announces the wrong role. The alternative, a hand-rolled `<a>`, would be
30+
unthemed and is what RULE 1 exists to prevent.
31+
- **Would have been solved by:** `<Link href external>` rendering a real
32+
anchor in the host's link style.
33+
34+
## A loading placeholder
35+
36+
- **Reached for:** `ui.Skeleton`
37+
- **Used instead:** a `Typography` line ("Loading the roster...") in
38+
`src/pages/CharactersPage.tsx`
39+
- **Cost:** the grid pops in rather than resolving from a wireframe, so the
40+
page jumps on first paint.
41+
- **Would have been solved by:** `<Skeleton variant='rect' />`, or exposing the
42+
wireframe the widget frame already draws.
43+
44+
## Client-side pagination outside a grid
45+
46+
- **Reached for:** the `DatagridTemplate` footer, for a card grid
47+
- **Used instead:** a `Load more` `Button` raising a visible-count state
48+
- **Cost:** small — arguably the better interaction for a card wall. Noted
49+
because pagination is otherwise only available by adopting the whole grid
50+
template.

0 commit comments

Comments
 (0)