Skip to content
2 changes: 2 additions & 0 deletions .github/VOUCHED.td
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# Keep entries sorted alphabetically.
github:adityavardhansharma
github:binbandit
github:chrisdeeming
github:chuks-qua
github:cursoragent
github:gbarros-dev
Expand All @@ -26,6 +27,7 @@ github:notkainoa
github:PatrickBauer
github:realAhmedRoach
github:shiroyasha9
github:StiensWout
github:Yash-Singh1
github:eggfriedrice24
github:Ymit24
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope,
[WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
[WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope,
[WS_METHODS.serverRunStopHook]: AuthOrchestrationOperateScope,
[WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope,
Expand Down
102 changes: 102 additions & 0 deletions apps/server/src/instanceHooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ServerStopHookError } from "@t3tools/contracts";
import { assert, it } from "@effect/vitest";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Schema from "effect/Schema";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";

import * as InstanceHooks from "./instanceHooks.ts";
import * as ServerSettings from "./serverSettings.ts";

interface RecordedHookRequest {
readonly method: string;
readonly url: string;
}

const makeHookEndpointLayer = (requests: Array<RecordedHookRequest>, status: number) =>
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
requests.push({ method: request.method, url: request.url });
return HttpClientResponse.fromWeb(request, new Response(null, { status }));
}),
),
);

const isStopHookError = Schema.is(ServerStopHookError);

it.effect("DELETEs the stop hook and reports the instance as stopping on 204", () =>
Effect.gen(function* () {
const requests: Array<RecordedHookRequest> = [];
const result = yield* InstanceHooks.runStopHook.pipe(
Effect.provide(
Layer.mergeAll(
makeHookEndpointLayer(requests, 204),
ServerSettings.layerTest({ stopHookUrl: "https://mgmt.example.test/instances/1/stop" }),
),
),
);
assert.deepEqual(result, { outcome: "stopped" });
assert.deepEqual(requests, [
{ method: "DELETE", url: "https://mgmt.example.test/instances/1/stop" },
]);
}),
);

it.effect("clears the stop hook setting when the endpoint is gone", () =>
Effect.gen(function* () {
const requests: Array<RecordedHookRequest> = [];
const settingsLayer = ServerSettings.layerTest({
stopHookUrl: "https://mgmt.example.test/instances/1/stop",
});
const result = yield* Effect.gen(function* () {
const outcome = yield* InstanceHooks.runStopHook.pipe(
Effect.provide(makeHookEndpointLayer(requests, 404)),
);
const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings;
return { outcome, stopHookUrl: settings.stopHookUrl };
}).pipe(Effect.provide(settingsLayer));
assert.deepEqual(result.outcome, { outcome: "gone" });
assert.equal(result.stopHookUrl, null);
}),
);

it.effect("fails when no stop hook is configured", () =>
Effect.gen(function* () {
const requests: Array<RecordedHookRequest> = [];
const failure = yield* InstanceHooks.runStopHook.pipe(
Effect.provide(
Layer.mergeAll(makeHookEndpointLayer(requests, 204), ServerSettings.layerTest()),
),
Effect.flip,
);
assert.isTrue(isStopHookError(failure));
assert.equal(isStopHookError(failure) ? failure.reason : null, "not-configured");
assert.deepEqual(requests, []);
}),
);

it.effect("surfaces unexpected statuses without clearing the hook", () =>
Effect.gen(function* () {
const requests: Array<RecordedHookRequest> = [];
const settingsLayer = ServerSettings.layerTest({
stopHookUrl: "https://mgmt.example.test/instances/1/stop",
});
const result = yield* Effect.gen(function* () {
const failure = yield* InstanceHooks.runStopHook.pipe(
Effect.provide(makeHookEndpointLayer(requests, 500)),
Effect.flip,
);
const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings;
return { failure, stopHookUrl: settings.stopHookUrl };
}).pipe(Effect.provide(settingsLayer));
assert.isTrue(isStopHookError(result.failure));
assert.equal(
isStopHookError(result.failure) ? result.failure.reason : null,
"unexpected-status",
);
assert.equal(result.stopHookUrl, "https://mgmt.example.test/instances/1/stop");
}),
);
37 changes: 37 additions & 0 deletions apps/server/src/instanceHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as Effect from "effect/Effect";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
import { ServerStopHookError, type ServerStopHookResult } from "@t3tools/contracts";

import * as ServerSettings from "./serverSettings.ts";

const STOP_HOOK_TIMEOUT = "20 seconds";

/**
* Run the configured stop hook: DELETE the management endpoint that stops
* this instance. A 204 reports the instance as stopping. A 404 means the
* hook no longer exists, so the setting is cleared and clients drop their
* stop controls with it.
*/
export const runStopHook = Effect.gen(function* () {
const serverSettings = yield* ServerSettings.ServerSettingsService;
const httpClient = yield* HttpClient.HttpClient;
const settings = yield* serverSettings.getSettings;
if (settings.stopHookUrl === null) {
return yield* new ServerStopHookError({ reason: "not-configured" });
}
const response = yield* httpClient.execute(HttpClientRequest.delete(settings.stopHookUrl)).pipe(
Effect.timeout(STOP_HOOK_TIMEOUT),
Effect.mapError(
(error) => new ServerStopHookError({ reason: "request-failed", detail: String(error) }),
),
);
if (response.status === 204) {
return { outcome: "stopped" } satisfies ServerStopHookResult;
}
if (response.status === 404) {
yield* serverSettings.updateSettings({ stopHookUrl: null });
return { outcome: "gone" } satisfies ServerStopHookResult;
}
return yield* new ServerStopHookError({ reason: "unexpected-status", status: response.status });
});
20 changes: 19 additions & 1 deletion apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,12 @@ import {
WsRpcGroup,
} from "@t3tools/contracts";
import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings";
import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http";
import {
HttpClient,
HttpRouter,
HttpServerRequest,
HttpServerRespondable,
} from "effect/unstable/http";
import { RpcSerialization, RpcServer } from "effect/unstable/rpc";

import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts";
Expand All @@ -83,6 +88,7 @@ import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner
import * as ServerSelfUpdate from "./cloud/selfUpdate.ts";
import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
import * as InstanceHooks from "./instanceHooks.ts";
import * as ServerSettings from "./serverSettings.ts";
import * as TerminalManager from "./terminal/Manager.ts";
import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
Expand Down Expand Up @@ -372,6 +378,7 @@ const makeWsRpcLayer = (
const config = yield* ServerConfig.ServerConfig;
const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
const serverSettings = yield* ServerSettings.ServerSettingsService;
const stopHookHttpClient = yield* HttpClient.HttpClient;
const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem;
Expand Down Expand Up @@ -1490,6 +1497,17 @@ const makeWsRpcLayer = (
"rpc.aggregate": "server",
},
),
[WS_METHODS.serverRunStopHook]: (_input) =>
observeRpcEffect(
WS_METHODS.serverRunStopHook,
InstanceHooks.runStopHook.pipe(
Effect.provideService(ServerSettings.ServerSettingsService, serverSettings),
Effect.provideService(HttpClient.HttpClient, stopHookHttpClient),
),
{
"rpc.aggregate": "server",
},
),
[WS_METHODS.serverDiscoverSourceControl]: (_input) =>
observeRpcEffect(
WS_METHODS.serverDiscoverSourceControl,
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/browser/browserTargetResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import { isLoopbackHost, normalizePreviewUrl } from "@t3tools/shared/preview";

import { readPreparedConnection } from "~/state/session";

const normalizeHostname = (host: string): string => host.toLowerCase().replace(/^\[|\]$/g, "");
export const normalizeHostname = (host: string): string =>
host.toLowerCase().replace(/^\[|\]$/g, "");

const parseIpv4Address = (host: string): readonly number[] | null => {
const parts = normalizeHostname(host).split(".").map(Number);
Expand All @@ -17,7 +18,7 @@ const parseIpv4Address = (host: string): readonly number[] | null => {
: null;
};

const isLocalLoopbackHost = (host: string): boolean => {
export const isLocalLoopbackHost = (host: string): boolean => {
const normalized = normalizeHostname(host);
if (normalized === "localhost" || normalized === "::1") return true;
return parseIpv4Address(normalized)?.[0] === 127;
Expand Down
Loading
Loading