Skip to content

Commit 37161cd

Browse files
authored
fix(host): the bare host opens the product its path names (#387)
1 parent a9aafd3 commit 37161cd

9 files changed

Lines changed: 94 additions & 17 deletions

File tree

‎apps/host/README.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ by `.dot` name inside a sandbox iframe. See the root [README](../../README.md) f
55

66
## Responsibility
77

8-
- Route a page load to a product, a local or preview target, or the landing page.
8+
- Route a page load to a product, a local or preview target, or the landing page. A product is named by its host
9+
(`<label>.<domain>`) or, on the bare host, by the first path segment (`<domain>/<label>`), and gets only the path
10+
after it.
911
- Take a `.dot` label to a product on screen, or to the screen that says why not.
1012
- Own the page chrome: topbar, URL pill, loading screen, settings, account, permissions and network views.
1113
- Own the domain building blocks the chrome composes: account, chat, permission, appearance and chain views and their
@@ -95,7 +97,7 @@ flowchart LR
9597
## Entry points
9698

9799
- The shell page. Dev server on port 4321.
98-
- The landing page, served for the bare host.
100+
- The landing page, served for the bare host's root.
99101
- `npm run dev`, `build`, `build:prod`, `typecheck`, `lint`.
100102
- `npm test`: unit specs. `test:functional`, `test:e2e`, `test:e2e:local` and `test:perf*` run Playwright suites. A
101103
local functional run needs `VITE_METRICS=true` in both the build and the run.

‎apps/host/src/main.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,7 @@ async function openLocalTarget(target: LocalTarget, shell: Shell): Promise<void>
199199
}
200200
}
201201

202-
async function openProduct(label: string, shell: Shell): Promise<void> {
202+
async function openProduct({ label, base }: Extract<ShellRoute, { kind: 'product' }>, shell: Shell): Promise<void> {
203203
const { settings, bridgeModule } = shell.startup;
204204
const protocol = protocolClient();
205205
boot.step = 'resolve_setup';
@@ -221,6 +221,7 @@ async function openProduct(label: string, shell: Shell): Promise<void> {
221221
flowId: shell.flowId,
222222
startedAt: T0,
223223
names,
224+
base,
224225
frame: {
225226
setPageProduct: bridgeModule.setPageProduct,
226227
render: product => shell.productFrame.render(product),
@@ -323,7 +324,7 @@ async function main(): Promise<void> {
323324
return;
324325
case 'product':
325326
announceRoute(route, shell.flowId);
326-
await openProduct(route.label, shell);
327+
await openProduct(route, shell);
327328
return;
328329
case 'landing':
329330
announceRoute(route, shell.flowId);

‎apps/host/src/product-loader.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export interface ProductLoaderOptions {
7070
startedAt: number;
7171
names: NameSource;
7272
frame: LoaderFrame;
73+
/** The page path ahead of the product's own, which its frame does not get. */
74+
base?: string | undefined;
7375
cidCache: LoaderCidCache;
7476
/** The names this visitor opened, kept in the store every subdomain shares. */
7577
recents: Pick<RecentLabels, 'record'>;
@@ -182,7 +184,7 @@ async function loadProduct(label: string, options: ProductLoaderOptions): Promis
182184
progress.contentFetch();
183185
enterStep('render_handoff');
184186
settleOnContent();
185-
await frame.render({ mode: 'subdomain', label, cid, chat, ...manifestFacts(manifests) });
187+
await frame.render({ mode: 'subdomain', label, cid, base: options.base, chat, ...manifestFacts(manifests) });
186188
};
187189
const rendered = (manifests: ProductManifests): void => {
188190
options.failureScreen.forget();

‎apps/host/src/runtime/product-frame.spec.ts‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,32 @@ describe('product frame renders', () => {
363363
},
364364
);
365365

366+
it('As a visitor who opened a product by its name in the path, the product gets only its own path', async () => {
367+
// Given
368+
location = {
369+
...location,
370+
href: 'http://localhost:3000/Doom.dot/level/2?skill=3#boss',
371+
pathname: '/Doom.dot/level/2',
372+
search: '?skill=3',
373+
hash: '#boss',
374+
};
375+
const fakes = harness();
376+
377+
// When
378+
await settle(
379+
fakes,
380+
{ mode: 'subdomain', label: 'doom', cid: 'cid', base: '/Doom.dot', chat: Promise.resolve(false) },
381+
0,
382+
);
383+
384+
// Then
385+
const url = new URL(nth(fakes.hosts, 0).options.iframeUrl);
386+
expect(url.hostname).toBe('doom.app.localhost');
387+
expect(url.pathname).toBe('/level/2');
388+
expect(url.searchParams.get('skill')).toBe('3');
389+
expect(url.hash).toBe('#boss');
390+
});
391+
366392
it('As a user who reset everything, only the first product frame after the reload purges the sandbox origin', async () => {
367393
// Given
368394
const fakes = harness();

‎apps/host/src/runtime/product-frame.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,17 @@ import type { ManifestFacts } from './manifest-facts.js';
2828
/**
2929
* A product the host loads from a URL it was given, or from its content id on the app subdomain. `chat` says whether it
3030
* declares chat, which picks its connection's execution kind, so the frame awaits it before it connects the product.
31+
* `base` is the page path ahead of the product's own, which its frame does not get.
3132
*/
3233
export type FrameProduct =
3334
| { mode: 'iframe'; label: string; url: string; productId?: string | undefined; chat: Promise<boolean> }
34-
| ({ mode: 'subdomain'; label: string; cid: string; chat: Promise<boolean> } & ManifestFacts);
35+
| ({
36+
mode: 'subdomain';
37+
label: string;
38+
cid: string;
39+
base?: string | undefined;
40+
chat: Promise<boolean>;
41+
} & ManifestFacts);
3542

3643
/** `lost` is a product whose core went down, was refused or turned stale, until its render on a replacement begins. */
3744
export type ProductFrameStatus = 'idle' | 'connecting' | 'connected' | 'lost' | 'failed';
@@ -604,7 +611,7 @@ type PageLocation = ReturnType<ProductFramePage['location']>;
604611

605612
function sandboxUrl(product: Extract<FrameProduct, { mode: 'subdomain' }>, location: PageLocation): URL {
606613
const origin = appOrigin(product.label, location);
607-
const deepPath = getDeepPath(location);
614+
const deepPath = getDeepPath(location, product.base ?? '');
608615
const url = new URL(deepPath ? `${origin}${deepPath}` : origin);
609616
if (url.origin !== origin) {
610617
throw new Error(ERRORS.CROSS_ORIGIN_APP_URL);
@@ -637,13 +644,16 @@ function appOrigin(label: string, location: PageLocation): string {
637644
return `https://${label}.app.${BASE_DOMAIN}`;
638645
}
639646

640-
function getDeepPath(location: PageLocation): string {
647+
function getDeepPath(location: PageLocation, productBase: string): string {
641648
const { pathname, search, hash } = location;
642649
let p = pathname;
643650
const base = import.meta.env.BASE_URL;
644651
if (base !== '/' && p.startsWith(base)) {
645652
p = '/' + p.slice(base.length);
646653
}
654+
if (productBase !== '' && p.startsWith(productBase)) {
655+
p = p.slice(productBase.length);
656+
}
647657
const isRoot = p === '' || p === '/';
648658
if (isRoot) {
649659
return search || hash ? search + hash : '';

‎apps/host/src/shell-route.spec.ts‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,25 @@ describe('readShellRoute', () => {
3232
const route = readShellRoute(at('http://doom.localhost:5173/localhost:3000/deep'), LOCAL_DEBUG);
3333

3434
// Then
35-
expect(route).toEqual({ kind: 'product', label: 'doom' });
35+
expect(route).toEqual({ kind: 'product', label: 'doom', base: '' });
36+
});
37+
38+
it('As a visitor on the bare host, I open a product by its name in the path, with or without .dot', () => {
39+
// When
40+
const plain = readShellRoute(at('https://example.li/doom/level/2?skill=3#boss'), RELEASE);
41+
const dotted = readShellRoute(at('https://example.li/Doom.dot'), RELEASE);
42+
43+
// Then
44+
expect(plain).toEqual({ kind: 'product', label: 'doom', base: '/doom' });
45+
expect(dotted).toEqual({ kind: 'product', label: 'doom', base: '/Doom.dot' });
46+
});
47+
48+
it('As a visitor on the bare host, a path that cannot be a name gets me no product', () => {
49+
// When
50+
const route = readShellRoute(at('https://example.li/not_a-name-/deep'), RELEASE);
51+
52+
// Then
53+
expect(route).toEqual({ kind: 'landing' });
3654
});
3755

3856
it('As a visitor on a sandbox host or the bare host, I am given no product, so the shell can send me to the landing page', () => {

‎apps/host/src/shell-route.ts‎

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
// Copyright 2026 Parity Technologies (UK) Ltd.
22
// SPDX-License-Identifier: AGPL-3.0-only
33

4-
// Which page the shell is: a product by its `.dot` label, a local or preview target in debug builds, or no product at
5-
// all. Read from the URL alone.
4+
// Which page the shell is: a product by its `.dot` label in the hostname or the path, a local or preview target in debug
5+
// builds, or no product at all. Read from the URL alone.
66

77
import { BASE_DOMAIN } from '@dotli/config';
8-
import { dotNsUrl } from '@dotli/resolver';
8+
import { dotNsUrl, isValidDotLabel } from '@dotli/resolver';
99
import { log } from '@dotli/shared';
1010
import { parseDotLabel } from './dot-label.js';
1111
import { parsePreviewTargetUrl } from './preview-route.js';
@@ -43,7 +43,12 @@ export interface LocalTarget {
4343
}
4444

4545
export type ShellRoute =
46-
| { kind: 'product'; label: string }
46+
| {
47+
kind: 'product';
48+
label: string;
49+
/** The page path ahead of the product's own: `/<label>` where the path names the product, else empty. */
50+
base: string;
51+
}
4752
| LocalTarget
4853
/** A nested dot.li, which would draw a second topbar. */
4954
| { kind: 'nested' }
@@ -56,7 +61,7 @@ export function readShellRoute(location: ShellLocation, options: RouteOptions):
5661
}
5762
const label = parseDotLabel(location.hostname);
5863
if (label !== null) {
59-
return { kind: 'product', label };
64+
return { kind: 'product', label, base: '' };
6065
}
6166
const productId = options.localhost ? readProductIdOverride(location.search) : undefined;
6267
if (previewUrl !== null) {
@@ -66,7 +71,18 @@ export function readShellRoute(location: ShellLocation, options: RouteOptions):
6671
if (localhostUrl !== null) {
6772
return { kind: 'localhost', url: localhostUrl, host: new URL(localhostUrl).host, productId };
6873
}
69-
return { kind: 'landing' };
74+
return readPathProduct(location.pathname) ?? { kind: 'landing' };
75+
}
76+
77+
/** A link to a product on the bare host: `/<label>` or `/<label>.dot`, then the product's own path. */
78+
function readPathProduct(pathname: string): ShellRoute | null {
79+
const segment = /^\/([^/]+)/.exec(pathname)?.[1];
80+
if (segment === undefined) {
81+
return null;
82+
}
83+
// Lowercased as the browser lowercases a hostname, so both spellings of a link reach the same name.
84+
const label = segment.replace(/\.dot$/i, '').toLowerCase();
85+
return isValidDotLabel(label) ? { kind: 'product', label, base: `/${segment}` } : null;
7086
}
7187

7288
function readProductIdOverride(search: string): string | undefined {

‎nginx/nginx.conf.template‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ server {
4848
${SENTRY}set $sentry_project "${SENTRY_PROJECT}";
4949
${SENTRY}include /etc/nginx/snippets/dotli-sentry-tunnel.conf;
5050

51-
# The landing page. Every other path gets the shell, which sends a URL with no product back here.
51+
# The landing page. Every other path gets the shell, which opens the product `/<label>` names and sends a URL with no
52+
# product back here.
5253
location = / {
5354
try_files /landing.html =404;
5455
}

‎nginx/nginx.docker.conf.template‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ server {
1414
include /etc/nginx/snippets/dotli-headers-host.conf;
1515
include /etc/nginx/snippets/dotli-precompressed.conf;
1616

17-
# The landing page. Every other path gets the shell, which sends a URL with no product back here.
17+
# The landing page. Every other path gets the shell, which opens the product `/<label>` names and sends a URL with no
18+
# product back here.
1819
location = / {
1920
try_files /landing.html =404;
2021
}

0 commit comments

Comments
 (0)