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
27 changes: 27 additions & 0 deletions apps/server/src/jones/previewCompanion/RenderHostSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,3 +86,30 @@ it.effect("fails closed when an existing selection file cannot be read", () =>
),
),
);

it.effect("popup inheritance is authoritative and rejects a conflicting generic prebind", () =>
Effect.gen(function* () {
const selection = yield* Selection.RenderHostSelection;
yield* selection.setDefault({ _tag: "companion", hostId: "later-default" });
const key = { threadId: "thread", tabId: "popup" };
expect(yield* selection.bind(key, { _tag: "server" })).toEqual({ _tag: "server" });
expect(yield* selection.bind(key, { _tag: "server" })).toEqual({ _tag: "server" });
yield* selection.release(key);
yield* selection.bind(key);
expect(yield* Effect.exit(selection.bind(key, { _tag: "server" }))).toMatchObject({
_tag: "Failure",
});
expect(yield* selection.binding(key)).toEqual({ _tag: "companion", hostId: "later-default" });
expect(
yield* Effect.exit(selection.bind(key, { _tag: "companion", hostId: "different" })),
).toMatchObject({ _tag: "Failure" });
}).pipe(
Effect.scoped,
Effect.provide(
Selection.layer.pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "popup-selection-" })),
Layer.provide(NodeServices.layer),
),
),
),
);
22 changes: 18 additions & 4 deletions apps/server/src/jones/previewCompanion/RenderHostSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ export class RenderHostSelection extends Context.Service<
threadId: string,
selection: PreviewRenderHostSelection | null,
) => Effect.Effect<void, RenderHostSelectionWriteError>;
readonly bind: (key: DesktopTabKey) => Effect.Effect<PreviewRenderHostSelection>;
readonly bind: (
key: DesktopTabKey,
inherited?: PreviewRenderHostSelection,
) => Effect.Effect<PreviewRenderHostSelection>;
readonly binding: (key: DesktopTabKey) => Effect.Effect<PreviewRenderHostSelection | undefined>;
readonly release: (key: DesktopTabKey) => Effect.Effect<void>;
}
Expand Down Expand Up @@ -132,11 +135,22 @@ const make = Effect.gen(function* () {
});
return { ...state, threads };
}),
bind: (key) =>
bind: (key, inherited) =>
Effect.sync(() => {
const existing = bindings.get(keyOf(key));
if (existing) return existing.selection;
const selection = effective(key.threadId);
if (existing) {
if (
inherited &&
(existing.selection._tag !== inherited._tag ||
(existing.selection._tag === "companion" &&
inherited._tag === "companion" &&
existing.selection.hostId !== inherited.hostId))
) {
throw new Error("Popup render host conflicts with its opener's recorded selection.");
}
return existing.selection;
}
const selection = inherited ?? effective(key.threadId);
bindings.set(keyOf(key), { key, selection });
return selection;
}),
Expand Down
25 changes: 25 additions & 0 deletions apps/server/src/jones/previewCompanion/RenderPlacement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,28 @@ it.live("preserves the typed not_started failure at the existing Promise boundar
await expect(runPlacement(Effect.fail(error))).rejects.toBe(error);
}),
);

it.effect(
"synchronous admission freezes ordinary opens and inherits only recorded popup selection",
() =>
Effect.gen(function* () {
const placement = yield* Placement.RenderPlacement;
const selection = yield* Selection.RenderHostSelection;
expect(placement.bindAtOpen(snapshot)).toEqual({ _tag: "server" });
yield* selection.setDefault({ _tag: "companion", hostId: "later" });
expect(placement.bindAtOpen({ ...snapshot, tabId: "popup" }, snapshot)).toEqual({
_tag: "server",
});
expect(
placement.bindAtOpen({ ...snapshot, tabId: "orphan" }, { ...snapshot, tabId: "missing" }),
).toEqual({ _tag: "server" });
expect(yield* placement.place(snapshot)).toEqual({ _tag: "headless" });
const next = { ...snapshot, tabId: "next" };
expect(placement.bindAtOpen(next)).toEqual({ _tag: "companion", hostId: "later" });
expect(placement.bindAtOpen({ ...snapshot, tabId: "companion-popup" }, next)).toEqual({
_tag: "companion",
hostId: "later",
});
expect(() => placement.bindAtOpen(next, snapshot)).toThrow();
}).pipe(Effect.scoped, Effect.provide(dependencies)),
);
16 changes: 15 additions & 1 deletion apps/server/src/jones/previewCompanion/RenderPlacement.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { PreviewCompanionCapabilities, PreviewSessionSnapshot } from "@t3tools/contracts";
import type {
PreviewCompanionCapabilities,
PreviewSessionSnapshot,
PreviewRenderHostSelection,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -19,6 +23,7 @@ export type Placement =
export class RenderPlacement extends Context.Service<
RenderPlacement,
{
readonly bindAtOpen: (key: DesktopTabKey, opener?: DesktopTabKey) => PreviewRenderHostSelection;
readonly channel: CompanionBrowserChannel.CompanionBrowserChannel["Service"];
readonly place: (
snapshot: PreviewSessionSnapshot,
Expand All @@ -37,6 +42,15 @@ const make = Effect.gen(function* () {
const desktop = yield* DesktopBrowserChannel.DesktopBrowserChannel;
return RenderPlacement.of({
channel,
bindAtOpen: (key, opener) =>
Effect.runSync(
selection.bind(
key,
opener === undefined
? undefined
: (Effect.runSync(selection.binding(opener)) ?? { _tag: "server" }),
),
),
place: Effect.fn("RenderPlacement.place")(function* (snapshot) {
const bound = yield* selection.bind(snapshot);
if (bound._tag === "server") return { _tag: desktop.available ? "local" : "headless" };
Expand Down
43 changes: 43 additions & 0 deletions apps/server/src/jones/previewCompanion/openBinding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, it } from "@effect/vitest";
import { ThreadId } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as ServerConfig from "../../config.ts";
import * as Manager from "../../preview/Manager.ts";
import * as Selection from "./RenderHostSelection.ts";
import { resolveOpenBinding } from "./openBinding.ts";

it.effect("leaves legacy compositions without render-host selection compatible", () =>
Effect.gen(function* () {
assert.isUndefined(yield* resolveOpenBinding());
}),
);

it.effect("skips non-server opens and preserves saved thread choices at admission", () =>
Effect.gen(function* () {
const manager = yield* Manager.PreviewManager;
const selection = yield* Selection.RenderHostSelection;
const beforePublish = yield* resolveOpenBinding();
if (beforePublish === undefined) return yield* Effect.die("Missing open binding");
const threadId = ThreadId.make("thread");
yield* selection.setThread(threadId, { _tag: "companion", hostId: "chosen" });
for (const runtime of [undefined, "desktop"] as const) {
const snapshot = yield* manager.open({ threadId, runtime, beforePublish });
assert.isUndefined(yield* selection.binding(snapshot));
}
const snapshot = yield* manager.open({ threadId, runtime: "server", beforePublish });
assert.deepEqual(yield* selection.binding(snapshot), { _tag: "companion", hostId: "chosen" });
yield* selection.setThread(threadId, { _tag: "server" });
beforePublish(snapshot);
assert.deepEqual(yield* selection.binding(snapshot), { _tag: "companion", hostId: "chosen" });
}).pipe(
Effect.scoped,
Effect.provide(
Layer.merge(Manager.layer, Selection.layer).pipe(
Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "preview-open-binding-" })),
Layer.provide(NodeServices.layer),
),
),
),
);
14 changes: 14 additions & 0 deletions apps/server/src/jones/previewCompanion/openBinding.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { PreviewSessionSnapshot } from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Option from "effect/Option";
import * as RenderHostSelection from "./RenderHostSelection.ts";

export const resolveOpenBinding = Effect.fn("PreviewCompanion.resolveOpenBinding")(function* () {
const selection = yield* Effect.serviceOption(RenderHostSelection.RenderHostSelection);
if (Option.isNone(selection)) return undefined;
return (snapshot: PreviewSessionSnapshot): void => {
if (snapshot.runtime !== "server") return;
// bind is synchronous: freeze selection before Manager publishes opened to its async mirror.
Effect.runSync(selection.value.bind(snapshot));
};
});
Loading