Skip to content
Closed
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
150 changes: 150 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AuthEnvironmentBootstrapTokenType,
AuthTokenExchangeGrantType,
CommandId,
CURRENT_KEYBINDING_COMMAND_SET_VERSION,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
EventId,
Expand All @@ -27,6 +28,7 @@ import {
ProviderInstanceId,
ResolvedKeybindingRule,
ThreadId,
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
WS_METHODS,
WsRpcGroup,
EditorId,
Expand Down Expand Up @@ -4321,6 +4323,154 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("projects keybinding commands for each websocket client version", () =>
Effect.gen(function* () {
const currentCommands = [
"terminal.toggle",
"filePicker.toggle",
"projectSearch.toggle",
"script.format.run",
] satisfies Array<ResolvedKeybindingRule["command"]>;
const shortcut = {
key: "k",
metaKey: false,
ctrlKey: true,
shiftKey: false,
altKey: false,
modKey: true,
};
const keybindings = currentCommands.map((command) => ({
command,
shortcut,
})) satisfies ReadonlyArray<ResolvedKeybindingRule>;
const issues = [
{
kind: "keybindings.invalid-entry",
message: "Preserved issue",
index: 4,
},
] as const;
const changeEvent = { keybindings, issues };

yield* buildAppUnderTest({
layers: {
keybindings: {
loadConfigState: Effect.succeed({ keybindings, issues }),
streamChanges: Stream.succeed(changeEvent),
upsertKeybindingRule: () => Effect.succeed(keybindings),
removeKeybindingRule: () => Effect.succeed(keybindings),
},
},
});

const unnegotiatedUrl = yield* getWsServerUrl("/ws");
const oldClientUrl = new URL(unnegotiatedUrl);
oldClientUrl.searchParams.set(WS_KEYBINDING_COMMAND_SET_QUERY_PARAM, "0");
const currentClientUrl = new URL(unnegotiatedUrl);
currentClientUrl.searchParams.set(
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
String(CURRENT_KEYBINDING_COMMAND_SET_VERSION),
);
const futureClientUrl = new URL(unnegotiatedUrl);
futureClientUrl.searchParams.set(
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
String(CURRENT_KEYBINDING_COMMAND_SET_VERSION + 1),
);
const legacyCommands = ["terminal.toggle", "script.format.run"];
const commands = (rules: ReadonlyArray<ResolvedKeybindingRule>) =>
rules.map(({ command }) => command);
const mutationRule: KeybindingRule = {
command: "terminal.toggle",
key: "ctrl+t",
};

const unnegotiatedConfig = yield* Effect.scoped(
withWsRpcClient(unnegotiatedUrl, (client) => client[WS_METHODS.serverGetConfig]({})),
);
const oldClientConfig = yield* Effect.scoped(
withWsRpcClient(oldClientUrl.toString(), (client) =>
client[WS_METHODS.serverGetConfig]({}),
),
);
const currentClientConfig = yield* Effect.scoped(
withWsRpcClient(currentClientUrl.toString(), (client) =>
client[WS_METHODS.serverGetConfig]({}),
),
);
const futureClientConfig = yield* Effect.scoped(
withWsRpcClient(futureClientUrl.toString(), (client) =>
client[WS_METHODS.serverGetConfig]({}),
),
);
const unnegotiatedEvents = yield* Effect.scoped(
withWsRpcClient(unnegotiatedUrl, (client) =>
client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
),
);
const currentClientEvents = yield* Effect.scoped(
withWsRpcClient(currentClientUrl.toString(), (client) =>
client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect),
),
);
const unnegotiatedUpsert = yield* Effect.scoped(
withWsRpcClient(unnegotiatedUrl, (client) =>
client[WS_METHODS.serverUpsertKeybinding](mutationRule),
),
);
const currentClientUpsert = yield* Effect.scoped(
withWsRpcClient(currentClientUrl.toString(), (client) =>
client[WS_METHODS.serverUpsertKeybinding](mutationRule),
),
);
const unnegotiatedRemove = yield* Effect.scoped(
withWsRpcClient(unnegotiatedUrl, (client) =>
client[WS_METHODS.serverRemoveKeybinding](mutationRule),
),
);
const currentClientRemove = yield* Effect.scoped(
withWsRpcClient(currentClientUrl.toString(), (client) =>
client[WS_METHODS.serverRemoveKeybinding](mutationRule),
),
);

assert.deepEqual(commands(unnegotiatedConfig.keybindings), legacyCommands);
assert.deepEqual(commands(oldClientConfig.keybindings), legacyCommands);
assert.deepEqual(commands(currentClientConfig.keybindings), currentCommands);
assert.deepEqual(commands(futureClientConfig.keybindings), currentCommands);
assert.deepEqual(unnegotiatedConfig.issues, issues);
assert.deepEqual(currentClientConfig.issues, issues);

const [unnegotiatedSnapshot, unnegotiatedUpdate] = Array.from(unnegotiatedEvents);
assert.equal(unnegotiatedSnapshot?.type, "snapshot");
assert.equal(unnegotiatedUpdate?.type, "keybindingsUpdated");
if (unnegotiatedSnapshot?.type === "snapshot") {
assert.deepEqual(commands(unnegotiatedSnapshot.config.keybindings), legacyCommands);
assert.deepEqual(unnegotiatedSnapshot.config.issues, issues);
}
if (unnegotiatedUpdate?.type === "keybindingsUpdated") {
assert.deepEqual(commands(unnegotiatedUpdate.payload.keybindings), legacyCommands);
assert.deepEqual(unnegotiatedUpdate.payload.issues, issues);
}

const [currentSnapshot, currentUpdate] = Array.from(currentClientEvents);
assert.equal(currentSnapshot?.type, "snapshot");
assert.equal(currentUpdate?.type, "keybindingsUpdated");
if (currentSnapshot?.type === "snapshot") {
assert.deepEqual(commands(currentSnapshot.config.keybindings), currentCommands);
assert.deepEqual(currentSnapshot.config.issues, issues);
}
if (currentUpdate?.type === "keybindingsUpdated") {
assert.deepEqual(commands(currentUpdate.payload.keybindings), currentCommands);
assert.deepEqual(currentUpdate.payload.issues, issues);
}

assert.deepEqual(commands(unnegotiatedUpsert.keybindings), legacyCommands);
assert.deepEqual(commands(currentClientUpsert.keybindings), currentCommands);
assert.deepEqual(commands(unnegotiatedRemove.keybindings), legacyCommands);
assert.deepEqual(commands(currentClientRemove.keybindings), currentCommands);
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

it.effect("shares one preview automation broker across websocket sessions", () =>
Effect.scoped(
Effect.gen(function* () {
Expand Down
54 changes: 49 additions & 5 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
type AuthEnvironmentScope,
AuthSessionId,
CommandId,
CURRENT_KEYBINDING_COMMAND_SET_VERSION,
type DiscoveredLocalServerList,
EventId,
type OrchestrationCommand,
Expand All @@ -41,6 +42,7 @@ import {
ProjectSearchEntriesError,
ProjectWriteFileError,
RelayClientInstallFailedError,
type ResolvedKeybindingRule,
type RelayClientInstallProgressEvent,
type ServerSelfUpdateError,
type ServerSelfUpdateProgressEvent,
Expand All @@ -55,6 +57,7 @@ import {
type TerminalError,
type TerminalEvent,
type TerminalMetadataStreamEvent,
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
WS_METHODS,
WsRpcGroup,
} from "@t3tools/contracts";
Expand Down Expand Up @@ -125,6 +128,21 @@ const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchComma
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
const EDITOR_DISCOVERY_TIMEOUT = Duration.seconds(5);

const projectKeybindingsForClient = (
keybindings: ReadonlyArray<ResolvedKeybindingRule>,
supportsCurrentKeybindingCommands: boolean,
) =>
supportsCurrentKeybindingCommands
? keybindings
: keybindings.filter(
({ command }) => command !== "filePicker.toggle" && command !== "projectSearch.toggle",
);

const supportsCurrentKeybindingCommandSet = (url: URL) => {
const version = Number(url.searchParams.get(WS_KEYBINDING_COMMAND_SET_QUERY_PARAM));
return Number.isSafeInteger(version) && version >= CURRENT_KEYBINDING_COMMAND_SET_VERSION;
};

export const resolveAvailableEditorsForConfig = <A, E, R>(
discovery: Effect.Effect<ReadonlyArray<A>, E, R>,
) =>
Expand Down Expand Up @@ -341,6 +359,7 @@ function toAuthAccessStreamEvent(
const makeWsRpcLayer = (
currentSession: EnvironmentAuth.AuthenticatedSession,
previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"],
supportsCurrentKeybindingCommands: boolean,
) =>
WsRpcGroup.toLayer(
Effect.gen(function* () {
Expand Down Expand Up @@ -983,7 +1002,10 @@ const makeWsRpcLayer = (
auth,
cwd: config.cwd,
keybindingsConfigPath: config.keybindingsConfigPath,
keybindings: keybindingsConfig.keybindings,
keybindings: projectKeybindingsForClient(
keybindingsConfig.keybindings,
supportsCurrentKeybindingCommands,
),
issues: keybindingsConfig.issues,
providers,
availableEditors: yield* resolveAvailableEditorsForConfig(
Expand Down Expand Up @@ -1414,7 +1436,13 @@ const makeWsRpcLayer = (
WS_METHODS.serverUpsertKeybinding,
Effect.gen(function* () {
const keybindingsConfig = yield* keybindings.upsertKeybindingRule(rule);
return { keybindings: keybindingsConfig, issues: [] };
return {
keybindings: projectKeybindingsForClient(
keybindingsConfig,
supportsCurrentKeybindingCommands,
),
issues: [],
};
}),
{ "rpc.aggregate": "server" },
),
Expand All @@ -1423,7 +1451,13 @@ const makeWsRpcLayer = (
WS_METHODS.serverRemoveKeybinding,
Effect.gen(function* () {
const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule);
return { keybindings: keybindingsConfig, issues: [] };
return {
keybindings: projectKeybindingsForClient(
keybindingsConfig,
supportsCurrentKeybindingCommands,
),
issues: [],
};
}),
{ "rpc.aggregate": "server" },
),
Expand Down Expand Up @@ -1973,7 +2007,10 @@ const makeWsRpcLayer = (
version: 1 as const,
type: "keybindingsUpdated" as const,
payload: {
keybindings: event.keybindings,
keybindings: projectKeybindingsForClient(
event.keybindings,
supportsCurrentKeybindingCommands,
),
issues: event.issues,
},
})),
Expand Down Expand Up @@ -2095,6 +2132,9 @@ export const websocketRpcRouteLayer = Layer.unwrap(
"/ws",
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest;
const requestUrl = HttpServerRequest.toURL(request);
const supportsCurrentKeybindingCommands =
Option.isSome(requestUrl) && supportsCurrentKeybindingCommandSet(requestUrl.value);
const serverAuth = yield* EnvironmentAuth.EnvironmentAuth;
const sessions = yield* SessionStore.SessionStore;
const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe(
Expand All @@ -2109,7 +2149,11 @@ export const websocketRpcRouteLayer = Layer.unwrap(
disableTracing: true,
}).pipe(
Effect.provide(
makeWsRpcLayer(session, previewAutomationBroker).pipe(
makeWsRpcLayer(
session,
previewAutomationBroker,
supportsCurrentKeybindingCommands,
).pipe(
Layer.provideMerge(RpcSerialization.layerJson),
Layer.provide(ProviderMaintenanceRunner.layer),
Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)),
Expand Down
8 changes: 7 additions & 1 deletion packages/client-runtime/src/rpc/session.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import {
CURRENT_KEYBINDING_COMMAND_SET_VERSION,
DEFAULT_SERVER_SETTINGS,
EnvironmentId,
ServerConfig,
type ServerConfig as ServerConfigType,
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
WS_METHODS,
} from "@t3tools/contracts";
import { describe, expect, it } from "@effect/vitest";
Expand Down Expand Up @@ -222,7 +224,11 @@ describe("RpcSessionFactory", () => {
const readyFiber = yield* Effect.forkChild(session.ready);
const socket = yield* awaitSocket(sockets);

expect(socket.url).toBe(PREPARED.socketUrl);
const socketUrl = new URL(socket.url);
expect(socketUrl.searchParams.get("wsTicket")).toBe("test");
expect(socketUrl.searchParams.get(WS_KEYBINDING_COMMAND_SET_QUERY_PARAM)).toBe(
String(CURRENT_KEYBINDING_COMMAND_SET_VERSION),
);
socket.open();
yield* completeInitialConfig(socket);
yield* Fiber.join(readyFiber);
Expand Down
14 changes: 12 additions & 2 deletions packages/client-runtime/src/rpc/session.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type ServerConfig, WS_METHODS } from "@t3tools/contracts";
import {
CURRENT_KEYBINDING_COMMAND_SET_VERSION,
type ServerConfig,
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
WS_METHODS,
} from "@t3tools/contracts";
import * as Context from "effect/Context";
import * as Deferred from "effect/Deferred";
import * as Effect from "effect/Effect";
Expand Down Expand Up @@ -92,7 +97,12 @@ export const make = Effect.gen(function* () {
Effect.asVoid,
),
});
const socketLayer = Socket.layerWebSocket(connection.socketUrl, {
const socketUrl = new URL(connection.socketUrl);
socketUrl.searchParams.set(
WS_KEYBINDING_COMMAND_SET_QUERY_PARAM,
String(CURRENT_KEYBINDING_COMMAND_SET_VERSION),
);
const socketLayer = Socket.layerWebSocket(socketUrl.toString(), {
openTimeout: SOCKET_OPEN_TIMEOUT,
}).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor)));
const protocolLayer = Layer.effect(
Expand Down
5 changes: 5 additions & 0 deletions packages/contracts/src/rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,11 @@ import {
} from "./sourceControl.ts";
import { VcsError } from "./vcs.ts";

/** WebSocket capability for keybinding commands added after v0.0.31.
Absence means the client needs the legacy-safe keybinding projection. */
export const WS_KEYBINDING_COMMAND_SET_QUERY_PARAM = "keybindingCommandSet";
export const CURRENT_KEYBINDING_COMMAND_SET_VERSION = 1;

export const WS_METHODS = {
// Project registry methods
projectsList: "projects.list",
Expand Down
Loading