Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-browsers-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"daegari": minor
---

Report typed browser-launch failures and return explicit Host and browser request outcomes from Target opening.
1 change: 1 addition & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/verify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:

permissions:
contents: read
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,12 @@ await app.host.stop()
```

The first remote procedure or `open()` call reuses a live Host or starts the built
Host in the background. `open()` returns whether the Host was `started` or `reused`
and a capability-bearing URL; treat that URL as sensitive data.
Host in the background. `open()` returns the Host outcome, whether a browser request
was made, and a capability-bearing URL; treat that URL as sensitive data.

`browser: "requested"` means Daegari dispatched the URL to the platform launcher. It
does not guarantee that a browser tab loaded or rendered. Launcher startup and early
handoff failures throw `App.Target.BrowserLaunchError`.

A Target is an opaque non-empty string of at most 8 KiB in UTF-8. Daegari does not
trim, normalize, resolve, or interpret it. The product canonicalizes files, URLs, and
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
- [x] Finalize the consumer-owned Target resolver interface and playground example
- [x] Cover multi-Target launch, reconnection, cancellation, and shutdown in integration
tests
- [x] Stabilize cross-platform browser launching with typed failures and explicit
request results
- [ ] Exchange short-lived launch capabilities for renewable browser-session
capabilities

Expand Down
8 changes: 6 additions & 2 deletions docs/v0.2-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,16 @@ optionally opens the browser. Normal outcomes are returned instead of printed:

```ts
type App.Target.open.ReturnType = {
status: "started" | "reused"
browser: "requested" | "skipped"
host: "started" | "reused"
url: string
}
```

The URL contains a launch capability and must be treated as a credential.
`browser: "requested"` means Daegari dispatched the URL to the platform launcher; it
does not guarantee that a tab loaded or rendered. Launcher startup and early handoff
failures throw `App.Target.BrowserLaunchError`. The URL contains a launch capability
and must be treated as a credential.

## Host lifecycle

Expand Down
3 changes: 3 additions & 0 deletions packages/daegari/src/app/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ describe("App", () => {
expect(new App.Target.InvalidRouteError("invalid").code).toBe(
"DAEGARI_APP_TARGET_INVALID_ROUTE",
);
expect(new App.Target.BrowserLaunchError("failed").code).toBe(
"DAEGARI_APP_TARGET_BROWSER_LAUNCH",
);
expect(new App.Target.RecentNotFoundError("missing").code).toBe(
"DAEGARI_APP_TARGET_RECENT_NOT_FOUND",
);
Expand Down
5 changes: 5 additions & 0 deletions packages/daegari/src/app/exports.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ async function targetContract() {
channel: "cli" | "mcp" | "ui";
target: string;
}>();
expectTypeOf(open).toEqualTypeOf<{
browser: "requested" | "skipped";
host: "reused" | "started";
url: string;
}>();
expectTypeOf(open).toEqualTypeOf<App.Target.open.ReturnType>();
}

Expand Down
15 changes: 14 additions & 1 deletion packages/daegari/src/app/target.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export type Instance<TRouter extends Router<Context>> = {
*
* @throws {@link InvalidRouteError} when the product route violates the same-origin
* path contract.
* @throws {@link BrowserLaunchError} when the platform launcher cannot be started or
* reports a failure during handoff.
*/
open(options?: open.Options): Promise<open.ReturnType>;
};
Expand All @@ -44,6 +46,12 @@ export class InvalidRouteError extends BaseError {
override name = "App.Target.InvalidRouteError";
}

/** The platform browser launcher could not be started or reported a handoff failure. */
export class BrowserLaunchError extends BaseError {
override readonly code = "DAEGARI_APP_TARGET_BROWSER_LAUNCH";
override name = "App.Target.BrowserLaunchError";
}

/** `app.target()` was called without a Ref before this App stored a recent Target. */
export class RecentNotFoundError extends BaseError {
override readonly code = "DAEGARI_APP_TARGET_RECENT_NOT_FOUND";
Expand All @@ -61,8 +69,13 @@ export declare namespace open {

/** Result of creating a Target-bound browser launch. */
type ReturnType = {
/**
* `requested` means Daegari dispatched the URL to the platform launcher; it does
* not guarantee that a browser tab loaded or rendered.
*/
browser: "requested" | "skipped";
/** Whether Daegari reused an existing Host or started a new one. */
status: "reused" | "started";
host: "reused" | "started";
/** Capability-bearing launch URL. Treat it as short-lived sensitive data. */
url: string;
};
Expand Down
110 changes: 110 additions & 0 deletions packages/daegari/src/core/internal/browser-launcher.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { EventEmitter } from "node:events";
import { describe, expect, test, vi } from "vitest";

import { requestBrowser } from "./browser-launcher.js";

function fakeProcess() {
return Object.assign(new EventEmitter(), { unref: vi.fn() });
}

describe("browser launcher", () => {
test.each([
["darwin", "http://127.0.0.1:5173/", "open", ["http://127.0.0.1:5173/"]],
["linux", "http://127.0.0.1:5173/", "xdg-open", ["http://127.0.0.1:5173/"]],
[
"win32",
"http://127.0.0.1:5173/?target=alpha&view=details#token=secret",
"rundll32.exe",
[
"url.dll,FileProtocolHandler",
"http://127.0.0.1:5173/?target=alpha&view=details#token=secret",
],
],
] as const)(
"dispatches through the %s platform launcher",
async (platform, url, command, args) => {
const child = fakeProcess();
const spawn = vi.fn(() => {
queueMicrotask(() => {
child.emit("spawn");
child.emit("close", 0, null);
});
return child;
});

await requestBrowser(url, { platform, spawn });

expect(spawn).toHaveBeenCalledWith(command, args, {
detached: true,
stdio: "ignore",
windowsHide: true,
});
expect(child.unref).toHaveBeenCalledOnce();
},
);

test("turns a spawn failure into a typed Target error", async () => {
const child = fakeProcess();
const cause = Object.assign(new Error("spawn xdg-open ENOENT"), { code: "ENOENT" });
const spawn = vi.fn(() => {
queueMicrotask(() => child.emit("error", cause));
return child;
});

await expect(
requestBrowser("http://127.0.0.1:5173/#token=secret", {
handoffTimeoutMs: 10,
platform: "linux",
spawn,
}),
).rejects.toMatchObject({
cause,
code: "DAEGARI_APP_TARGET_BROWSER_LAUNCH",
name: "App.Target.BrowserLaunchError",
});
expect(child.unref).not.toHaveBeenCalled();
});

test("reports a launcher that rejects the request immediately", async () => {
const child = fakeProcess();
const spawn = vi.fn(() => {
queueMicrotask(() => {
child.emit("spawn");
child.emit("close", 4, null);
});
return child;
});

await expect(
requestBrowser("http://127.0.0.1:5173/#token=secret", {
handoffTimeoutMs: 10,
platform: "linux",
spawn,
}),
).rejects.toMatchObject({
code: "DAEGARI_APP_TARGET_BROWSER_LAUNCH",
name: "App.Target.BrowserLaunchError",
});
expect(child.unref).toHaveBeenCalledOnce();
});

test("stops waiting after the bounded handoff window", async () => {
vi.useFakeTimers();
try {
const child = fakeProcess();
const request = requestBrowser("http://127.0.0.1:5173/", {
handoffTimeoutMs: 10,
platform: "linux",
spawn: () => child,
});

child.emit("spawn");
await vi.advanceTimersByTimeAsync(10);

await expect(request).resolves.toBeUndefined();
expect(child.unref).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
});
84 changes: 84 additions & 0 deletions packages/daegari/src/core/internal/browser-launcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { spawn as spawnProcess } from "node:child_process";

import { BrowserLaunchError } from "../../app/target.js";
Comment thread
2wheeh marked this conversation as resolved.

type BrowserProcess = {
off(event: string | symbol, listener: (...args: unknown[]) => void): unknown;
once(event: string | symbol, listener: (...args: unknown[]) => void): unknown;
unref(): void;
};

type SpawnBrowser = (
command: string,
args: string[],
options: { detached: true; stdio: "ignore"; windowsHide: true },
) => BrowserProcess;

type RequestBrowserOptions = {
handoffTimeoutMs?: number;
platform?: NodeJS.Platform;
spawn?: SpawnBrowser;
};

const defaultHandoffTimeoutMs = 1_000;

function launcher(platform: NodeJS.Platform, url: string) {
if (platform === "darwin") return { args: [url], command: "open" };
if (platform === "win32") {
return { args: ["url.dll,FileProtocolHandler", url], command: "rundll32.exe" };
}
return { args: [url], command: "xdg-open" };
Comment thread
2wheeh marked this conversation as resolved.
}

function launchError(cause: unknown) {
return new BrowserLaunchError("Failed to request the platform browser", { cause });
}

export function requestBrowser(url: string, options: RequestBrowserOptions = {}): Promise<void> {
const spawn =
options.spawn ??
((command, args, spawnOptions) => spawnProcess(command, args, spawnOptions) as BrowserProcess);
const { args, command } = launcher(options.platform ?? process.platform, url);

return new Promise((resolve, reject) => {
let child: ReturnType<SpawnBrowser>;
try {
child = spawn(command, args, { detached: true, stdio: "ignore", windowsHide: true });
} catch (error) {
reject(launchError(error));
return;
}

let timer: NodeJS.Timeout | undefined;
let settled = false;

const finish = (result?: BrowserLaunchError) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
child.off("error", onError);
child.off("close", onClose);
child.off("spawn", onSpawn);
if (result) reject(result);
else resolve();
};
Comment thread
2wheeh marked this conversation as resolved.
const onError = (error: unknown) => finish(launchError(error));
const onClose = (code: unknown, signal: unknown) => {
if (code === 0) {
finish();
return;
}
const outcome = code === null ? `signal ${String(signal)}` : `exit code ${String(code)}`;
finish(launchError(new Error(`Browser launcher "${command}" failed with ${outcome}`)));
};
const onSpawn = () => {
if (settled) return;
child.unref();
child.once("close", onClose);
timer = setTimeout(finish, options.handoffTimeoutMs ?? defaultHandoffTimeoutMs);
};

child.once("error", onError);
child.once("spawn", onSpawn);
});
}
14 changes: 4 additions & 10 deletions packages/daegari/src/core/internal/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { spawn } from "node:child_process";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type { Router, RouterClient } from "@orpc/server";
Expand All @@ -11,19 +10,13 @@ import { hostApi, hostHeaders } from "../../internal/host-protocol.js";
import { type Runtime, runtime } from "../../internal/runtime.js";
import { createSystemClient } from "../../internal/system-client.js";
import type { Channel, Context } from "../../rpc/procedure.js";
import { requestBrowser } from "./browser-launcher.js";
import { serveHost } from "./host.js";
import { isHostProcess, launchHost } from "./launcher.js";
import { findRuntimeManifest } from "./manifest.js";
import { parseTarget, type Session } from "./protocol.js";
import { createHostStorage } from "./storage.js";

function openBrowser(url: string) {
const command =
process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
spawn(command, args, { detached: true, stdio: "ignore" }).unref();
}

function browserUrl(origin: string, token: string, route = "/") {
const url = new URL(route, origin);
url.hash = new URLSearchParams({ token }).toString();
Expand Down Expand Up @@ -133,8 +126,9 @@ export function createRuntime<TRouter extends Router<Context>>(
const launched = await ensureHost();
const launch = await createLaunch(launched.session, ref);
const url = browserUrl(launched.session.origin, launch.launchToken, route);
if (openOptions.browser !== false) openBrowser(url);
return { status: launched.started ? "started" : "reused", url };
const browser = openOptions.browser === false ? "skipped" : "requested";
if (browser === "requested") await requestBrowser(url);
return { browser, host: launched.started ? "started" : "reused", url };
},
};
}
Expand Down
1 change: 1 addition & 0 deletions packages/vite/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"scripts": {
"clean": "node ../../scripts/clean.mjs",
"build": "tsdown",
"pretest": "pnpm --filter daegari build",
"test": "vitest run",
"test:type": "vitest --typecheck.only",
"typecheck": "tsc --noEmit"
Expand Down
5 changes: 3 additions & 2 deletions playgrounds/greeting/scripts/lifecycle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ const runCli = (...arguments_) => runCliWith(environment, ...arguments_);

try {
const first = JSON.parse((await runCli("open", "document:first", "--no-open")).stdout);
assert.equal(first.status, "started");
assert.equal(first.host, "started");
assert.equal(first.browser, "skipped");
const firstStatus = JSON.parse((await runCli("status")).stdout);
assert.equal(firstStatus.state, "running");

Expand All @@ -31,7 +32,7 @@ try {
Array.from({ length: 4 }, () => runCli("open", "document:atomic", "--no-open")),
);
const results = concurrent.map(({ stdout }) => JSON.parse(stdout));
assert.equal(results.filter(({ status }) => status === "started").length, 1);
assert.equal(results.filter(({ host }) => host === "started").length, 1);
assert.equal(new Set(results.map(({ url }) => new URL(url).origin)).size, 1);
await runCli("stop");

Expand Down
Loading