@aistrike-dev/ui-shell-dev 2.0.3 → 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +41 -17
  2. package/dist/args.d.ts +9 -1
  3. package/dist/args.js +51 -5
  4. package/dist/cell.d.ts +29 -0
  5. package/dist/cell.js +194 -0
  6. package/dist/cli.js +25 -1
  7. package/dist/devfile.d.ts +25 -9
  8. package/dist/devfile.js +75 -30
  9. package/dist/hosts.d.ts +17 -0
  10. package/dist/hosts.js +51 -0
  11. package/dist/inject.js +0 -3
  12. package/dist/platform/host.d.ts +5 -7
  13. package/dist/platform/host.js +4 -4
  14. package/dist/platform/list.d.ts +2 -2
  15. package/dist/platform/list.js +6 -5
  16. package/dist/platform/server.d.ts +3 -2
  17. package/dist/platform/server.js +21 -10
  18. package/dist/ports.d.ts +12 -0
  19. package/dist/ports.js +14 -0
  20. package/dist/preamble.d.ts +15 -0
  21. package/dist/preamble.js +44 -0
  22. package/dist/serve.d.ts +28 -0
  23. package/dist/serve.js +161 -0
  24. package/dist/token.d.ts +6 -1
  25. package/dist/token.js +2 -2
  26. package/dist/vite/plugin.d.ts +12 -3
  27. package/dist/vite/plugin.js +126 -27
  28. package/dist/vite/targets.d.ts +27 -0
  29. package/dist/vite/targets.js +35 -0
  30. package/package.json +2 -2
  31. package/platform/assistant/mock.js +1809 -222
  32. package/platform/assistant/ui/chunks/ui-CnxV0v5I.js +280 -0
  33. package/platform/assistant/ui/entry.js +2 -2
  34. package/platform/home/ui/chunks/ui-BJZEfOZI.js +174 -0
  35. package/platform/home/ui/entry.js +1 -1
  36. package/platform/settings/ui/chunks/ui-BRCsapmB.js +271 -0
  37. package/platform/settings/ui/entry.js +2 -2
  38. package/shell/assets/LabHost-D3o-OJsx.js +2 -0
  39. package/shell/assets/index-DW0Z5YeB.js +185 -0
  40. package/shell/index.html +1 -1
  41. package/platform/assistant/ui/chunks/ui-C9pWqM_D.js +0 -182
  42. package/platform/home/ui/chunks/ui-SNPmLvIE.js +0 -174
  43. package/platform/settings/ui/chunks/ui-C21hUUVD.js +0 -271
  44. package/shell/assets/LabHost-BiiVtiYJ.js +0 -2
  45. package/shell/assets/index-CqB5Mswt.js +0 -184
package/README.md CHANGED
@@ -16,26 +16,40 @@ export default defineConfig({
16
16
  });
17
17
  ```
18
18
 
19
- `vite` now serves the shell at `/` with a minted dev session, and your MFE at `/ui/latest/`, on one origin, with HMR.
20
- Other domains resolve by the profile's convention unless `shell.dev.json` overrides them.
19
+ `vite` now runs the shell as it is deployed: a **console** and a **cell** on two origins.
20
+
21
+ - The console is your Vite server: the shell at `/` with a minted dev session.
22
+ - The cell is a server on its own port: the console's plus 1000 (5103 → 6103), or any free port when that is past 65535
23
+ or taken. `shellDevHost({ cellPort })` pins it; a pinned port that is taken is logged and the cell does not start. It
24
+ forwards each domain's `/<id>/ui/…` and `/<id>/api/…`, WebSockets included, to the server that has it: your MFE
25
+ (this Vite server) and the built-in MFEs, nothing else.
26
+ - The dev token's `cell` claim names the cell on the address the browser used, so the shell loads every domain from it
27
+ cross-origin, with CORS and the bearer token, exactly as from `https://<cell>/<id>` when deployed. Your MFE's HMR runs
28
+ through the cell too.
29
+
30
+ Your dev server serves the cell's layout: `mfe()` puts the MFE under the Vite base `/<id>/ui/latest/` and answers
31
+ `/<id>/api/…` as `/api/…`, so your mock API keeps its `/api/` paths.
32
+
33
+ To see your MFE beside other domains' dev servers, use the ui-shell repository's dev server
34
+ (`pnpm --filter @aistrike-dev/ui-shell dev`), whose cell forwards to every server its `shell.dev.json` lists.
21
35
 
22
36
  ## Built-in MFEs
23
37
 
24
38
  The package carries prebuilt copies of the MFEs that ship with the shell (assistant, settings and home) with their mock
25
- APIs, and the plugin serves each on the port the shell looks for it on: assistant 5104, settings 5101, home 5102. You
26
- get the whole frame (Home, Settings, the assistant pane on ⌘I / Ctrl+I) with nothing else running.
39
+ APIs, and the plugin serves each on its port (assistant 5104, settings 5101, home 5102), behind the cell. You get the
40
+ whole frame (Home, Settings, the assistant pane on ⌘I / Ctrl+I) with nothing else running.
27
41
 
28
42
  - A server already listening on one of those ports wins: the plugin leaves that port alone, so a real dev server for
29
43
  that MFE (with HMR) is used instead. A port freed later is taken over on the next page load.
30
44
  - A real dev server for one of them started *after* this one fails, because the bundled copy holds its port. Start the
31
45
  one you are editing first, or restart this server after starting it.
32
- - `shellDevHost({ platform: false })` serves none; `shellDevHost({ platform: ['assistant'] })` only those listed. A
33
- domain that `shell.dev.json` overrides, or any profile other than `local`, is never bundled.
46
+ - `shellDevHost({ platform: false })` serves none; `shellDevHost({ platform: ['assistant'] })` only those listed.
34
47
  - The copies are as old as the package (in this repository, the last `pnpm nx build dev-host`).
35
48
 
36
- Keep Vite's `server.allowedHosts` at its default: the plugin derives your MFE's origin from the request's `Host`
37
- header, which is only safe while Vite rejects unknown hosts. With `server.https` Vite skips host validation, so the
38
- self-override trusts the `Host` header as-is.
49
+ Keep Vite's `server.allowedHosts` at its default: the plugin names the cell in the dev token on the request's `Host`
50
+ address, which is only safe while Vite rejects unknown hosts. The cell applies the same rule itself: it answers only a
51
+ `Host` of `localhost`, `*.localhost` or an IP address, and `403` to anything else. The cell is plain HTTP, so serve the
52
+ console over HTTP too: the shell loads the cell over the console's own protocol.
39
53
 
40
54
  ## `shell.dev.json`
41
55
 
@@ -43,19 +57,19 @@ Read from the Vite root when present (or from the plugin's `devFile` option).
43
57
 
44
58
  ```json
45
59
  {
46
- "profile": "local",
47
- "overrides": { "other": "http://127.0.0.1:5102" },
48
- "domains": [{ "id": "other", "nav": { "label": "Other" }, "devPort": 5102 }],
60
+ "domains": [{ "id": "hello", "nav": { "label": "Hello" } }],
49
61
  "idleTimeoutMs": 0,
50
62
  "claims": { "permissions": ["hello:read"] }
51
63
  }
52
64
  ```
53
65
 
54
- A domain's route prefix is always `/<id>`, so a domain has no `route` key: a leftover one stops the dev server with a message to delete it. The ids `shell`, `preferences`, `commands`, `api`, `assets` and `ui` are reserved.
66
+ A domain's route prefix is always `/<id>`, so a domain has no `route` key, and the shell has no profiles, so the file
67
+ has no `profile` key: a leftover one of either stops the dev server with a message to delete it. The ids `shell`,
68
+ `preferences`, `commands`, `api`, `assets`, `ui`, `config`, `signin` and `webhooks` are reserved.
55
69
 
56
- `profile` defaults to `local`, `overrides` point other domains at their own dev servers (bare origins, no path; your
57
- own domain is overridden automatically), `idleTimeoutMs` defaults to `0` (idle timeout off), and `claims` is merged
58
- over the default dev claims. The plugin's `claims`, `ttlMs` and `noToken` options win over the file. `"lab": true`
70
+ `domains` names your own domain when the shell does not compile it in (a `devPort` is accepted and ignored); naming
71
+ another domain, or an `overrides` or `cell` key, stops the dev server. `idleTimeoutMs` defaults to `0` (idle timeout off), and `claims` is merged
72
+ over the default dev claims (`cell` is the plugin's, not the file's). The plugin's `claims`, `ttlMs` and `noToken` options win over the file. `"lab": true`
59
73
  shows Octopus UI's Design Lab (its live controls for the open design questions, in a Picture-in-Picture window) over
60
74
  your pages, so you can judge them on your own screens. `"lab": { "designSystem": "../octopus-ui" }` also names an
61
75
  Octopus checkout (relative to the Vite root): **Lock in** then writes a work-loop item into its `.backlog/`, through
@@ -72,7 +86,17 @@ aistrike-shell token [--claims <path>] [--ttl 30d|12h|45m|90s] [--out <path>] [-
72
86
 
73
87
  The package's own e2e (`pnpm nx e2e dev-host`) serves the settings MFE on port 5101, with the bundled home and assistant on 5102 and 5104; it fails loudly if a dev server already occupies 5101, and stop any on 5102 or 5104 first. It also checks that `npm pack` would publish the bundled copies.
74
88
 
75
- Opening `/` in dev lands on your own MFE (the plugin injects `landing` with your domain id; `shell.dev.json` may set `landing` to another domain id; an id that is not in the shell's domain list falls back to `/home`). A domain in the profile that is neither bundled nor running shows as failed in the rail.
89
+ Opening `/` in dev lands on your own MFE (the plugin injects `landing` with your domain id; `shell.dev.json` may set `landing` to another domain id; an id that is not in the shell's domain list falls back to `/home`). A domain that is neither bundled nor running shows as failed in the rail.
90
+
91
+ ```
92
+ aistrike-shell serve --shell-dir <devhost build> [--config shell.dev.json] [--port 3000] [--cell-port 4000]
93
+ ```
94
+
95
+ `serve` is the ui-shell repository's dev server: the console and a cell whose `shell.dev.json` lists where each domain
96
+ runs, `{ "cell": { "domains": [{ "id": "lab", "nav": { "label": "Lab" }, "at": 5103 }] } }` (`at` a port or an `http:` origin:
97
+ the cell forwards over HTTP; `nav` only for a domain the shell does not compile in). Its console and cell answer only
98
+ `localhost`, `*.localhost` and IP addresses. Every compiled domain not listed is the built-in MFE. A deployed
99
+ cell (`"cell": "https://…"`) is refused until the shell can sign in to it.
76
100
 
77
101
  ## Checking Fast Refresh (spike 1)
78
102
 
package/dist/args.d.ts CHANGED
@@ -1,10 +1,18 @@
1
- export declare const USAGE = "Usage: aistrike-shell token [options]\n\nMint a dev token for local back ends.\n --claims <path> JSON claims for the token\n --ttl <30d|12h|45m|90s> token lifetime (default 30d)\n --out <path> also write the token to a file (mode 0600)\n --json print { token, session } instead of the bare token\n\nThe shell itself is served by shellDevHost() from @aistrike-dev/ui-shell-dev/vite:\nadd it to your MFE's vite.config.ts after mfe(), and \"vite\" serves the shell at /.\nShell settings (profile, overrides, domains, claims) live in shell.dev.json.\n";
1
+ export declare const USAGE = "Usage: aistrike-shell token [options]\n aistrike-shell serve [options]\n\ntoken: mint a dev token for local back ends.\n --claims <path> JSON claims for the token\n --ttl <30d|12h|45m|90s> token lifetime (default 30d)\n --out <path> also write the token to a file (mode 0600)\n --json print { token, session } instead of the bare token\n\nserve: the ui-shell dev server: the console, and a dev cell that forwards each\ndomain to the dev server shell.dev.json's cell.domains names, or to the\nbuilt-in MFE.\n --shell-dir <path> the shell's devhost build (required)\n --config <path> the dev file (default shell.dev.json)\n --port <n> the console's port (default 3000)\n --cell-port <n> the cell's port (default 4000)\n\nIn an MFE project the shell is served by shellDevHost() from\n@aistrike-dev/ui-shell-dev/vite: add it to your vite.config.ts after mfe(), and\n\"vite\" serves the shell at / beside a dev cell. Settings live in shell.dev.json.\n";
2
2
  export declare const DOMAIN_ID: RegExp;
3
3
  export declare const RESERVED_DOMAIN_IDS: ReadonlySet<string>;
4
4
  export declare function isValidDomainId(id: string): boolean;
5
5
  export declare function parseTtl(text: string): number;
6
6
  /** `id=origin`. The origin must be a bare origin: scheme, host, optional port, nothing else (spec 4.5). */
7
7
  export declare function parseOverride(text: string): [string, string];
8
+ export interface ServeArgs {
9
+ shellDir?: string;
10
+ config?: string;
11
+ port?: number;
12
+ cellPort?: number;
13
+ help: boolean;
14
+ }
15
+ export declare function parseServeArgs(argv: string[]): ServeArgs;
8
16
  export interface TokenArgs {
9
17
  claims?: string;
10
18
  ttl?: string;
package/dist/args.js CHANGED
@@ -1,21 +1,32 @@
1
1
  export const USAGE = `Usage: aistrike-shell token [options]
2
+ aistrike-shell serve [options]
2
3
 
3
- Mint a dev token for local back ends.
4
+ token: mint a dev token for local back ends.
4
5
  --claims <path> JSON claims for the token
5
6
  --ttl <30d|12h|45m|90s> token lifetime (default 30d)
6
7
  --out <path> also write the token to a file (mode 0600)
7
8
  --json print { token, session } instead of the bare token
8
9
 
9
- The shell itself is served by shellDevHost() from @aistrike-dev/ui-shell-dev/vite:
10
- add it to your MFE's vite.config.ts after mfe(), and "vite" serves the shell at /.
11
- Shell settings (profile, overrides, domains, claims) live in shell.dev.json.
10
+ serve: the ui-shell dev server: the console, and a dev cell that forwards each
11
+ domain to the dev server shell.dev.json's cell.domains names, or to the
12
+ built-in MFE.
13
+ --shell-dir <path> the shell's devhost build (required)
14
+ --config <path> the dev file (default shell.dev.json)
15
+ --port <n> the console's port (default 3000)
16
+ --cell-port <n> the cell's port (default 4000)
17
+
18
+ In an MFE project the shell is served by shellDevHost() from
19
+ @aistrike-dev/ui-shell-dev/vite: add it to your vite.config.ts after mfe(), and
20
+ "vite" serves the shell at / beside a dev cell. Settings live in shell.dev.json.
12
21
  `;
13
22
  const TTL = /^(\d+)([dhms])$/;
14
23
  const UNIT_MS = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1000 };
15
24
  // Mirrors the contract's DNS-label rule for domain ids. Duplicated (not imported) so the dev host has no runtime dependencies.
16
25
  export const DOMAIN_ID = /^[a-z](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
17
26
  // Mirrors the SDK's RESERVED_DOMAIN_IDS, duplicated for the same reason as DOMAIN_ID.
18
- export const RESERVED_DOMAIN_IDS = new Set(['shell', 'preferences', 'commands', 'api', 'assets', 'ui']);
27
+ export const RESERVED_DOMAIN_IDS = new Set([
28
+ 'shell', 'preferences', 'commands', 'api', 'assets', 'ui', 'config', 'signin', 'webhooks',
29
+ ]);
19
30
  export function isValidDomainId(id) {
20
31
  return DOMAIN_ID.test(id) && !RESERVED_DOMAIN_IDS.has(id);
21
32
  }
@@ -64,6 +75,41 @@ function takeValue(argv, index, flag) {
64
75
  }
65
76
  return value;
66
77
  }
78
+ function parsePort(text, flag) {
79
+ const port = Number(text);
80
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
81
+ throw new Error(`${flag} must be a port from 1 to 65535`);
82
+ }
83
+ return port;
84
+ }
85
+ export function parseServeArgs(argv) {
86
+ const args = { help: false };
87
+ for (let i = 0; i < argv.length; i += 1) {
88
+ const flag = argv[i];
89
+ if (flag === '--help') {
90
+ args.help = true;
91
+ continue;
92
+ }
93
+ const value = takeValue(argv, i, flag);
94
+ i += 1;
95
+ if (flag === '--shell-dir') {
96
+ args.shellDir = value;
97
+ }
98
+ else if (flag === '--config') {
99
+ args.config = value;
100
+ }
101
+ else if (flag === '--port') {
102
+ args.port = parsePort(value, flag);
103
+ }
104
+ else if (flag === '--cell-port') {
105
+ args.cellPort = parsePort(value, flag);
106
+ }
107
+ else {
108
+ throw new Error(`unknown option "${flag}"`);
109
+ }
110
+ }
111
+ return args;
112
+ }
67
113
  const TOKEN_VALUE_FLAGS = new Map([['--claims', 'claims'], ['--ttl', 'ttl'], ['--out', 'out']]);
68
114
  export function parseTokenArgs(argv) {
69
115
  const args = { json: false, help: false };
package/dist/cell.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import { type Server } from 'node:http';
2
+ export interface CellLogger {
3
+ warn: (msg: string) => void;
4
+ }
5
+ export interface CellOptions {
6
+ /** Where each domain runs: id → origin (`http://127.0.0.1:5104`). The cell forwards that domain's paths there. */
7
+ targets: () => ReadonlyMap<string, string>;
8
+ logger: CellLogger;
9
+ }
10
+ /** The domain a request is for, when its path is a domain's UI or API. */
11
+ export declare function domainOf(url: string | undefined): string | null;
12
+ /** The dev cell's server. */
13
+ export interface CellServer extends Server {
14
+ /**
15
+ * Destroys every upgraded connection (a forwarded WebSocket, both its sides). `close()` waits for them and
16
+ * `closeAllConnections()` does not reach them, so without this a cell with an open HMR socket never closes.
17
+ */
18
+ destroyUpgrades: () => void;
19
+ }
20
+ /** Closes the cell and every connection it holds, upgraded ones included. */
21
+ export declare function closeCell(cell: CellServer): Promise<void>;
22
+ /**
23
+ * The dev cell (cell resolution design 3): an origin of its own, as a deployed cell is, that answers each domain's
24
+ * `/<id>/ui/…` and `/<id>/api/…` by forwarding to the server that has it, with the path and `Host` unchanged and
25
+ * WebSocket upgrades included. The shell loads every domain from here, cross-origin, as it does from a deployed cell;
26
+ * the servers behind it answer CORS for the console's origin. Anything else is 404, and a `Host` that is not
27
+ * `localhost` or an IP address is 403 (`isAllowedHost`).
28
+ */
29
+ export declare function createCellServer(options: CellOptions): CellServer;
package/dist/cell.js ADDED
@@ -0,0 +1,194 @@
1
+ import { createServer, request } from 'node:http';
2
+ import { isAllowedHost, refuseHost, refuseUpgradeHost } from './hosts.js';
3
+ /**
4
+ * A domain's paths on a cell: its UI and its API (addressing standard, micro front-end and API paths). Every other path
5
+ * is not the cell's.
6
+ */
7
+ const DOMAIN_PATH = /^\/([a-z](?:[a-z0-9-]{0,61}[a-z0-9])?)\/(?:ui|api)(?:[/?#]|$)/;
8
+ /** The domain a request is for, when its path is a domain's UI or API. */
9
+ export function domainOf(url) {
10
+ return DOMAIN_PATH.exec(url ?? '')?.[1] ?? null;
11
+ }
12
+ function answer(res, status, text) {
13
+ res.statusCode = status;
14
+ res.setHeader('Content-Type', 'text/plain');
15
+ res.end(text);
16
+ }
17
+ function forward(req, res, target, logger) {
18
+ const failed = (error) => {
19
+ logger.warn(`cell: ${req.url ?? ''} → ${target.origin}: ${error.message}`);
20
+ if (!res.headersSent) {
21
+ answer(res, 502, `nothing answers at ${target.origin}`);
22
+ }
23
+ else {
24
+ res.destroy();
25
+ }
26
+ };
27
+ let upstream;
28
+ try {
29
+ upstream = request({ protocol: target.protocol, hostname: target.hostname, port: target.port, method: req.method, path: req.url, headers: req.headers }, (reply) => {
30
+ res.writeHead(reply.statusCode ?? 502, reply.headers);
31
+ // The domain's server went away before its answer ended (a dev server that restarts during a stream of
32
+ // server-sent events): so does the browser's, which then reconnects. `pipe` alone would leave it open.
33
+ reply.on('error', (error) => {
34
+ logger.warn(`cell: ${req.url ?? ''} → ${target.origin}: ${error.message}`);
35
+ res.destroy();
36
+ });
37
+ reply.on('close', () => {
38
+ if (!reply.complete) {
39
+ res.destroy();
40
+ }
41
+ });
42
+ reply.pipe(res);
43
+ });
44
+ }
45
+ catch (error) {
46
+ // A target `node:http` cannot reach (an `https:` origin) throws here, not on 'error'.
47
+ failed(error instanceof Error ? error : new Error(String(error)));
48
+ return;
49
+ }
50
+ // The browser went away before the answer ended (a closed tab, a stream such as server-sent events): so does the
51
+ // request behind it, which would otherwise stay open on the domain's server.
52
+ let gone = false;
53
+ res.on('close', () => {
54
+ if (!res.writableFinished) {
55
+ gone = true;
56
+ upstream.destroy();
57
+ }
58
+ });
59
+ upstream.on('error', (error) => {
60
+ if (!gone) {
61
+ failed(error);
62
+ }
63
+ });
64
+ req.on('error', () => {
65
+ upstream.destroy();
66
+ });
67
+ req.pipe(upstream);
68
+ }
69
+ /**
70
+ * Forwards a WebSocket upgrade (a dev server's HMR) and then the raw bytes both ways. Node removes its own `error`
71
+ * listener from the browser's socket before it emits `upgrade`, so this one is attached first: a browser that resets
72
+ * the connection before the domain's server answers would otherwise end the process. Whichever side closes or fails,
73
+ * both are destroyed.
74
+ */
75
+ function forwardUpgrade(req, socket, head, target, track) {
76
+ let upstream = null;
77
+ let upstreamSocket = null;
78
+ const teardown = () => {
79
+ upstream?.destroy();
80
+ upstreamSocket?.destroy();
81
+ socket.destroy();
82
+ };
83
+ socket.on('error', teardown);
84
+ socket.on('close', teardown);
85
+ const refuse = (status) => {
86
+ if (!socket.destroyed) {
87
+ socket.end(`HTTP/1.1 ${status}\r\n\r\n`);
88
+ }
89
+ };
90
+ try {
91
+ upstream = request({ hostname: target.hostname, port: target.port, method: req.method, path: req.url, headers: req.headers });
92
+ }
93
+ catch {
94
+ refuse('502 Bad Gateway');
95
+ return;
96
+ }
97
+ upstream.on('upgrade', (reply, upgraded, upstreamHead) => {
98
+ upstreamSocket = upgraded;
99
+ track(upgraded);
100
+ upgraded.on('error', teardown);
101
+ upgraded.on('close', teardown);
102
+ if (socket.destroyed) {
103
+ upgraded.destroy();
104
+ return;
105
+ }
106
+ const lines = [`HTTP/1.1 ${String(reply.statusCode ?? 101)} ${reply.statusMessage ?? 'Switching Protocols'}`];
107
+ for (let i = 0; i < reply.rawHeaders.length; i += 2) {
108
+ lines.push(`${reply.rawHeaders[i] ?? ''}: ${reply.rawHeaders[i + 1] ?? ''}`);
109
+ }
110
+ socket.write(`${lines.join('\r\n')}\r\n\r\n`);
111
+ if (upstreamHead.length > 0) {
112
+ socket.write(upstreamHead);
113
+ }
114
+ if (head.length > 0) {
115
+ upgraded.write(head);
116
+ }
117
+ upgraded.pipe(socket).pipe(upgraded);
118
+ });
119
+ upstream.on('response', (reply) => {
120
+ reply.resume();
121
+ refuse(`${String(reply.statusCode ?? 502)} ${reply.statusMessage ?? ''}`);
122
+ });
123
+ upstream.on('error', () => {
124
+ refuse('502 Bad Gateway');
125
+ });
126
+ upstream.end();
127
+ }
128
+ /** Closes the cell and every connection it holds, upgraded ones included. */
129
+ export async function closeCell(cell) {
130
+ await new Promise((done) => {
131
+ cell.close(() => {
132
+ done();
133
+ });
134
+ cell.closeAllConnections();
135
+ cell.destroyUpgrades();
136
+ });
137
+ }
138
+ /**
139
+ * The dev cell (cell resolution design 3): an origin of its own, as a deployed cell is, that answers each domain's
140
+ * `/<id>/ui/…` and `/<id>/api/…` by forwarding to the server that has it, with the path and `Host` unchanged and
141
+ * WebSocket upgrades included. The shell loads every domain from here, cross-origin, as it does from a deployed cell;
142
+ * the servers behind it answer CORS for the console's origin. Anything else is 404, and a `Host` that is not
143
+ * `localhost` or an IP address is 403 (`isAllowedHost`).
144
+ */
145
+ export function createCellServer(options) {
146
+ const targetOf = (url) => {
147
+ const id = domainOf(url);
148
+ const origin = id === null ? undefined : options.targets().get(id);
149
+ return origin === undefined ? null : new URL(origin);
150
+ };
151
+ const server = createServer((req, res) => {
152
+ if (!isAllowedHost(req.headers.host)) {
153
+ refuseHost(res);
154
+ return;
155
+ }
156
+ const target = targetOf(req.url);
157
+ if (target === null) {
158
+ answer(res, 404, 'not found');
159
+ return;
160
+ }
161
+ forward(req, res, target, options.logger);
162
+ });
163
+ const upgraded = new Set();
164
+ const track = (socket) => {
165
+ upgraded.add(socket);
166
+ socket.once('close', () => {
167
+ upgraded.delete(socket);
168
+ });
169
+ };
170
+ server.on('upgrade', (req, socket, head) => {
171
+ track(socket);
172
+ // Node has removed its own listener by now; a reset while this socket is refused must not end the process.
173
+ socket.on('error', () => {
174
+ socket.destroy();
175
+ });
176
+ if (!isAllowedHost(req.headers.host)) {
177
+ refuseUpgradeHost(socket);
178
+ return;
179
+ }
180
+ const target = targetOf(req.url);
181
+ if (target === null) {
182
+ socket.end('HTTP/1.1 404 Not Found\r\n\r\n');
183
+ return;
184
+ }
185
+ forwardUpgrade(req, socket, head, target, track);
186
+ });
187
+ return Object.assign(server, {
188
+ destroyUpgrades: () => {
189
+ for (const socket of upgraded) {
190
+ socket.destroy();
191
+ }
192
+ },
193
+ });
194
+ }
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { resolve } from 'node:path';
3
3
  import process from 'node:process';
4
- import { USAGE, parseTokenArgs, parseTtl } from './args.js';
4
+ import { USAGE, parseServeArgs, parseTokenArgs, parseTtl } from './args.js';
5
+ import { serveDevShell } from './serve.js';
5
6
  import { assertClaimsInput } from './devfile.js';
6
7
  import { DEFAULT_TTL_MS, loadOrCreateKey, mintToken } from './token.js';
7
8
  const defaultIo = { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) };
@@ -42,6 +43,27 @@ function runToken(argv, io) {
42
43
  io.stdout(output);
43
44
  return 0;
44
45
  }
46
+ /** Both `src/` and `dist/` sit one level below the package root, next to `platform/`. */
47
+ const PLATFORM_DIR = resolve(import.meta.dirname, '../platform');
48
+ async function runServe(argv, io) {
49
+ const args = parseServeArgs(argv);
50
+ if (args.help) {
51
+ io.stdout(USAGE);
52
+ return 0;
53
+ }
54
+ if (args.shellDir === undefined) {
55
+ throw new Error('serve needs --shell-dir: the shell\'s devhost build');
56
+ }
57
+ const cwd = io.cwd ?? process.cwd();
58
+ const logger = { info: (msg) => io.stdout(`${msg}\n`), warn: (msg) => io.stderr(`${msg}\n`) };
59
+ const serving = await serveDevShell({
60
+ shellDir: resolve(cwd, args.shellDir), platformDir: PLATFORM_DIR, config: args.config, cwd, port: args.port,
61
+ cellPort: args.cellPort, keyDir: io.keyDir, logger,
62
+ });
63
+ io.stdout(` shell http://localhost:${String(serving.consolePort)}/ (the console)\n`);
64
+ io.stdout(` cell http://localhost:${String(serving.cellPort)}/ (every domain, as the shell loads them)\n`);
65
+ return 0;
66
+ }
45
67
  export async function main(argv, io = defaultIo) {
46
68
  const [command, ...rest] = argv;
47
69
  try {
@@ -53,6 +75,8 @@ export async function main(argv, io = defaultIo) {
53
75
  return 0;
54
76
  case 'token':
55
77
  return runToken(rest, io);
78
+ case 'serve':
79
+ return await runServe(rest, io);
56
80
  default:
57
81
  io.stderr(`unknown command "${command}"\n\n${USAGE}`);
58
82
  return 2;
package/dist/devfile.d.ts CHANGED
@@ -5,12 +5,29 @@ export declare const DEV_FILE = "shell.dev.json";
5
5
  export interface DevLab {
6
6
  designSystem?: string;
7
7
  }
8
+ /** A domain the ui-shell dev server's cell forwards to, and the origin of the server that has it. */
9
+ export interface CellDomain {
10
+ id: string;
11
+ /** Required for a domain the shell does not compile in; a compiled one keeps its own. */
12
+ nav?: DevDomain['nav'];
13
+ /** The domain's server: `http://127.0.0.1:<port>` for a port in the file, or the origin given. */
14
+ at: string;
15
+ }
16
+ /**
17
+ * Where the ui-shell dev server's cell finds the domains (cell resolution design 3): each listed one at its server, and
18
+ * every compiled domain not listed at the built-in MFE.
19
+ */
20
+ export interface CellConfig {
21
+ domains: CellDomain[];
22
+ }
8
23
  export interface DevFile {
9
- profile?: string;
10
- overrides?: {
11
- [id: string]: string;
12
- };
24
+ /**
25
+ * The MFE project's own domain, when the shell does not compile it in. An MFE project's dev server runs the built-in
26
+ * MFEs and its own, nothing else; any `devPort` is ignored.
27
+ */
13
28
  domains?: DevDomain[];
29
+ /** The ui-shell dev server only: where its cell finds each domain. */
30
+ cell?: CellConfig;
14
31
  idleTimeoutMs?: number;
15
32
  landing?: string;
16
33
  /**
@@ -25,11 +42,10 @@ export declare function assertClaimsInput(value: unknown, source: string): DevCl
25
42
  /** An explicit `--config` must exist; the default `shell.dev.json` is optional. */
26
43
  export declare function loadDevFile(path: string | undefined, cwd?: string): DevFile | null;
27
44
  export interface DevOverrides {
28
- profile?: string;
29
45
  landing?: string;
30
- overrides: {
31
- [id: string]: string;
32
- };
33
46
  }
34
- /** Explicit overrides win over the file; the profile defaults to `local`; the idle timeout defaults to off for local work. */
47
+ /**
48
+ * What the shell is told: the domains it does not compile in (by name; where they run stays here), the landing route,
49
+ * the lab and the session. An explicit landing wins over the file; the idle timeout defaults to off for local work.
50
+ */
35
51
  export declare function buildDevConfig(file: DevFile | null, args: DevOverrides, session: Session | undefined): DevConfig;