diff --git a/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts index 6ebd541395..40975797a1 100644 --- a/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts +++ b/rivetkit-typescript/packages/rivetkit/src/engine-client/api-endpoints.ts @@ -146,7 +146,7 @@ export async function getRunnerConfig( return apiCall( config, "GET", - `/runner-configs?runner_name=${name}`, + `/runner-configs?runner_name=${encodeURIComponent(name)}`, ); } diff --git a/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts b/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts index 8423064e98..ac1854337c 100644 --- a/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts +++ b/rivetkit-typescript/packages/rivetkit/src/serverless/configure.ts @@ -1,14 +1,19 @@ import { convertRegistryConfigToClientConfig } from "@/client/config"; +import type { ClientConfig } from "@/client/config"; import { stringifyError } from "@/common/utils"; +import { RivetError } from "@/actor/errors"; import { getDatacenters, + getRunnerConfig, updateRunnerConfig, } from "@/engine-client/api-endpoints"; import type { RegistryConfig } from "@/registry/config"; import { logger } from "@/registry/log"; +import { isDev } from "@/utils/env-vars"; const DEFAULT_CONFIGURE_TIMEOUT_MS = 60_000; const CONFIGURE_RETRY_DELAY_MS = 1_000; +const LOCAL_HANDLER_PROBE_TIMEOUT_MS = 1_000; function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -62,6 +67,11 @@ export async function configureServerlessPool( const clientConfig = convertRegistryConfigToClientConfig(config); const dcsRes = await getDatacenters(clientConfig); const poolName = customConfig.name ?? "default"; + await assertLocalServerlessHandlerOwnership( + clientConfig, + poolName, + customConfig.url, + ); const serverlessToken = config.token ?? config.publicToken; const headers = { ...(serverlessToken @@ -101,6 +111,13 @@ export async function configureServerlessPool( }); return; } catch (error) { + if ( + error instanceof RivetError && + error.group === "rivetkit" && + error.code === "local_serverless_handler_conflict" + ) { + throw error; + } lastError = error; logger().warn({ msg: "serverless pool configuration attempt failed", @@ -118,3 +135,61 @@ export async function configureServerlessPool( }); throw lastError; } + +async function assertLocalServerlessHandlerOwnership( + config: ClientConfig, + poolName: string, + requestedUrl: string, +): Promise { + if (!isDev()) return; + + const response = await getRunnerConfig(config, poolName); + const datacenters = response.runner_configs[poolName]?.datacenters ?? {}; + const existingUrls = new Set( + Object.values(datacenters) + .map((runnerConfig) => runnerConfig.serverless?.url) + .filter((url): url is string => url !== undefined), + ); + + for (const existingUrl of existingUrls) { + if (normalizeHandlerUrl(existingUrl) === normalizeHandlerUrl(requestedUrl)) { + continue; + } + if (!(await handlerIsLive(existingUrl))) continue; + + throw new RivetError( + "rivetkit", + "local_serverless_handler_conflict", + `namespace \`${config.namespace}\` and pool \`${poolName}\` already use the live serverless handler ${existingUrl}. Stop the other project, set RIVET_NAMESPACE to a different namespace, or set RIVET_RUN_ENGINE_PORT to a different Engine port. See https://rivet.dev/docs/general/environment-variables/`, + { + public: true, + metadata: { + namespace: config.namespace, + poolName, + existingUrl, + requestedUrl, + }, + }, + ); + } +} + +export function normalizeHandlerUrl(value: string): string { + const url = new URL(value); + url.hash = ""; + url.pathname = url.pathname.replace(/\/+$/, ""); + return url.toString(); +} + +async function handlerIsLive(handlerUrl: string): Promise { + const metadataUrl = new URL(handlerUrl); + metadataUrl.pathname = `${metadataUrl.pathname.replace(/\/+$/, "")}/metadata`; + try { + const response = await fetch(metadataUrl, { + signal: AbortSignal.timeout(LOCAL_HANDLER_PROBE_TIMEOUT_MS), + }); + return response.ok; + } catch { + return false; + } +} diff --git a/rivetkit-typescript/packages/rivetkit/tests/serverless-configure.test.ts b/rivetkit-typescript/packages/rivetkit/tests/serverless-configure.test.ts new file mode 100644 index 0000000000..3acb094fe8 --- /dev/null +++ b/rivetkit-typescript/packages/rivetkit/tests/serverless-configure.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { normalizeHandlerUrl } from "../src/serverless/configure"; + +describe("local serverless handler ownership", () => { + test("normalizes equivalent hot-reload handler URLs", () => { + expect(normalizeHandlerUrl("http://LOCALHOST:3000/api/rivet/")).toBe( + normalizeHandlerUrl("http://localhost:3000/api/rivet"), + ); + }); + + test("preserves distinct handler ports", () => { + expect(normalizeHandlerUrl("http://localhost:3000/api/rivet")).not.toBe( + normalizeHandlerUrl("http://localhost:3001/api/rivet"), + ); + }); +});