diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index fff71dbb4e7..7c990522f1f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -9,6 +9,7 @@ import { AuthEnvironmentBootstrapTokenType, AuthTokenExchangeGrantType, CommandId, + CURRENT_KEYBINDING_COMMAND_SET_VERSION, DEFAULT_SERVER_SETTINGS, EnvironmentId, EventId, @@ -27,6 +28,7 @@ import { ProviderInstanceId, ResolvedKeybindingRule, ThreadId, + WS_KEYBINDING_COMMAND_SET_QUERY_PARAM, WS_METHODS, WsRpcGroup, EditorId, @@ -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; + const shortcut = { + key: "k", + metaKey: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + modKey: true, + }; + const keybindings = currentCommands.map((command) => ({ + command, + shortcut, + })) satisfies ReadonlyArray; + 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) => + 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* () { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 06888ef3f70..8400ef52b7e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -16,6 +16,7 @@ import { type AuthEnvironmentScope, AuthSessionId, CommandId, + CURRENT_KEYBINDING_COMMAND_SET_VERSION, type DiscoveredLocalServerList, EventId, type OrchestrationCommand, @@ -41,6 +42,7 @@ import { ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, + type ResolvedKeybindingRule, type RelayClientInstallProgressEvent, type ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, @@ -55,6 +57,7 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + WS_KEYBINDING_COMMAND_SET_QUERY_PARAM, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; @@ -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, + 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 = ( discovery: Effect.Effect, E, R>, ) => @@ -341,6 +359,7 @@ function toAuthAccessStreamEvent( const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], + supportsCurrentKeybindingCommands: boolean, ) => WsRpcGroup.toLayer( Effect.gen(function* () { @@ -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( @@ -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" }, ), @@ -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" }, ), @@ -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, }, })), @@ -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( @@ -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)), diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 71649ad94dd..62c00206bc8 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -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"; @@ -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); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa406..026b63c5c7d 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -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"; @@ -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( diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 400011f8843..8e474e50afe 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -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",