nylorun 0.5.0-beta → 0.6.0-beta

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.
@@ -99,3 +99,66 @@ export function parseComposePs(stdout) {
99
99
  };
100
100
  });
101
101
  }
102
+ const UNITS = {
103
+ b: 1,
104
+ kb: 1e3,
105
+ mb: 1e6,
106
+ gb: 1e9,
107
+ tb: 1e12,
108
+ kib: 2 ** 10,
109
+ mib: 2 ** 20,
110
+ gib: 2 ** 30,
111
+ tib: 2 ** 40,
112
+ };
113
+ /** `123.4MiB` (from `docker stats` MemUsage) in bytes; undefined when unreadable. */
114
+ export function parseByteSize(text) {
115
+ const match = /^([\d.]+)\s*([a-z]+)$/i.exec(text.trim());
116
+ const unit = match ? UNITS[match[2].toLowerCase()] : undefined;
117
+ return match && unit ? Number(match[1]) * unit : undefined;
118
+ }
119
+ /** `812 MB`, `2.1 GB`. */
120
+ export function formatBytes(bytes) {
121
+ return bytes >= 1e9 ? `${(bytes / 1e9).toFixed(1)} GB` : `${Math.round(bytes / 1e6)} MB`;
122
+ }
123
+ /**
124
+ * Memory in use per Tenant: the sum over its running containers, found by the label
125
+ * `dev.nylorun.tenant`, from one `docker stats --no-stream`. Undefined when Docker does not
126
+ * answer; a Tenant with no running container is absent.
127
+ */
128
+ export async function tenantMemory(docker) {
129
+ const ps = await docker.run([
130
+ "ps",
131
+ "--filter",
132
+ "label=dev.nylorun.tenant",
133
+ "--format",
134
+ '{{.Names}}\t{{.Label "dev.nylorun.tenant"}}',
135
+ ]);
136
+ if (ps.code !== 0)
137
+ return undefined;
138
+ const owners = new Map(ps.stdout
139
+ .split(/\r?\n/)
140
+ .map((line) => line.trim().split("\t"))
141
+ .filter((cells) => cells.length === 2 && cells[1] !== ""));
142
+ const memory = new Map();
143
+ if (owners.size === 0)
144
+ return memory;
145
+ const stats = await docker.run([
146
+ "stats",
147
+ "--no-stream",
148
+ "--format",
149
+ "{{.Name}}\t{{.MemUsage}}",
150
+ ...owners.keys(),
151
+ ]);
152
+ if (stats.code !== 0)
153
+ return undefined;
154
+ for (const line of stats.stdout.split(/\r?\n/)) {
155
+ const [name, usage] = line.trim().split("\t");
156
+ const tenant = name ? owners.get(name) : undefined;
157
+ const bytes = usage ? parseByteSize(usage.split("/")[0]) : undefined;
158
+ if (tenant && bytes !== undefined)
159
+ memory.set(tenant, (memory.get(tenant) ?? 0) + bytes);
160
+ }
161
+ for (const [tenant, bytes] of memory)
162
+ memory.set(tenant, Math.round(bytes));
163
+ return memory;
164
+ }
@@ -34,6 +34,11 @@ export interface StackEnv {
34
34
  * when telemetry is off. Decided again on every start.
35
35
  */
36
36
  studioAnalyticsId: string;
37
+ /**
38
+ * Origins Studio also serves besides localhost and 127.0.0.1 on its port, separated by
39
+ * spaces: the proxy's `http://<name>.localhost:<port>`, or empty. Decided on every start.
40
+ */
41
+ studioPublicOrigins: string;
37
42
  /** The Tenant's name, which the Runtime gives the Tenant it creates. */
38
43
  tenantName: string;
39
44
  /**
@@ -24,6 +24,7 @@ const KEYS = {
24
24
  studioImage: "NYLORUN_STUDIO_IMAGE",
25
25
  studioFrameAncestors: "NYLORUN_STUDIO_FRAME_ANCESTORS",
26
26
  studioAnalyticsId: "NYLORUN_STUDIO_ANALYTICS_ID",
27
+ studioPublicOrigins: "NYLORUN_STUDIO_PUBLIC_ORIGINS",
27
28
  tenantName: "NYLORUN_TENANT_NAME",
28
29
  derivedPrincipals: "NYLORUN_DERIVED_PRINCIPALS",
29
30
  };
@@ -81,6 +82,10 @@ export function renderEnvFile(env) {
81
82
  "# it off with nylorun telemetry disable or NYLORUN_TELEMETRY_DISABLED=1.",
82
83
  line("studioAnalyticsId"),
83
84
  "",
85
+ "# Studio's address through the proxy (http://<name>.localhost:<port>); empty",
86
+ "# without the proxy. Set on every start.",
87
+ line("studioPublicOrigins"),
88
+ "",
84
89
  "# The Tenant's name, which the Runtime gives the Tenant it creates on the first",
85
90
  "# start, and the derived principals it registers on that Tenant (keys derived",
86
91
  "# from the admin key; `project` is the Project link's).",
@@ -2,12 +2,13 @@
2
2
  * Images a local Tenant runs. The Runtime and Studio tags are pinned by this
3
3
  * CLI release; `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them
4
4
  * (local builds, CI images). Postgres, Restate and s2-lite are the official
5
- * images, pinned here.
5
+ * images, pinned here, as is Caddy for the machine's Studio proxy.
6
6
  */
7
7
  export declare const PINNED_IMAGES: {
8
8
  readonly postgres: "postgres:17.11";
9
9
  readonly restate: "docker.restate.dev/restatedev/restate:1.7.12";
10
10
  readonly s2: "ghcr.io/s2-streamstore/s2:0.43.0";
11
+ readonly proxy: "caddy:2.11.6";
11
12
  };
12
13
  export interface StackImages {
13
14
  runtime: string;
@@ -15,6 +16,7 @@ export interface StackImages {
15
16
  postgres: string;
16
17
  restate: string;
17
18
  s2: string;
19
+ proxy: string;
18
20
  }
19
21
  /** `NYLORUN_RUNTIME_IMAGE` names the Runtime image, so its version is unknown here. */
20
22
  export declare function runtimeImageOverridden(env: Readonly<Record<string, string | undefined>>): boolean;
@@ -3,12 +3,13 @@ import { pinnedVersion } from "./versions.js";
3
3
  * Images a local Tenant runs. The Runtime and Studio tags are pinned by this
4
4
  * CLI release; `NYLORUN_RUNTIME_IMAGE` and `NYLORUN_STUDIO_IMAGE` override them
5
5
  * (local builds, CI images). Postgres, Restate and s2-lite are the official
6
- * images, pinned here.
6
+ * images, pinned here, as is Caddy for the machine's Studio proxy.
7
7
  */
8
8
  export const PINNED_IMAGES = {
9
9
  postgres: "postgres:17.11",
10
10
  restate: "docker.restate.dev/restatedev/restate:1.7.12",
11
11
  s2: "ghcr.io/s2-streamstore/s2:0.43.0",
12
+ proxy: "caddy:2.11.6",
12
13
  };
13
14
  function override(env, name) {
14
15
  const value = env[name]?.trim();
@@ -1,8 +1,9 @@
1
1
  import { type StackDeps } from "./commands.js";
2
- export { ensureStack, isStackCommand, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
2
+ export { ensureStack, isStackCommand, readProxyReport, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
3
3
  export type { StackDeps, StackEndpoints, StackStatus, StackTenant } from "./commands.js";
4
4
  export { checkDocker } from "./docker.js";
5
5
  export type { Check, DockerChecks } from "./docker.js";
6
+ export type { ProxyStatus } from "./proxy.js";
6
7
  /** Real dependencies: docker on PATH, global fetch, the terminal. */
7
8
  export declare function defaultStackDeps(env: Readonly<Record<string, string | undefined>>): StackDeps;
8
9
  /** Entry for `nylorun start|stop|status|logs|reset|studio|ls|delete`. */
@@ -4,7 +4,7 @@ import { runStackCommand, runStudioCommand } from "./commands.js";
4
4
  import { spawnDocker } from "./docker.js";
5
5
  import { pinnedVersion } from "./versions.js";
6
6
  import { loopbackPorts } from "./ports.js";
7
- export { ensureStack, isStackCommand, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
7
+ export { ensureStack, isStackCommand, readProxyReport, readStackStatus, stackUsage, studioLoginUrl, tenantStudioPath, STACK_SERVICES, } from "./commands.js";
8
8
  export { checkDocker } from "./docker.js";
9
9
  function browserCommand(env) {
10
10
  if (process.platform === "darwin")
@@ -21,6 +21,8 @@ const STACK_ENV_OWNED = [
21
21
  "NYLORUN_TENANT_NAME",
22
22
  "NYLORUN_DERIVED_PRINCIPALS",
23
23
  "NYLORUN_STUDIO_ANALYTICS_ID",
24
+ "NYLORUN_STUDIO_PUBLIC_ORIGINS",
25
+ "NYLORUN_PROXY_PORT",
24
26
  ];
25
27
  /** Real dependencies: docker on PATH, global fetch, the terminal. */
26
28
  export function defaultStackDeps(env) {
@@ -1,4 +1,6 @@
1
1
  export declare const DEFAULT_PORTS: {
2
+ /** The Studio proxy, one per machine (`http://<tenant>.localhost:<port>`). */
3
+ readonly proxy: 4160;
2
4
  readonly runtime: 8787;
3
5
  /** The Runtime's operator listener (Admin API). */
4
6
  readonly admin: 8788;
@@ -1,5 +1,7 @@
1
1
  import { createServer } from "node:net";
2
2
  export const DEFAULT_PORTS = {
3
+ /** The Studio proxy, one per machine (`http://<tenant>.localhost:<port>`). */
4
+ proxy: 4160,
3
5
  runtime: 8787,
4
6
  /** The Runtime's operator listener (Admin API). */
5
7
  admin: 8788,
@@ -43,4 +43,6 @@ export declare function prepareStack(input: {
43
43
  };
44
44
  /** The measurement id Studio reports page views to; absent when telemetry is off. */
45
45
  studioAnalyticsId?: string;
46
+ /** Studio's origin through the proxy; absent without the proxy. */
47
+ studioPublicOrigin?: string;
46
48
  }): Promise<PreparedStack>;
@@ -77,6 +77,7 @@ export async function prepareStack(input) {
77
77
  studioImage: input.images.studio,
78
78
  studioFrameAncestors,
79
79
  studioAnalyticsId: input.studioAnalyticsId ?? "",
80
+ studioPublicOrigins: input.studioPublicOrigin ?? "",
80
81
  tenantName: input.name,
81
82
  derivedPrincipals: (input.derivedPrincipals?.trim()
82
83
  ? parseDerivedPrincipals(input.derivedPrincipals, "NYLORUN_DERIVED_PRINCIPALS")
@@ -90,6 +91,6 @@ export async function prepareStack(input) {
90
91
  runtimeVersion: input.runtimeVersion,
91
92
  });
92
93
  await writeFileMode(paths.env, renderEnvFile(env), 0o600);
93
- await writeFileMode(paths.compose, renderComposeFile(input.project), 0o644);
94
+ await writeFileMode(paths.compose, renderComposeFile(input.project, input.name), 0o644);
94
95
  return { env, host, adminKey, firstRun };
95
96
  }
@@ -0,0 +1,78 @@
1
+ import type { DockerRunner } from "./docker.js";
2
+ import { type PortProbe } from "./ports.js";
3
+ /**
4
+ * The Studio proxy: one small Caddy container per machine that gives each local Tenant's
5
+ * Studio the address `http://<name>.localhost:<port>`. Browsers keep cookies per host, not
6
+ * per port, so separate hosts keep the Studios' sessions apart. It routes browsers to Studio
7
+ * only (programs keep the Runtime's `http://localhost:<port>`: Node's resolver need not
8
+ * resolve `*.localhost`) and holds no Tenant data: its files are `~/.nylorun/proxy/`
9
+ * (`compose.yaml`, `Caddyfile`, `.env`), and Caddy's own state is tmpfs.
10
+ *
11
+ * The proxy joins each Tenant's network (named after its Compose project) and reaches Studio
12
+ * by container name, `<project>-studio:3000`. Docker cannot remove a network the proxy is
13
+ * attached to, so `reset` and `delete` disconnect it first.
14
+ */
15
+ export declare const PROXY_PROJECT = "nylorun-proxy";
16
+ export declare const PROXY_CONTAINER = "nylorun-proxy";
17
+ export interface ProxyPaths {
18
+ dir: string;
19
+ compose: string;
20
+ caddyfile: string;
21
+ /** The port, and whether Docker took the `::1` binding. */
22
+ env: string;
23
+ }
24
+ export declare function proxyPaths(base: string): ProxyPaths;
25
+ /** `NYLORUN_PROXY_DISABLED=1`: no proxy; Studio is `http://localhost:<port>`. */
26
+ export declare function proxyDisabled(env: Readonly<Record<string, string | undefined>>): boolean;
27
+ export declare function proxyOrigin(name: string, port: number): string;
28
+ export interface ProxySettings {
29
+ port: number;
30
+ /** Also published on `[::1]` (false once Docker refused it). */
31
+ ipv6: boolean;
32
+ }
33
+ export declare function readProxySettings(base: string): Promise<ProxySettings | undefined>;
34
+ /**
35
+ * The proxy's settings. On first use its port is 4160 when free, else a free one outside
36
+ * `taken`; it is kept after that.
37
+ */
38
+ export declare function ensureProxySettings(base: string, ports: PortProbe, taken: ReadonlySet<number>): Promise<ProxySettings>;
39
+ /** A Tenant the proxy routes to: its name and Compose project (also its network's name). */
40
+ export interface ProxyRoute {
41
+ name: string;
42
+ project: string;
43
+ }
44
+ /**
45
+ * One site per Tenant. Site addresses carry no port: Caddy listens on 80 in the container,
46
+ * and its host match ignores the published port in the browser's Host. The browser's Host
47
+ * (`<name>.localhost:<port>`) reaches Studio unchanged, which Studio checks; a Tenant that is
48
+ * not running answers 502, and an unknown host 404.
49
+ */
50
+ export declare function renderCaddyfile(routes: readonly ProxyRoute[]): string;
51
+ export declare function renderProxyCompose(ipv6: boolean): string;
52
+ /** Detach the proxy from a Tenant's network, so Compose can remove it; never fails. */
53
+ export declare function disconnectProxy(docker: DockerRunner, network: string): Promise<void>;
54
+ /**
55
+ * Write the routes, bring the proxy up (Compose recreates it when its file changed), attach
56
+ * it to every routed Tenant's network and reload Caddy. Never throws: a problem is warned
57
+ * once and the caller shows Studio's own port. When Docker refuses `[::1]` (IPv6 off), the
58
+ * proxy listens on 127.0.0.1 only, and says so once.
59
+ */
60
+ export declare function startProxy(input: {
61
+ docker: DockerRunner;
62
+ base: string;
63
+ settings: ProxySettings;
64
+ routes: readonly ProxyRoute[];
65
+ err(line: string): void;
66
+ pollMs?: number;
67
+ }): Promise<boolean>;
68
+ /** Rewrite the routes of an existing proxy and reload it (`delete`); never fails. */
69
+ export declare function refreshProxyRoutes(docker: DockerRunner, base: string, routes: readonly ProxyRoute[]): Promise<void>;
70
+ /** Stop the proxy (`stop --all`); true when it stopped. */
71
+ export declare function stopProxy(docker: DockerRunner): Promise<boolean>;
72
+ export interface ProxyStatus {
73
+ state: "running" | "stopped" | "absent" | "disabled";
74
+ port?: number;
75
+ ipv6?: boolean;
76
+ }
77
+ /** The proxy as `nylorun doctor` reports it. */
78
+ export declare function readProxyStatus(docker: DockerRunner, base: string, env: Readonly<Record<string, string | undefined>>): Promise<ProxyStatus>;
@@ -0,0 +1,224 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { parseEnvLines } from "./env-file.js";
6
+ import { PINNED_IMAGES } from "./images.js";
7
+ import { choosePort, DEFAULT_PORTS } from "./ports.js";
8
+ /**
9
+ * The Studio proxy: one small Caddy container per machine that gives each local Tenant's
10
+ * Studio the address `http://<name>.localhost:<port>`. Browsers keep cookies per host, not
11
+ * per port, so separate hosts keep the Studios' sessions apart. It routes browsers to Studio
12
+ * only (programs keep the Runtime's `http://localhost:<port>`: Node's resolver need not
13
+ * resolve `*.localhost`) and holds no Tenant data: its files are `~/.nylorun/proxy/`
14
+ * (`compose.yaml`, `Caddyfile`, `.env`), and Caddy's own state is tmpfs.
15
+ *
16
+ * The proxy joins each Tenant's network (named after its Compose project) and reaches Studio
17
+ * by container name, `<project>-studio:3000`. Docker cannot remove a network the proxy is
18
+ * attached to, so `reset` and `delete` disconnect it first.
19
+ */
20
+ export const PROXY_PROJECT = "nylorun-proxy";
21
+ export const PROXY_CONTAINER = "nylorun-proxy";
22
+ export function proxyPaths(base) {
23
+ const dir = join(base, "proxy");
24
+ return {
25
+ dir,
26
+ compose: join(dir, "compose.yaml"),
27
+ caddyfile: join(dir, "Caddyfile"),
28
+ env: join(dir, ".env"),
29
+ };
30
+ }
31
+ /** `NYLORUN_PROXY_DISABLED=1`: no proxy; Studio is `http://localhost:<port>`. */
32
+ export function proxyDisabled(env) {
33
+ return /^(1|true)$/i.test(env.NYLORUN_PROXY_DISABLED?.trim() ?? "");
34
+ }
35
+ export function proxyOrigin(name, port) {
36
+ return `http://${name}.localhost:${port}`;
37
+ }
38
+ export async function readProxySettings(base) {
39
+ let text;
40
+ try {
41
+ text = await readFile(proxyPaths(base).env, "utf8");
42
+ }
43
+ catch {
44
+ return undefined;
45
+ }
46
+ const values = parseEnvLines(text);
47
+ const port = Number(values.get("NYLORUN_PROXY_PORT"));
48
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
49
+ return undefined;
50
+ return { port, ipv6: values.get("NYLORUN_PROXY_IPV6") !== "false" };
51
+ }
52
+ async function writeAtomic(path, text) {
53
+ const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`;
54
+ await writeFile(temporary, text, { mode: 0o644 });
55
+ await rename(temporary, path);
56
+ }
57
+ async function writeProxySettings(base, settings) {
58
+ const paths = proxyPaths(base);
59
+ await mkdir(paths.dir, { recursive: true, mode: 0o755 });
60
+ await writeAtomic(paths.env, [
61
+ "# Written by `nylorun start`: the Studio proxy's port, kept from its first start,",
62
+ "# and whether it is also published on [::1].",
63
+ `NYLORUN_PROXY_PORT=${settings.port}`,
64
+ `NYLORUN_PROXY_IPV6=${settings.ipv6}`,
65
+ "",
66
+ ].join("\n"));
67
+ }
68
+ /**
69
+ * The proxy's settings. On first use its port is 4160 when free, else a free one outside
70
+ * `taken`; it is kept after that.
71
+ */
72
+ export async function ensureProxySettings(base, ports, taken) {
73
+ const existing = await readProxySettings(base);
74
+ if (existing)
75
+ return existing;
76
+ const settings = { port: await choosePort(ports, DEFAULT_PORTS.proxy, undefined, taken), ipv6: true };
77
+ await writeProxySettings(base, settings);
78
+ return settings;
79
+ }
80
+ /**
81
+ * One site per Tenant. Site addresses carry no port: Caddy listens on 80 in the container,
82
+ * and its host match ignores the published port in the browser's Host. The browser's Host
83
+ * (`<name>.localhost:<port>`) reaches Studio unchanged, which Studio checks; a Tenant that is
84
+ * not running answers 502, and an unknown host 404.
85
+ */
86
+ export function renderCaddyfile(routes) {
87
+ const sites = routes.map(({ name, project }) => `http://${name}.localhost {
88
+ reverse_proxy ${project}-studio:3000
89
+ handle_errors {
90
+ respond \`Tenant ${name} is not running. Run "nylorun start --tenant ${name}".\` 502
91
+ }
92
+ }
93
+ `);
94
+ return [
95
+ `# Written by \`nylorun start\`: a site per Tenant under ~/.nylorun/tenants. Rewritten on every start.
96
+ {
97
+ auto_https off
98
+ admin localhost:2019 # inside the container only; \`caddy reload\` uses it
99
+ }
100
+ `,
101
+ ...sites,
102
+ `:80 {
103
+ respond \`No Tenant at {host}. "nylorun ls" lists the Tenants on this machine.\` 404
104
+ }
105
+ `,
106
+ ].join("\n");
107
+ }
108
+ export function renderProxyCompose(ipv6) {
109
+ return `# Written by \`nylorun start\`; rewritten on every start. The port lives in .env.
110
+ name: ${PROXY_PROJECT}
111
+
112
+ services:
113
+ proxy: # http://<tenant>.localhost:<port>: every local Tenant's Studio
114
+ image: ${PINNED_IMAGES.proxy}
115
+ container_name: ${PROXY_CONTAINER}
116
+ labels:
117
+ dev.nylorun.proxy: "true"
118
+ volumes:
119
+ - .:/etc/caddy:ro # this directory: the Caddyfile, reloaded with \`caddy reload\`
120
+ tmpfs: [/config, /data] # Caddy's own state; nothing to keep
121
+ ports:
122
+ - "127.0.0.1:\${NYLORUN_PROXY_PORT:?run nylorun start}:80"${ipv6
123
+ ? `
124
+ - "[::1]:\${NYLORUN_PROXY_PORT:?run nylorun start}:80" # macOS resolves <name>.localhost to ::1 only`
125
+ : ""}
126
+ restart: unless-stopped
127
+
128
+ networks:
129
+ default:
130
+ name: ${PROXY_PROJECT}
131
+ labels:
132
+ dev.nylorun.proxy: "true"
133
+ `;
134
+ }
135
+ function proxyCompose(paths, ...args) {
136
+ return ["compose", "--project-name", PROXY_PROJECT, "--file", paths.compose, "--env-file", paths.env, ...args];
137
+ }
138
+ function lastLine(text) {
139
+ return text.trim().split(/\r?\n/).at(-1) ?? "";
140
+ }
141
+ /** Attach the proxy to a Tenant's network; "already attached" and "no such network" are fine. */
142
+ async function connectProxy(docker, network) {
143
+ const result = await docker.run(["network", "connect", network, PROXY_CONTAINER]);
144
+ if (result.code === 0 || /already exists|not found|no such network/i.test(result.stderr))
145
+ return undefined;
146
+ return lastLine(result.stderr) || `exit ${result.code}`;
147
+ }
148
+ /** Detach the proxy from a Tenant's network, so Compose can remove it; never fails. */
149
+ export async function disconnectProxy(docker, network) {
150
+ await docker.run(["network", "disconnect", "--force", network, PROXY_CONTAINER]);
151
+ }
152
+ async function reloadProxy(docker) {
153
+ return await docker.run(["exec", PROXY_CONTAINER, "caddy", "reload", "--config", "/etc/caddy/Caddyfile"]);
154
+ }
155
+ /**
156
+ * Write the routes, bring the proxy up (Compose recreates it when its file changed), attach
157
+ * it to every routed Tenant's network and reload Caddy. Never throws: a problem is warned
158
+ * once and the caller shows Studio's own port. When Docker refuses `[::1]` (IPv6 off), the
159
+ * proxy listens on 127.0.0.1 only, and says so once.
160
+ */
161
+ export async function startProxy(input) {
162
+ const { docker, base, err } = input;
163
+ const paths = proxyPaths(base);
164
+ const warn = (problem) => err(`Warning: the Studio proxy is not available (${problem}); Studio is on its own port. See "nylorun doctor".`);
165
+ try {
166
+ await writeAtomic(paths.caddyfile, renderCaddyfile(input.routes));
167
+ await writeAtomic(paths.compose, renderProxyCompose(input.settings.ipv6));
168
+ }
169
+ catch (error) {
170
+ warn(error instanceof Error ? error.message : String(error));
171
+ return false;
172
+ }
173
+ let up = await docker.run(proxyCompose(paths, "up", "--detach"));
174
+ if (up.code !== 0 && input.settings.ipv6) {
175
+ await writeAtomic(paths.compose, renderProxyCompose(false));
176
+ const ipv4 = await docker.run(proxyCompose(paths, "up", "--detach"));
177
+ if (ipv4.code === 0) {
178
+ await writeProxySettings(base, { ...input.settings, ipv6: false });
179
+ err(`Docker refused the Studio proxy on [::1]:${input.settings.port} (${lastLine(up.stderr)}); it listens on 127.0.0.1 only, so a browser that resolves *.localhost to ::1 alone does not reach it.`);
180
+ }
181
+ up = ipv4;
182
+ }
183
+ if (up.code !== 0) {
184
+ warn(`docker compose up: ${lastLine(up.stderr) || `exit ${up.code}`}`);
185
+ return false;
186
+ }
187
+ for (const route of input.routes) {
188
+ const problem = await connectProxy(docker, route.project);
189
+ if (problem)
190
+ err(`Warning: the Studio proxy could not join ${route.project} (${problem}).`);
191
+ }
192
+ // A container Compose just created may not take the reload yet.
193
+ for (let attempt = 1;; attempt += 1) {
194
+ const reload = await reloadProxy(docker);
195
+ if (reload.code === 0)
196
+ return true;
197
+ if (attempt === 10) {
198
+ warn(`caddy reload: ${lastLine(reload.stderr) || `exit ${reload.code}`}`);
199
+ return false;
200
+ }
201
+ await new Promise((resolve) => setTimeout(resolve, input.pollMs ?? 500));
202
+ }
203
+ }
204
+ /** Rewrite the routes of an existing proxy and reload it (`delete`); never fails. */
205
+ export async function refreshProxyRoutes(docker, base, routes) {
206
+ const paths = proxyPaths(base);
207
+ if (!existsSync(paths.caddyfile))
208
+ return;
209
+ await writeAtomic(paths.caddyfile, renderCaddyfile(routes));
210
+ await reloadProxy(docker);
211
+ }
212
+ /** Stop the proxy (`stop --all`); true when it stopped. */
213
+ export async function stopProxy(docker) {
214
+ return (await docker.run(["compose", "--project-name", PROXY_PROJECT, "stop"])).code === 0;
215
+ }
216
+ /** The proxy as `nylorun doctor` reports it. */
217
+ export async function readProxyStatus(docker, base, env) {
218
+ if (proxyDisabled(env))
219
+ return { state: "disabled" };
220
+ const settings = await readProxySettings(base);
221
+ const inspect = await docker.run(["inspect", "--format", "{{.State.Status}}", PROXY_CONTAINER]);
222
+ const state = inspect.code !== 0 ? "absent" : inspect.stdout.trim() === "running" ? "running" : "stopped";
223
+ return { state, ...(settings ? { port: settings.port, ipv6: settings.ipv6 } : {}) };
224
+ }
@@ -1,14 +1,15 @@
1
1
  export type FetchLike = (input: string, init?: RequestInit) => Promise<Response>;
2
- /** Studio's origin as the CLI opens it: always `localhost` (the cookie's host). */
2
+ /** Studio's published origin: always `localhost` (the cookie's host). */
3
3
  export declare function studioOrigin(port: number): string;
4
4
  /**
5
5
  * Ask the Studio container for a single-use login token with the admin key
6
- * (`POST /_studio/login-tokens`, Studio §6) and return the URL to open.
6
+ * (`POST /_studio/login-tokens`, Studio §6) on `origin` (its published port, or
7
+ * the proxy's `http://<name>.localhost:<port>`) and return the URL to open there.
7
8
  * Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
8
9
  */
9
10
  export declare function mintStudioLogin(input: {
10
11
  fetch: FetchLike;
11
- studioPort: number;
12
+ origin: string;
12
13
  adminKey: string;
13
14
  timeoutMs?: number;
14
15
  }): Promise<string>;
@@ -1,15 +1,16 @@
1
1
  import { STACK_CLIENT_HOST } from "./host-files.js";
2
- /** Studio's origin as the CLI opens it: always `localhost` (the cookie's host). */
2
+ /** Studio's published origin: always `localhost` (the cookie's host). */
3
3
  export function studioOrigin(port) {
4
4
  return `http://${STACK_CLIENT_HOST}:${port}`;
5
5
  }
6
6
  /**
7
7
  * Ask the Studio container for a single-use login token with the admin key
8
- * (`POST /_studio/login-tokens`, Studio §6) and return the URL to open.
8
+ * (`POST /_studio/login-tokens`, Studio §6) on `origin` (its published port, or
9
+ * the proxy's `http://<name>.localhost:<port>`) and return the URL to open there.
9
10
  * Accepts `{ loginUrl }`, `{ url }` or `{ token }` in the response.
10
11
  */
11
12
  export async function mintStudioLogin(input) {
12
- const origin = studioOrigin(input.studioPort);
13
+ const { origin } = input;
13
14
  const response = await input.fetch(`${origin}/_studio/login-tokens`, {
14
15
  method: "POST",
15
16
  headers: {
@@ -27,7 +28,7 @@ export async function mintStudioLogin(input) {
27
28
  for (const field of ["loginUrl", "url"]) {
28
29
  const value = body[field];
29
30
  if (typeof value === "string" && value !== "") {
30
- // A relative URL is resolved against the published origin.
31
+ // A relative URL is resolved against the origin it was minted on.
31
32
  return new URL(value, origin).toString();
32
33
  }
33
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nylorun",
3
- "version": "0.5.0-beta",
3
+ "version": "0.6.0-beta",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Set up and run local Nylorun Tenants (Runtime and Studio) with Docker Compose",
@@ -41,7 +41,7 @@
41
41
  "nylorun": "dist/cli.js"
42
42
  },
43
43
  "nylorun": {
44
- "runtime": "0.15.1-beta",
45
- "studio": "0.16.0-beta"
44
+ "runtime": "0.15.2-beta",
45
+ "studio": "0.17.0-beta"
46
46
  }
47
47
  }