diff --git a/examples/chat-room-python/package.json b/examples/chat-room-python/package.json index 4ec21f0db..9a44c0212 100644 --- a/examples/chat-room-python/package.json +++ b/examples/chat-room-python/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "npx rivetkit/cli@latest dev actors/app.ts", + "dev": "tsx src/server.ts", "check-types": "tsc --noEmit", "pytest": "pytest tests/test_chat_room.py -v" }, @@ -14,6 +14,9 @@ "tsx": "^3.12.7", "typescript": "^5.5.2" }, + "dependencies": { + "@rivetkit/nodejs": "workspace:*" + }, "example": { "platforms": [ "*" diff --git a/examples/chat-room-python/src/server.ts b/examples/chat-room-python/src/server.ts new file mode 100644 index 000000000..b19f6afd9 --- /dev/null +++ b/examples/chat-room-python/src/server.ts @@ -0,0 +1,4 @@ +import { serve } from "@rivetkit/nodejs"; +import { app } from "./workers/app"; + +serve(app); \ No newline at end of file diff --git a/examples/chat-room-python/actors/app.ts b/examples/chat-room-python/src/workers/app.ts similarity index 87% rename from examples/chat-room-python/actors/app.ts rename to examples/chat-room-python/src/workers/app.ts index e48f81b22..271185181 100644 --- a/examples/chat-room-python/actors/app.ts +++ b/examples/chat-room-python/src/workers/app.ts @@ -1,11 +1,11 @@ -import { actor, setup } from "rivetkit"; +import { worker, setup } from "rivetkit"; // state managed by the actor export interface State { messages: { username: string; message: string }[]; } -export const chatRoom = actor({ +export const chatRoom = worker({ // initialize state state: { messages: [] } as State, @@ -28,7 +28,7 @@ export const chatRoom = actor({ // Create and export the app export const app = setup({ - actors: { chatRoom }, + workers: { chatRoom }, }); // Export type for client type checking diff --git a/examples/chat-room/package.json b/examples/chat-room/package.json index 863d73b14..341aa1a4e 100644 --- a/examples/chat-room/package.json +++ b/examples/chat-room/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "npx rivetkit/cli@latest dev actors/app.ts", + "dev": "tsx src/server.ts", "check-types": "tsc --noEmit", "test": "vitest run" }, @@ -17,6 +17,9 @@ "typescript": "^5.5.2", "vitest": "^3.1.1" }, + "dependencies": { + "@rivetkit/nodejs": "workspace:*" + }, "example": { "platforms": [ "*" diff --git a/examples/chat-room/src/server.ts b/examples/chat-room/src/server.ts new file mode 100644 index 000000000..b19f6afd9 --- /dev/null +++ b/examples/chat-room/src/server.ts @@ -0,0 +1,4 @@ +import { serve } from "@rivetkit/nodejs"; +import { app } from "./workers/app"; + +serve(app); \ No newline at end of file diff --git a/examples/chat-room/actors/app.ts b/examples/chat-room/src/workers/app.ts similarity index 87% rename from examples/chat-room/actors/app.ts rename to examples/chat-room/src/workers/app.ts index e48f81b22..271185181 100644 --- a/examples/chat-room/actors/app.ts +++ b/examples/chat-room/src/workers/app.ts @@ -1,11 +1,11 @@ -import { actor, setup } from "rivetkit"; +import { worker, setup } from "rivetkit"; // state managed by the actor export interface State { messages: { username: string; message: string }[]; } -export const chatRoom = actor({ +export const chatRoom = worker({ // initialize state state: { messages: [] } as State, @@ -28,7 +28,7 @@ export const chatRoom = actor({ // Create and export the app export const app = setup({ - actors: { chatRoom }, + workers: { chatRoom }, }); // Export type for client type checking diff --git a/examples/chat-room/tests/chat-room.test.ts b/examples/chat-room/tests/chat-room.test.ts index 1f00fde11..6fc01ff1f 100644 --- a/examples/chat-room/tests/chat-room.test.ts +++ b/examples/chat-room/tests/chat-room.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "vitest"; import { setupTest } from "rivetkit/test"; -import { app } from "../actors/app"; +import { app } from "../src/workers/app"; test("chat room should handle messages", async (test) => { const { client } = await setupTest(test, app); diff --git a/examples/counter/package.json b/examples/counter/package.json index c76b69bdc..ea8e41584 100644 --- a/examples/counter/package.json +++ b/examples/counter/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "npx rivetkit/cli@latest dev actors/app.ts", + "dev": "tsx src/server.ts", "check-types": "tsc --noEmit", "test": "vitest run" }, @@ -15,6 +15,9 @@ "typescript": "^5.7.3", "vitest": "^3.1.1" }, + "dependencies": { + "@rivetkit/nodejs": "workspace:*" + }, "example": { "platforms": [ "*" diff --git a/examples/counter/src/server.ts b/examples/counter/src/server.ts new file mode 100644 index 000000000..b19f6afd9 --- /dev/null +++ b/examples/counter/src/server.ts @@ -0,0 +1,4 @@ +import { serve } from "@rivetkit/nodejs"; +import { app } from "./workers/app"; + +serve(app); \ No newline at end of file diff --git a/examples/counter/actors/app.ts b/examples/counter/src/workers/app.ts similarity index 76% rename from examples/counter/actors/app.ts rename to examples/counter/src/workers/app.ts index 0c20944fc..5b91c482f 100644 --- a/examples/counter/actors/app.ts +++ b/examples/counter/src/workers/app.ts @@ -1,6 +1,6 @@ -import { actor, setup } from "rivetkit"; +import { worker, setup } from "rivetkit"; -const counter = actor({ +const counter = worker({ state: { count: 0 }, actions: { increment: (c, x: number) => { @@ -15,7 +15,7 @@ const counter = actor({ }); export const app = setup({ - actors: { counter }, + workers: { counter }, }); export type App = typeof app; diff --git a/examples/counter/tests/counter.test.ts b/examples/counter/tests/counter.test.ts index d4b16c366..fc739a3f6 100644 --- a/examples/counter/tests/counter.test.ts +++ b/examples/counter/tests/counter.test.ts @@ -1,6 +1,6 @@ import { test, expect } from "vitest"; import { setupTest } from "rivetkit/test"; -import { app } from "../actors/app"; +import { app } from "../src/workers/app"; test("it should count", async (test) => { const { client } = await setupTest(test, app); diff --git a/examples/linear-coding-agent/package.json b/examples/linear-coding-agent/package.json index 2f9bd09ee..eaf0954f5 100644 --- a/examples/linear-coding-agent/package.json +++ b/examples/linear-coding-agent/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "dev": "concurrently --raw \"yarn dev:actors\" \"yarn dev:server\" \"yarn dev:ngrok\"", - "dev:actors": "npx rivetkit/cli@latest dev src/actors/app.ts", + "dev:actors": "npx rivetkit/cli@latest dev src/workers/app.ts", "dev:server": "tsx --watch src/server/index.ts", "dev:ngrok": "ngrok http 3000", "check-types": "tsc --noEmit" @@ -27,6 +27,7 @@ "@hono/node-server": "^1.14.1", "@linear/sdk": "^7.0.0", "@octokit/rest": "^19.0.13", + "@rivetkit/nodejs": "workspace:*", "ai": "^4.3.9", "body-parser": "^2.2.0", "dotenv": "^16.5.0", diff --git a/examples/linear-coding-agent/src/server.ts b/examples/linear-coding-agent/src/server.ts new file mode 100644 index 000000000..b19f6afd9 --- /dev/null +++ b/examples/linear-coding-agent/src/server.ts @@ -0,0 +1,4 @@ +import { serve } from "@rivetkit/nodejs"; +import { app } from "./workers/app"; + +serve(app); \ No newline at end of file diff --git a/examples/linear-coding-agent/src/server/index.ts b/examples/linear-coding-agent/src/server/index.ts index 0bf93866f..04185e328 100644 --- a/examples/linear-coding-agent/src/server/index.ts +++ b/examples/linear-coding-agent/src/server/index.ts @@ -2,8 +2,8 @@ import { Hono } from "hono"; import { serve } from "@hono/node-server"; import dotenv from "dotenv"; import { createClient } from "rivetkit/client"; -import { app } from "../actors/app"; -import type { App } from "../actors/app"; +import { app } from "../workers/app"; +import type { App } from "../workers/app"; import type { LinearWebhookEvent } from "../types"; // Load environment variables diff --git a/examples/linear-coding-agent/src/actors/app.ts b/examples/linear-coding-agent/src/workers/app.ts similarity index 91% rename from examples/linear-coding-agent/src/actors/app.ts rename to examples/linear-coding-agent/src/workers/app.ts index 993c8d923..6fa245d7a 100644 --- a/examples/linear-coding-agent/src/actors/app.ts +++ b/examples/linear-coding-agent/src/workers/app.ts @@ -7,7 +7,7 @@ dotenv.config(); // Create and export the app export const app = setup({ - actors: { codingAgent }, + workers: { codingAgent }, }); // Export type for client type checking diff --git a/examples/linear-coding-agent/src/actors/coding-agent/github.ts b/examples/linear-coding-agent/src/workers/coding-agent/github.ts similarity index 100% rename from examples/linear-coding-agent/src/actors/coding-agent/github.ts rename to examples/linear-coding-agent/src/workers/coding-agent/github.ts diff --git a/examples/linear-coding-agent/src/actors/coding-agent/linear-utils.ts b/examples/linear-coding-agent/src/workers/coding-agent/linear-utils.ts similarity index 100% rename from examples/linear-coding-agent/src/actors/coding-agent/linear-utils.ts rename to examples/linear-coding-agent/src/workers/coding-agent/linear-utils.ts diff --git a/examples/linear-coding-agent/src/actors/coding-agent/linear.ts b/examples/linear-coding-agent/src/workers/coding-agent/linear.ts similarity index 100% rename from examples/linear-coding-agent/src/actors/coding-agent/linear.ts rename to examples/linear-coding-agent/src/workers/coding-agent/linear.ts diff --git a/examples/linear-coding-agent/src/actors/coding-agent/llm.ts b/examples/linear-coding-agent/src/workers/coding-agent/llm.ts similarity index 100% rename from examples/linear-coding-agent/src/actors/coding-agent/llm.ts rename to examples/linear-coding-agent/src/workers/coding-agent/llm.ts diff --git a/examples/linear-coding-agent/src/actors/coding-agent/mod.ts b/examples/linear-coding-agent/src/workers/coding-agent/mod.ts similarity index 99% rename from examples/linear-coding-agent/src/actors/coding-agent/mod.ts rename to examples/linear-coding-agent/src/workers/coding-agent/mod.ts index bdcd8b107..c220117cf 100644 --- a/examples/linear-coding-agent/src/actors/coding-agent/mod.ts +++ b/examples/linear-coding-agent/src/workers/coding-agent/mod.ts @@ -1,4 +1,4 @@ -import { type ActionContextOf, type ActorContextOf, actor } from "rivetkit"; +import { type ActionContextOf, type WorkerContextOf, worker } from "rivetkit"; import type { CodingAgentState, CodingAgentVars, @@ -20,7 +20,7 @@ import { } from "./linear"; export type Ctx = - | ActorContextOf + | WorkerContextOf | ActionContextOf; /** @@ -353,7 +353,7 @@ function enqueueRequest(c: Ctx, type: RequestType, data: LinearWebhookEvent): st } } -export const codingAgent = actor({ +export const codingAgent = worker({ // Initialize state state: { // Linear issue information diff --git a/examples/linear-coding-agent/src/actors/coding-agent/types.ts b/examples/linear-coding-agent/src/workers/coding-agent/types.ts similarity index 100% rename from examples/linear-coding-agent/src/actors/coding-agent/types.ts rename to examples/linear-coding-agent/src/workers/coding-agent/types.ts diff --git a/examples/resend-streaks/package.json b/examples/resend-streaks/package.json index 26b504499..a935d83aa 100644 --- a/examples/resend-streaks/package.json +++ b/examples/resend-streaks/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "dev": "npx rivetkit/cli@latest dev actors/app.ts", + "dev": "tsx src/server.ts", "check-types": "tsc --noEmit", "test": "vitest run" }, @@ -17,6 +17,7 @@ }, "dependencies": { "@date-fns/tz": "^1.2.0", + "@rivetkit/nodejs": "workspace:*", "date-fns": "^4.1.0", "resend": "^2.0.0" }, diff --git a/examples/resend-streaks/src/server.ts b/examples/resend-streaks/src/server.ts new file mode 100644 index 000000000..b19f6afd9 --- /dev/null +++ b/examples/resend-streaks/src/server.ts @@ -0,0 +1,4 @@ +import { serve } from "@rivetkit/nodejs"; +import { app } from "./workers/app"; + +serve(app); \ No newline at end of file diff --git a/examples/resend-streaks/actors/app.ts b/examples/resend-streaks/src/workers/app.ts similarity index 96% rename from examples/resend-streaks/actors/app.ts rename to examples/resend-streaks/src/workers/app.ts index 75b1e2aad..1a71ff5eb 100644 --- a/examples/resend-streaks/actors/app.ts +++ b/examples/resend-streaks/src/workers/app.ts @@ -1,9 +1,9 @@ import { TZDate } from "@date-fns/tz"; -import { UserError, actor, setup } from "rivetkit"; +import { UserError, worker, setup } from "rivetkit"; import { addDays, set } from "date-fns"; import { Resend } from "resend"; -const user = actor({ +const user = worker({ state: { email: null as string | null, timeZone: "UTC", @@ -105,7 +105,7 @@ function isSameDay(a: TZDate, b: TZDate) { } export const app = setup({ - actors: { user }, + workers: { user }, }); export type App = typeof app; diff --git a/examples/resend-streaks/tests/user.test.ts b/examples/resend-streaks/tests/user.test.ts index 537b6b432..ac4313cb3 100644 --- a/examples/resend-streaks/tests/user.test.ts +++ b/examples/resend-streaks/tests/user.test.ts @@ -1,6 +1,6 @@ import { test, expect, vi, beforeEach } from "vitest"; import { setupTest } from "rivetkit/test"; -import { app } from "../actors/app"; +import { app } from "../src/workers/app"; // Create mock for send method const mockSendEmail = vi.fn().mockResolvedValue({ success: true }); diff --git a/packages/core/fixtures/driver-test-suite/action-inputs.ts b/packages/core/fixtures/driver-test-suite/action-inputs.ts index 2c81bf2ef..4a1d23d68 100644 --- a/packages/core/fixtures/driver-test-suite/action-inputs.ts +++ b/packages/core/fixtures/driver-test-suite/action-inputs.ts @@ -1,12 +1,12 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; -interface State { +export interface State { initialInput?: unknown; onCreateInput?: unknown; } // Test worker that can capture input during creation -const inputWorker = worker({ +export const inputWorker = worker({ createState: (c, { input }): State => { return { initialInput: input, @@ -28,8 +28,3 @@ const inputWorker = worker({ }, }); -export const app = setup({ - workers: { inputWorker }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/action-timeout.ts b/packages/core/fixtures/driver-test-suite/action-timeout.ts index 2aa998b71..fb18967e0 100644 --- a/packages/core/fixtures/driver-test-suite/action-timeout.ts +++ b/packages/core/fixtures/driver-test-suite/action-timeout.ts @@ -1,7 +1,7 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; // Short timeout worker -const shortTimeoutWorker = worker({ +export const shortTimeoutWorker = worker({ state: { value: 0 }, options: { action: { @@ -21,7 +21,7 @@ const shortTimeoutWorker = worker({ }); // Long timeout worker -const longTimeoutWorker = worker({ +export const longTimeoutWorker = worker({ state: { value: 0 }, options: { action: { @@ -38,7 +38,7 @@ const longTimeoutWorker = worker({ }); // Default timeout worker -const defaultTimeoutWorker = worker({ +export const defaultTimeoutWorker = worker({ state: { value: 0 }, actions: { normalAction: async (c) => { @@ -49,7 +49,7 @@ const defaultTimeoutWorker = worker({ }); // Sync worker (timeout shouldn't apply) -const syncWorker = worker({ +export const syncTimeoutWorker = worker({ state: { value: 0 }, options: { action: { @@ -63,14 +63,4 @@ const syncWorker = worker({ }, }); -export const app = setup({ - workers: { - shortTimeoutWorker, - longTimeoutWorker, - defaultTimeoutWorker, - syncWorker, - }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/action-types.ts b/packages/core/fixtures/driver-test-suite/action-types.ts index fe920fabd..006b36fb6 100644 --- a/packages/core/fixtures/driver-test-suite/action-types.ts +++ b/packages/core/fixtures/driver-test-suite/action-types.ts @@ -1,7 +1,7 @@ -import { worker, setup, UserError } from "rivetkit"; +import { worker, UserError } from "rivetkit"; // Worker with synchronous actions -const syncWorker = worker({ +export const syncActionWorker = worker({ state: { value: 0 }, actions: { // Simple synchronous action that returns a value directly @@ -24,7 +24,7 @@ const syncWorker = worker({ }); // Worker with asynchronous actions -const asyncWorker = worker({ +export const asyncActionWorker = worker({ state: { value: 0, data: null as any }, actions: { // Async action with a delay @@ -56,7 +56,7 @@ const asyncWorker = worker({ }); // Worker with promise actions -const promiseWorker = worker({ +export const promiseWorker = worker({ state: { results: [] as string[] }, actions: { // Action that returns a resolved promise @@ -81,12 +81,3 @@ const promiseWorker = worker({ }, }); -export const app = setup({ - workers: { - syncWorker, - asyncWorker, - promiseWorker, - }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/app.ts b/packages/core/fixtures/driver-test-suite/app.ts new file mode 100644 index 000000000..aba2fbe3e --- /dev/null +++ b/packages/core/fixtures/driver-test-suite/app.ts @@ -0,0 +1,69 @@ +import { setup } from "rivetkit"; + +// Import workers from individual files +import { counter } from "./counter"; +import { counterWithLifecycle } from "./lifecycle"; +import { scheduled } from "./scheduled"; +import { errorHandlingWorker, customTimeoutWorker } from "./error-handling"; +import { inputWorker } from "./action-inputs"; +import { + shortTimeoutWorker, + longTimeoutWorker, + defaultTimeoutWorker, + syncTimeoutWorker, +} from "./action-timeout"; +import { + syncActionWorker, + asyncActionWorker, + promiseWorker, +} from "./action-types"; +import { counterWithParams } from "./conn-params"; +import { connStateWorker } from "./conn-state"; +import { metadataWorker } from "./metadata"; +import { + staticVarWorker, + nestedVarWorker, + dynamicVarWorker, + uniqueVarWorker, + driverCtxWorker, +} from "./vars"; + +// Consolidated setup with all workers +export const app = setup({ + workers: { + // From counter.ts + counter, + // From lifecycle.ts + counterWithLifecycle, + // From scheduled.ts + scheduled, + // From error-handling.ts + errorHandlingWorker, + customTimeoutWorker, + // From action-inputs.ts + inputWorker, + // From action-timeout.ts + shortTimeoutWorker, + longTimeoutWorker, + defaultTimeoutWorker, + syncTimeoutWorker, + // From action-types.ts + syncActionWorker, + asyncActionWorker, + promiseWorker, + // From conn-params.ts + counterWithParams, + // From conn-state.ts + connStateWorker, + // From metadata.ts + metadataWorker, + // From vars.ts + staticVarWorker, + nestedVarWorker, + dynamicVarWorker, + uniqueVarWorker, + driverCtxWorker, + }, +}); + +export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/conn-params.ts b/packages/core/fixtures/driver-test-suite/conn-params.ts index 6f7c48cc3..9ca3a94c2 100644 --- a/packages/core/fixtures/driver-test-suite/conn-params.ts +++ b/packages/core/fixtures/driver-test-suite/conn-params.ts @@ -1,6 +1,6 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; -const counterWithParams = worker({ +export const counterWithParams = worker({ state: { count: 0, initializers: [] as string[] }, createConnState: (c, { params }: { params: { name?: string } }) => { return { @@ -26,8 +26,3 @@ const counterWithParams = worker({ }, }); -export const app = setup({ - workers: { counter: counterWithParams }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/conn-state.ts b/packages/core/fixtures/driver-test-suite/conn-state.ts index 81844b87d..279f3b5df 100644 --- a/packages/core/fixtures/driver-test-suite/conn-state.ts +++ b/packages/core/fixtures/driver-test-suite/conn-state.ts @@ -1,13 +1,13 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; -type ConnState = { +export type ConnState = { username: string; role: string; counter: number; createdAt: number; }; -const connStateWorker = worker({ +export const connStateWorker = worker({ state: { sharedCounter: 0, disconnectionCount: 0, @@ -94,8 +94,3 @@ const connStateWorker = worker({ }, }); -export const app = setup({ - workers: { connStateWorker }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/counter.ts b/packages/core/fixtures/driver-test-suite/counter.ts index 5b91c482f..0c0254a01 100644 --- a/packages/core/fixtures/driver-test-suite/counter.ts +++ b/packages/core/fixtures/driver-test-suite/counter.ts @@ -1,6 +1,6 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; -const counter = worker({ +export const counter = worker({ state: { count: 0 }, actions: { increment: (c, x: number) => { @@ -13,9 +13,3 @@ const counter = worker({ }, }, }); - -export const app = setup({ - workers: { counter }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/error-handling.ts b/packages/core/fixtures/driver-test-suite/error-handling.ts index b717b8366..e84d5b050 100644 --- a/packages/core/fixtures/driver-test-suite/error-handling.ts +++ b/packages/core/fixtures/driver-test-suite/error-handling.ts @@ -1,6 +1,6 @@ -import { worker, setup, UserError } from "rivetkit"; +import { worker, UserError } from "rivetkit"; -const errorHandlingWorker = worker({ +export const errorHandlingWorker = worker({ state: { errorLog: [] as string[], }, @@ -76,7 +76,7 @@ const errorHandlingWorker = worker({ }); // Worker with custom timeout -const customTimeoutWorker = worker({ +export const customTimeoutWorker = worker({ state: {}, actions: { quickAction: async () => { @@ -95,11 +95,3 @@ const customTimeoutWorker = worker({ }, }); -export const app = setup({ - workers: { - errorHandlingWorker, - customTimeoutWorker, - }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/lifecycle.ts b/packages/core/fixtures/driver-test-suite/lifecycle.ts index 4eec992d4..f316c1f78 100644 --- a/packages/core/fixtures/driver-test-suite/lifecycle.ts +++ b/packages/core/fixtures/driver-test-suite/lifecycle.ts @@ -1,6 +1,6 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; -const lifecycleWorker = worker({ +export const counterWithLifecycle = worker({ state: { count: 0, events: [] as string[], @@ -34,8 +34,3 @@ const lifecycleWorker = worker({ }, }); -export const app = setup({ - workers: { counter: lifecycleWorker }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/metadata.ts b/packages/core/fixtures/driver-test-suite/metadata.ts index 3adfa4cac..bf64c5142 100644 --- a/packages/core/fixtures/driver-test-suite/metadata.ts +++ b/packages/core/fixtures/driver-test-suite/metadata.ts @@ -1,8 +1,8 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; // Note: For testing only - metadata API will need to be mocked // in tests since this is implementation-specific -const metadataWorker = worker({ +export const metadataWorker = worker({ state: { lastMetadata: null as any, workerName: "", @@ -74,9 +74,4 @@ const metadataWorker = worker({ }, }); -export const app = setup({ - workers: { metadataWorker }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/scheduled.ts b/packages/core/fixtures/driver-test-suite/scheduled.ts index fd2f606e3..a5ce59a7e 100644 --- a/packages/core/fixtures/driver-test-suite/scheduled.ts +++ b/packages/core/fixtures/driver-test-suite/scheduled.ts @@ -1,6 +1,6 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; -const scheduled = worker({ +export const scheduled = worker({ state: { lastRun: 0, scheduledCount: 0, @@ -75,9 +75,4 @@ const scheduled = worker({ }, }); -export const app = setup({ - workers: { scheduled }, -}); - -export type App = typeof app; diff --git a/packages/core/fixtures/driver-test-suite/vars.ts b/packages/core/fixtures/driver-test-suite/vars.ts index 57bc3fa27..a42b7f042 100644 --- a/packages/core/fixtures/driver-test-suite/vars.ts +++ b/packages/core/fixtures/driver-test-suite/vars.ts @@ -1,7 +1,7 @@ -import { worker, setup } from "rivetkit"; +import { worker } from "rivetkit"; // Worker with static vars -const staticVarWorker = worker({ +export const staticVarWorker = worker({ state: { value: 0 }, connState: { hello: "world" }, vars: { counter: 42, name: "test-worker" }, @@ -16,7 +16,7 @@ const staticVarWorker = worker({ }); // Worker with nested vars -const nestedVarWorker = worker({ +export const nestedVarWorker = worker({ state: { value: 0 }, connState: { hello: "world" }, vars: { @@ -42,7 +42,7 @@ const nestedVarWorker = worker({ }); // Worker with dynamic vars -const dynamicVarWorker = worker({ +export const dynamicVarWorker = worker({ state: { value: 0 }, connState: { hello: "world" }, createVars: () => { @@ -59,7 +59,7 @@ const dynamicVarWorker = worker({ }); // Worker with unique vars per instance -const uniqueVarWorker = worker({ +export const uniqueVarWorker = worker({ state: { value: 0 }, connState: { hello: "world" }, createVars: () => { @@ -75,7 +75,7 @@ const uniqueVarWorker = worker({ }); // Worker that uses driver context -const driverCtxWorker = worker({ +export const driverCtxWorker = worker({ state: { value: 0 }, connState: { hello: "world" }, createVars: (c, driverCtx: any) => { @@ -90,15 +90,4 @@ const driverCtxWorker = worker({ }, }); -export const app = setup({ - workers: { - staticVarWorker, - nestedVarWorker, - dynamicVarWorker, - uniqueVarWorker, - driverCtxWorker, - }, -}); - -export type App = typeof app; diff --git a/packages/core/package.json b/packages/core/package.json index 7d7eeddfb..1d0ee6514 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -170,6 +170,7 @@ "@types/invariant": "^2", "@types/node": "^22.13.1", "@types/ws": "^8", + "@vitest/ui": "3.1.1", "bundle-require": "^5.1.0", "eventsource": "^3.0.5", "tsup": "^8.4.0", diff --git a/packages/core/scripts/dump-openapi.ts b/packages/core/scripts/dump-openapi.ts index 85e6be0c9..6a3698476 100644 --- a/packages/core/scripts/dump-openapi.ts +++ b/packages/core/scripts/dump-openapi.ts @@ -1,5 +1,5 @@ import { createManagerRouter } from "@/manager/router"; -import { AppConfig, AppConfigSchema, setup } from "@/mod"; +import { AppConfig, AppConfigSchema, Encoding, setup } from "@/mod"; import { ConnectionHandlers } from "@/worker/router-endpoints"; import { DriverConfig } from "@/driver-helpers/config"; import { @@ -11,6 +11,11 @@ import { OpenAPIHono } from "@hono/zod-openapi"; import { VERSION } from "@/utils"; import * as fs from "node:fs/promises"; import { resolve } from "node:path"; +import { ClientDriver } from "@/client/client"; +import { WorkerQuery } from "@/manager/protocol/query"; +import { ToServer } from "@/worker/protocol/message/to-server"; +import { EventSource } from "eventsource"; +import { Context } from "hono"; function main() { const appConfig: AppConfig = AppConfigSchema.parse({ workers: {} }); @@ -40,13 +45,26 @@ function main() { }, }; - const managerRouter = createManagerRouter(appConfig, driverConfig, { - routingHandler: { - inline: { - handlers: sharedConnectionHandlers, + const inlineClientDriver: ClientDriver = { + action: unimplemented, + resolveWorkerId: unimplemented, + connectWebSocket: unimplemented, + connectSse: unimplemented, + sendHttpMessage: unimplemented, + }; + + const managerRouter = createManagerRouter( + appConfig, + driverConfig, + inlineClientDriver, + { + routingHandler: { + inline: { + handlers: sharedConnectionHandlers, + }, }, }, - }) as unknown as OpenAPIHono; + ) as unknown as OpenAPIHono; const openApiDoc = managerRouter.getOpenAPIDocument({ openapi: "3.0.0", diff --git a/packages/core/src/app/config.ts b/packages/core/src/app/config.ts index c6c2fd595..49cccf4a4 100644 --- a/packages/core/src/app/config.ts +++ b/packages/core/src/app/config.ts @@ -4,7 +4,6 @@ import { z } from "zod"; import type { cors } from "hono/cors"; import { WorkerDefinition, AnyWorkerDefinition } from "@/worker/definition"; import { InspectorConfigSchema } from "@/inspector/config"; - // Define CORS options schema type CorsOptions = NonNullable[0]>; @@ -48,6 +47,9 @@ export const WorkersSchema = z.record( ); export type Workers = z.infer; +export const TestConfigSchema = z.object({ enabled: z.boolean() }); +export type TestConfig = z.infer; + /** Base config used for the worker config across all platforms. */ export const AppConfigSchema = z.object({ workers: z.record(z.string(), z.custom()), @@ -71,6 +73,14 @@ export const AppConfigSchema = z.object({ /** Inspector configuration. */ inspector: InspectorConfigSchema.optional().default({ enabled: false }), + + // TODO: Find a better way of passing around the test config + /** + * Test configuration. + * + * DO NOT MANUALLY ENABLE. THIS IS USED INTERNALLY. + **/ + test: TestConfigSchema.optional().default({ enabled: false }), }); export type AppConfig = z.infer; export type AppConfigInput = Omit< diff --git a/packages/core/src/app/fake-event-source.ts b/packages/core/src/app/fake-event-source.ts new file mode 100644 index 000000000..ecbb826fc --- /dev/null +++ b/packages/core/src/app/fake-event-source.ts @@ -0,0 +1,219 @@ +import { logger } from "./log"; +import type { SSEStreamingApi } from "hono/streaming"; +import type { EventSource } from "eventsource"; + +/** + * FakeEventSource provides a minimal implementation of an SSE stream + * that handles events for the inline client driver + */ +export class FakeEventSource { + url = "http://internal-sse-endpoint"; + readyState = 1; // OPEN + withCredentials = false; + + // Event handlers + onopen: ((this: EventSource, ev: Event) => any) | null = null; + onmessage: ((this: EventSource, ev: MessageEvent) => any) | null = null; + onerror: ((this: EventSource, ev: Event) => any) | null = null; + + // Private event listeners + #listeners: Record> = { + open: new Set(), + message: new Set(), + error: new Set(), + close: new Set() + }; + + // Stream that will be passed to the handler + #stream: SSEStreamingApi; + #onCloseCallback: () => Promise; + + /** + * Creates a new FakeEventSource + */ + constructor(onCloseCallback: () => Promise) { + this.#onCloseCallback = onCloseCallback; + + this.#stream = this.#createStreamApi(); + + // Trigger open event on next tick + setTimeout(() => { + if (this.readyState === 1) { + this.#dispatchEvent('open'); + } + }, 0); + + logger().debug("FakeEventSource created"); + } + + // Creates the SSE streaming API implementation + #createStreamApi(): SSEStreamingApi { + // Create self-reference for closures + const self = this; + + const streamApi: SSEStreamingApi = { + write: async (input) => { + const data = typeof input === "string" ? input : new TextDecoder().decode(input); + self.#dispatchEvent('message', { data }); + return streamApi; + }, + + writeln: async (input: string) => { + await streamApi.write(input + "\n"); + return streamApi; + }, + + writeSSE: async (message: { data: string | Promise, event?: string, id?: string, retry?: number }): Promise => { + const data = await message.data; + + if (message.event) { + self.#dispatchEvent(message.event, { data }); + } else { + self.#dispatchEvent('message', { data }); + } + }, + + sleep: async (ms: number) => { + await new Promise(resolve => setTimeout(resolve, ms)); + return streamApi; + }, + + close: async () => { + self.close(); + }, + + pipe: async (_body: ReadableStream) => { + // No-op implementation + }, + + onAbort: async (cb: () => void) => { + self.addEventListener("error", () => { + cb(); + }); + return streamApi; + }, + + abort: async () => { + self.#dispatchEvent('error'); + return streamApi; + }, + + // Additional required properties + get responseReadable() { + return null as unknown as ReadableStream; + }, + + get aborted() { + return self.readyState === 2; // CLOSED + }, + + get closed() { + return self.readyState === 2; // CLOSED + } + }; + + return streamApi; + } + + /** + * Closes the connection + */ + close(): void { + if (this.readyState === 2) { // CLOSED + return; + } + + logger().debug("closing FakeEventSource"); + this.readyState = 2; // CLOSED + + // Call the close callback + this.#onCloseCallback().catch(err => { + logger().error("error in onClose callback", { error: err }); + }); + + // Dispatch close event + this.#dispatchEvent('close'); + } + + /** + * Get the stream API to pass to the handler + */ + getStream(): SSEStreamingApi { + return this.#stream; + } + + // Implementation of EventTarget-like interface + addEventListener(type: string, listener: EventListener): void { + if (!this.#listeners[type]) { + this.#listeners[type] = new Set(); + } + this.#listeners[type].add(listener); + + // Map to onX properties as well + if (type === "open" && typeof listener === "function" && !this.onopen) { + this.onopen = listener as any; + } else if (type === "message" && typeof listener === "function" && !this.onmessage) { + this.onmessage = listener as any; + } else if (type === "error" && typeof listener === "function" && !this.onerror) { + this.onerror = listener as any; + } + } + + removeEventListener(type: string, listener: EventListener): void { + if (this.#listeners[type]) { + this.#listeners[type].delete(listener); + } + + // Unset onX property if it matches + if (type === "open" && this.onopen === listener) { + this.onopen = null; + } else if (type === "message" && this.onmessage === listener) { + this.onmessage = null; + } else if (type === "error" && this.onerror === listener) { + this.onerror = null; + } + } + + // Internal method to dispatch events + #dispatchEvent(type: string, detail?: Record): void { + // Create appropriate event + let event: Event; + if (type === 'message' || detail) { + event = new MessageEvent(type, detail); + } else { + event = new Event(type); + } + + // Call specific handler + if (type === 'open' && this.onopen) { + try { + this.onopen.call(this as any, event); + } catch (err) { + logger().error("error in onopen handler", { error: err }); + } + } else if (type === 'message' && this.onmessage) { + try { + this.onmessage.call(this as any, event as MessageEvent); + } catch (err) { + logger().error("error in onmessage handler", { error: err }); + } + } else if (type === 'error' && this.onerror) { + try { + this.onerror.call(this as any, event); + } catch (err) { + logger().error("error in onerror handler", { error: err }); + } + } + + // Call all listeners + if (this.#listeners[type]) { + for (const listener of this.#listeners[type]) { + try { + listener.call(this, event); + } catch (err) { + logger().error(`error in ${type} event listener`, { error: err }); + } + } + } + } +} \ No newline at end of file diff --git a/packages/core/src/app/fake-websocket.ts b/packages/core/src/app/fake-websocket.ts new file mode 100644 index 000000000..28492ef0f --- /dev/null +++ b/packages/core/src/app/fake-websocket.ts @@ -0,0 +1,530 @@ +import { WSContext } from "hono/ws"; +import { logger } from "@/app/log"; +import type { ConnectWebSocketOutput } from "@/worker/router-endpoints"; +import type * as messageToServer from "@/worker/protocol/message/to-server"; +import { parseMessage } from "@/worker/protocol/message/mod"; +import type { InputData } from "@/worker/protocol/serde"; + +/** + * FakeWebSocket implements a WebSocket-like interface + * that connects to a ConnectWebSocketOutput handler + */ +export class FakeWebSocket implements WebSocket { + // WebSocket interface properties + binaryType: BinaryType = "arraybuffer"; + bufferedAmount: number = 0; + extensions: string = ""; + protocol: string = ""; + url: string = ""; + + // Event handlers + onclose: ((ev: CloseEvent) => any) | null = null; + onerror: ((ev: Event) => any) | null = null; + onmessage: ((ev: MessageEvent) => any) | null = null; + onopen: ((ev: Event) => any) | null = null; + + // WebSocket readyState values + readonly CONNECTING = 0 as const; + readonly OPEN = 1 as const; + readonly CLOSING = 2 as const; + readonly CLOSED = 3 as const; + + // Private properties + #handler: ConnectWebSocketOutput; + #wsContext: WSContext; + #readyState: 0 | 1 | 2 | 3 = 0; // Start in CONNECTING state + #initPromise: Promise; + #initResolve: (value: void) => void; + #initReject: (reason: any) => void; + #queuedMessages: Array = []; + + /** + * Creates a new FakeWebSocket connected to a ConnectWebSocketOutput handler + */ + constructor(handler: ConnectWebSocketOutput) { + this.#handler = handler; + + // Create promise resolvers for initialization + const initPromise = Promise.withResolvers(); + this.#initPromise = initPromise.promise; + this.#initResolve = initPromise.resolve; + this.#initReject = initPromise.reject; + + // Create a fake WSContext to pass to the handler + this.#wsContext = new WSContext({ + send: (data: string | ArrayBuffer | Uint8Array) => { + logger().debug("WSContext.send called", { + dataType: typeof data, + dataLength: + typeof data === "string" + ? data.length + : data instanceof ArrayBuffer + ? data.byteLength + : data instanceof Uint8Array + ? data.byteLength + : "unknown", + }); + this.#handleMessage(data); + }, + close: (code?: number, reason?: string) => { + logger().debug("WSContext.close called", { code, reason }); + this.#handleClose(code || 1000, reason || ""); + }, + // Set readyState to 1 (OPEN) since handlers expect an open connection + readyState: 1, + url: "ws://fake-websocket/", + protocol: "", + }); + + // Initialize the connection + this.#initialize(); + } + + /** + * Returns the current ready state of the connection + */ + get readyState(): 0 | 1 | 2 | 3 { + return this.#readyState; + } + + /** + * Sends data through the connection + */ + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + logger().debug("send called", { readyState: this.readyState }); + + if (this.readyState !== this.OPEN) { + const error = new Error("WebSocket is not open"); + logger().warn("cannot send message, websocket not open", { + readyState: this.readyState, + dataType: typeof data, + dataLength: typeof data === "string" ? data.length : "binary", + error, + }); + this.#fireError(error); + return; + } + + try { + // Handle different data types + if (typeof data === "string") { + // For string data, parse as JSON + logger().debug("parsing JSON string message", { + dataLength: data.length, + }); + const message = JSON.parse(data) as messageToServer.ToServer; + + logger().debug("fake websocket sending message", { + messageType: + message.b && + ("i" in message.b + ? "init" + : "ar" in message.b + ? "action" + : "sr" in message.b + ? "subscription" + : "unknown"), + }); + + this.#handler.onMessage(message).catch((err) => { + logger().error("error handling websocket message", { + error: err, + errorMessage: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + this.#fireError(err); + }); + } else if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + // Convert to Uint8Array if needed + const uint8Array = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + + logger().debug("sending binary message", { + dataLength: uint8Array.byteLength, + }); + + // Parse the binary message + this.#parseBinaryMessage(uint8Array); + } else if (data instanceof Blob) { + logger().debug("sending blob message", { blobSize: data.size }); + + // Convert Blob to ArrayBuffer + data + .arrayBuffer() + .then((buffer) => { + logger().debug("converted blob to arraybuffer", { + bufferLength: buffer.byteLength, + }); + this.#parseBinaryMessage(new Uint8Array(buffer)); + }) + .catch((err) => { + logger().error("error processing blob data", { + error: err, + errorMessage: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + blobSize: data.size, + }); + this.#fireError(err); + }); + } + } catch (err) { + logger().error("error sending websocket message", { + error: err, + errorMessage: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + dataType: typeof data, + dataLength: typeof data === "string" ? data.length : "binary", + }); + this.#fireError(err); + } + } + + /** + * Closes the connection + */ + close(code = 1000, reason = ""): void { + if (this.readyState === this.CLOSED || this.readyState === this.CLOSING) { + return; + } + + logger().debug("closing fake websocket", { code, reason }); + + this.#readyState = this.CLOSING; + + // Call the handler's onClose method + this.#handler + .onClose() + .catch((err) => { + logger().error("error closing websocket", { error: err }); + }) + .finally(() => { + this.#readyState = this.CLOSED; + + // Fire the close event + // Create a close event object since CloseEvent is not available in Node.js + const closeEvent = { + type: "close", + wasClean: code === 1000, + code, + reason, + target: this, + currentTarget: this, + } as unknown as CloseEvent; + + this.#fireClose(closeEvent); + }); + } + + /** + * Implementation of EventTarget methods (minimal implementation) + */ + addEventListener( + type: string, + listener: EventListenerOrEventListenerObject, + ): void { + // Map to the onXXX properties + switch (type) { + case "open": + this.onopen = + typeof listener === "function" + ? listener + : (ev) => listener.handleEvent(ev); + break; + case "message": + this.onmessage = + typeof listener === "function" + ? listener + : (ev) => listener.handleEvent(ev); + break; + case "close": + this.onclose = + typeof listener === "function" + ? listener + : (ev) => listener.handleEvent(ev); + break; + case "error": + this.onerror = + typeof listener === "function" + ? listener + : (ev) => listener.handleEvent(ev); + break; + } + } + + removeEventListener(type: string): void { + // Simple implementation that just nullifies the corresponding handler + switch (type) { + case "open": + this.onopen = null; + break; + case "message": + this.onmessage = null; + break; + case "close": + this.onclose = null; + break; + case "error": + this.onerror = null; + break; + } + } + + dispatchEvent(event: Event): boolean { + // Dispatch to the corresponding handler + switch (event.type) { + case "open": + if (this.onopen) this.onopen(event); + break; + case "message": + if (this.onmessage) this.onmessage(event as MessageEvent); + break; + case "close": + if (this.onclose) this.onclose(event as CloseEvent); + break; + case "error": + if (this.onerror) this.onerror(event); + break; + } + return !event.defaultPrevented; + } + + /** + * Wait for the WebSocket to be initialized and ready + */ + waitForReady(): Promise { + return this.#initPromise; + } + + /** + * Initialize the connection with the handler + */ + async #initialize(): Promise { + try { + logger().info("fake websocket initializing"); + + // Call the handler's onOpen method + logger().info("calling handler.onOpen with WSContext"); + await this.#handler.onOpen(this.#wsContext); + + // Update the ready state and fire events + this.#readyState = this.OPEN; + logger().info("fake websocket initialized and now OPEN"); + + // Fire the open event + this.#fireOpen(); + + // Resolve the initialization promise - do this BEFORE processing queued messages + // This allows clients to set up their event handlers before messages are processed + logger().info("resolving initialization promise"); + this.#initResolve(); + + // Delay processing queued messages slightly to allow event handlers to be set up + if (this.#queuedMessages.length > 0) { + if (this.readyState !== this.OPEN) { + logger().warn("socket no longer open, dropping queued messages"); + return; + } + + logger().info( + `now processing ${this.#queuedMessages.length} queued messages`, + ); + + // Create a copy to avoid issues if new messages arrive during processing + const messagesToProcess = [...this.#queuedMessages]; + this.#queuedMessages = []; + + // Process each queued message + for (const message of messagesToProcess) { + logger().debug("processing queued message"); + this.#handleMessage(message); + } + } + } catch (err) { + logger().error("error opening fake websocket", { + error: err, + errorMessage: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + this.#fireError(err); + this.close(1011, "Internal error during initialization"); + this.#initReject(err); + } + } + + /** + * Handle messages received from the server via the WSContext + */ + #handleMessage(data: string | ArrayBuffer | Uint8Array): void { + // Store messages that arrive before the socket is fully initialized + if (this.readyState !== this.OPEN) { + logger().debug("message received before socket is OPEN, queuing", { + readyState: this.readyState, + dataType: typeof data, + dataLength: + typeof data === "string" + ? data.length + : data instanceof ArrayBuffer + ? data.byteLength + : data instanceof Uint8Array + ? data.byteLength + : "unknown", + }); + + // Queue the message to be processed once the socket is open + this.#queuedMessages.push(data); + return; + } + + // Log message received from server + logger().debug("fake websocket received message from server", { + dataType: typeof data, + dataLength: + typeof data === "string" + ? data.length + : data instanceof ArrayBuffer + ? data.byteLength + : data instanceof Uint8Array + ? data.byteLength + : "unknown", + }); + + // Create a MessageEvent-like object + const event = { + type: "message", + data, + target: this, + currentTarget: this, + } as unknown as MessageEvent; + + // Dispatch the event + if (this.onmessage) { + logger().debug("dispatching message to onmessage handler"); + this.onmessage(event); + } else { + logger().warn("no onmessage handler registered, message dropped"); + } + } + + /** + * Handle connection close from the server side + */ + #handleClose(code: number, reason: string): void { + if (this.readyState === this.CLOSED) return; + + this.#readyState = this.CLOSED; + + // Create a CloseEvent-like object + const event = { + type: "close", + code, + reason, + wasClean: code === 1000, + target: this, + currentTarget: this, + } as unknown as CloseEvent; + + // Dispatch the event + if (this.onclose) { + this.onclose(event); + } + } + + /** + * Fire the open event + */ + #fireOpen(): void { + try { + // Create an Event-like object since Event constructor may not be available + const event = { + type: "open", + target: this, + currentTarget: this, + } as unknown as Event; + + if (this.onopen) { + this.onopen(event); + } + } catch (err) { + logger().error("error in onopen handler", { error: err }); + } + } + + /** + * Fire the close event + */ + #fireClose(event: CloseEvent): void { + try { + if (this.onclose) { + this.onclose(event); + } + } catch (err) { + logger().error("error in onclose handler", { error: err }); + } + } + + /** + * Fire the error event + */ + #fireError(error: unknown): void { + try { + // Create an Event-like object for error + const event = { + type: "error", + target: this, + currentTarget: this, + error, + message: error instanceof Error ? error.message : String(error), + } as unknown as Event; + + if (this.onerror) { + this.onerror(event); + } + } catch (err) { + logger().error("error in onerror handler", { error: err }); + } + + // Log the error + logger().error("websocket error", { error }); + } + + /** + * Parse binary message and forward to handler + */ + async #parseBinaryMessage(data: Uint8Array): Promise { + try { + logger().debug("parsing binary message", { dataLength: data.byteLength }); + + // Attempt to parse the binary message using the protocol's parse function + const message = await parseMessage(data as unknown as InputData, { + encoding: "cbor", + maxIncomingMessageSize: 1024 * 1024, // 1MB default limit + }); + + logger().debug("successfully parsed binary message", { + messageType: + message.b && + ("i" in message.b + ? "init" + : "ar" in message.b + ? "action" + : "sr" in message.b + ? "subscription" + : "unknown"), + }); + + // Forward the parsed message to the handler + await this.#handler.onMessage(message); + logger().debug("handler processed binary message"); + } catch (err) { + logger().error("error parsing binary websocket message", { + error: err, + errorMessage: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + dataLength: data.byteLength, + }); + this.#fireError(err); + } + } +} diff --git a/packages/core/src/app/inline-client-driver.ts b/packages/core/src/app/inline-client-driver.ts index 37503d38e..34bf18259 100644 --- a/packages/core/src/app/inline-client-driver.ts +++ b/packages/core/src/app/inline-client-driver.ts @@ -5,10 +5,16 @@ import type { EventSource } from "eventsource"; import type * as wsToServer from "@/worker/protocol/message/to-server"; import { type Encoding, serialize } from "@/worker/protocol/serde"; import { + ConnectWebSocketOutput, + handleWebSocketConnect, HEADER_CONN_PARAMS, HEADER_ENCODING, + HEADER_CONN_ID, + HEADER_CONN_TOKEN, type ConnectionHandlers, + HEADER_EXPOSE_INTERNAL_ERROR, } from "@/worker/router-endpoints"; +import type { SSEStreamingApi } from "hono/streaming"; import { HonoRequest, type Context as HonoContext, type Next } from "hono"; import invariant from "invariant"; import { ClientDriver } from "@/client/client"; @@ -18,10 +24,20 @@ import { ConnRoutingHandler } from "@/worker/conn-routing-handler"; import { sendHttpRequest, serializeWithEncoding } from "@/client/utils"; import { ActionRequest, ActionResponse } from "@/worker/protocol/http/action"; import { assertUnreachable } from "@/worker/utils"; +import { FakeWebSocket } from "./fake-websocket"; +import { FakeEventSource } from "./fake-event-source"; +import { importWebSocket } from "@/common/websocket"; +import { importEventSource } from "@/common/eventsource"; +import onChange from "on-change"; +import { httpUserAgent } from "@/utils"; +import { WorkerError as ClientWorkerError } from "@/client/errors"; +import { deconstructError } from "@/common/utils"; /** * Client driver that calls the manager driver inline. * + * This is only applicable to standalone & coordinated topologies. + * * This driver can access private resources. * * This driver serves a double purpose as: @@ -32,83 +48,102 @@ export function createInlineClientDriver( managerDriver: ManagerDriver, routingHandler: ConnRoutingHandler, ): ClientDriver { - //// Lazily import the dynamic imports so we don't have to turn `createClient` in to an aysnc fn - //const dynamicImports = (async () => { - // // Import dynamic dependencies - // const [WebSocket, EventSource] = await Promise.all([ - // importWebSocket(), - // importEventSource(), - // ]); - // return { - // WebSocket, - // EventSource, - // }; - //})(); - const driver: ClientDriver = { action: async = unknown[], Response = unknown>( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encoding: Encoding, params: unknown, actionName: string, ...args: Args ): Promise => { - // Get the worker ID and meta - const { workerId, meta } = await queryWorker( - req, - workerQuery, - managerDriver, - ); - logger().debug("found worker for action", { workerId, meta }); - invariant(workerId, "Missing worker ID"); + try { + // Get the worker ID and meta + const { workerId, meta } = await queryWorker( + c, + workerQuery, + managerDriver, + ); + logger().debug("found worker for action", { workerId, meta }); + invariant(workerId, "Missing worker ID"); - // Invoke the action - logger().debug("handling action", { actionName, encoding }); - if ("inline" in routingHandler) { - const { output } = await routingHandler.inline.handlers.onAction({ - req, - params, - actionName, - actionArgs: args, - workerId, - }); - return output as Response; - } else if ("custom" in routingHandler) { - const responseData = await sendHttpRequest< - ActionRequest, - ActionResponse - >({ - url: `http://worker/action/${encodeURIComponent(actionName)}`, - method: "POST", - headers: { - [HEADER_ENCODING]: encoding, - ...(params !== undefined - ? { [HEADER_CONN_PARAMS]: JSON.stringify(params) } - : {}), - }, - body: { a: args } satisfies ActionRequest, - encoding: encoding, - customFetch: routingHandler.custom.sendRequest.bind( - undefined, + // Invoke the action + logger().debug("handling action", { actionName, encoding }); + if ("inline" in routingHandler) { + const { output } = await routingHandler.inline.handlers.onAction({ + req: c?.req, + params, + actionName, + actionArgs: args, workerId, - meta, - ), - }); + }); - return responseData.o as Response; - } else { - assertUnreachable(routingHandler); + try { + // Normally, the output is serialized over the network and is safe to mutate + // + // In this case, this value is referencing the same value in the original + // state, so we have to clone it to ensure that it's safe to mutate + // without mutating the main state + return structuredClone(output) as Response; + } catch (err) { + // HACK: If we return a value that references the worker state (i.e. an on-change value), + // this will throw an error. We fall back to `DataCloneError`. + if (err instanceof DOMException && err.name === "DataCloneError") { + logger().trace( + "received DataCloneError which means that there was an on-change value, unproxying recursively", + ); + return structuredClone(unproxyRecursive(output as Response)); + } else { + throw err; + } + } + } else if ("custom" in routingHandler) { + const responseData = await sendHttpRequest< + ActionRequest, + ActionResponse + >({ + url: `http://worker/action/${encodeURIComponent(actionName)}`, + method: "POST", + headers: { + [HEADER_ENCODING]: encoding, + ...(params !== undefined + ? { [HEADER_CONN_PARAMS]: JSON.stringify(params) } + : {}), + [HEADER_EXPOSE_INTERNAL_ERROR]: "true", + }, + body: { a: args } satisfies ActionRequest, + encoding: encoding, + customFetch: routingHandler.custom.sendRequest.bind( + undefined, + workerId, + meta, + ), + }); + + return responseData.o as Response; + } else { + assertUnreachable(routingHandler); + } + } catch (err) { + // Standardize to ClientWorkerError instead of the native backend error + const { code, message, metadata } = deconstructError( + err, + logger(), + {}, + true, + ); + const x = new ClientWorkerError(code, message, metadata); + throw new ClientWorkerError(code, message, metadata); } }, resolveWorkerId: async ( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, _encodingKind: Encoding, ): Promise => { // Get the worker ID and meta - const { workerId } = await queryWorker(req, workerQuery, managerDriver); + const { workerId } = await queryWorker(c, workerQuery, managerDriver); logger().debug("resolved worker", { workerId }); invariant(workerId, "missing worker ID"); @@ -116,31 +151,213 @@ export function createInlineClientDriver( }, connectWebSocket: async ( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, + params?: unknown, ): Promise => { - throw "UNIMPLEMENTED"; + // Get the worker ID and meta + const { workerId, meta } = await queryWorker( + c, + workerQuery, + managerDriver, + ); + logger().debug("found worker for action", { workerId, meta }); + invariant(workerId, "Missing worker ID"); + + // Invoke the action + logger().debug("opening websocket", { workerId, encoding: encodingKind }); + if ("inline" in routingHandler) { + invariant( + routingHandler.inline.handlers.onConnectWebSocket, + "missing onConnectWebSocket handler", + ); + + logger().debug("calling onConnectWebSocket handler", { + workerId, + encoding: encodingKind, + }); + + // Create handler + const output = await routingHandler.inline.handlers.onConnectWebSocket({ + req: c?.req, + encoding: encodingKind, + params, + workerId, + }); + + logger().debug("got ConnectWebSocketOutput, creating FakeWebSocket"); + + // TODO: There might be a bug where mutating data from the response of an action over a websocket will mutate the original data. See note about `structuredClone` in `action` + // Create and initialize the FakeWebSocket, waiting for it to be ready + const webSocket = new FakeWebSocket(output); + logger().debug("FakeWebSocket created and initialized"); + + return webSocket; + } else if ("custom" in routingHandler) { + // Open WebSocket + const ws = await routingHandler.custom.openWebSocket( + workerId, + meta, + encodingKind, + ); + + // Send init message with the initialization data + // + // We can't pass this data in the query string since it might include sensitive data which would get logged + const messageSerialized = serializeWithEncoding(encodingKind, { + b: { i: { p: params } }, + }); + ws.send(messageSerialized); + logger().debug("sent websocket init message"); + + return ws; + } else { + assertUnreachable(routingHandler); + } }, connectSse: async ( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, params: unknown, ): Promise => { - throw "UNIMPLEMENTED"; + // Get the worker ID and meta + const { workerId, meta } = await queryWorker( + c, + workerQuery, + managerDriver, + ); + logger().debug("found worker for sse connection", { workerId, meta }); + invariant(workerId, "Missing worker ID"); + + logger().debug("opening sse connection", { + workerId, + encoding: encodingKind, + }); + + if ("inline" in routingHandler) { + invariant( + routingHandler.inline.handlers.onConnectSse, + "missing onConnectSse handler", + ); + + logger().debug("calling onConnectSse handler", { + workerId, + encoding: encodingKind, + }); + + // Create handler + const output = await routingHandler.inline.handlers.onConnectSse({ + req: c?.req, + encoding: encodingKind, + params, + workerId, + }); + + logger().debug("got ConnectSseOutput, creating FakeEventSource"); + + // Create a FakeEventSource that will connect to the output handler + const eventSource = new FakeEventSource(async () => { + try { + await output.onClose(); + } catch (err) { + logger().error("error closing sse connection", { error: err }); + } + }); + + // Initialize the connection + await output.onOpen(eventSource.getStream()); + + return eventSource as unknown as EventSource; + } else if ("custom" in routingHandler) { + const EventSourceClass = await importEventSource(); + + const eventSource = new EventSourceClass("http://worker/connect/sse", { + fetch: (input, init) => { + return fetch(input, { + ...init, + headers: { + ...init?.headers, + "User-Agent": httpUserAgent(), + [HEADER_ENCODING]: encodingKind, + ...(params !== undefined + ? { [HEADER_CONN_PARAMS]: JSON.stringify(params) } + : {}), + [HEADER_EXPOSE_INTERNAL_ERROR]: "true", + }, + }); + }, + }) as EventSource; + + return eventSource; + } else { + assertUnreachable(routingHandler); + } }, sendHttpMessage: async ( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerId: string, encoding: Encoding, connectionId: string, connectionToken: string, message: wsToServer.ToServer, ): Promise => { - throw "UNIMPLEMENTED"; + logger().debug("sending http message", { workerId, connectionId }); + + if ("inline" in routingHandler) { + invariant( + routingHandler.inline.handlers.onConnMessage, + "missing onConnMessage handler", + ); + + // Call the handler directly + await routingHandler.inline.handlers.onConnMessage({ + req: c?.req, + connId: connectionId, + connToken: connectionToken, + message, + workerId, + }); + + // Return empty response + return new Response(JSON.stringify({}), { + headers: { + "Content-Type": "application/json", + }, + }); + } else if ("custom" in routingHandler) { + // For custom routing handler, get the worker metadata first + const { meta } = await queryWorker( + c, + { getForId: { workerId } }, + managerDriver, + ); + + // Send an HTTP request to the connections endpoint + return sendHttpRequest({ + url: "http://worker/connections/message", + method: "POST", + headers: { + [HEADER_ENCODING]: encoding, + [HEADER_CONN_ID]: connectionId, + [HEADER_CONN_TOKEN]: connectionToken, + [HEADER_EXPOSE_INTERNAL_ERROR]: "true", + }, + body: message, + encoding, + skipParseResponse: true, + customFetch: routingHandler.custom.sendRequest.bind( + undefined, + workerId, + meta, + ), + }); + } else { + assertUnreachable(routingHandler); + } }, }; @@ -151,7 +368,7 @@ export function createInlineClientDriver( * Query the manager driver to get or create a worker based on the provided query */ export async function queryWorker( - req: HonoRequest | undefined, + c: HonoContext | undefined, query: WorkerQuery, driver: ManagerDriver, ): Promise<{ workerId: string; meta?: unknown }> { @@ -159,14 +376,14 @@ export async function queryWorker( let workerOutput: { workerId: string; meta?: unknown }; if ("getForId" in query) { const output = await driver.getForId({ - req, + c, workerId: query.getForId.workerId, }); if (!output) throw new errors.WorkerNotFound(query.getForId.workerId); workerOutput = output; } else if ("getForKey" in query) { const existingWorker = await driver.getWithKey({ - req, + c, name: query.getForKey.name, key: query.getForKey.key, }); @@ -178,7 +395,7 @@ export async function queryWorker( workerOutput = existingWorker; } else if ("getOrCreateForKey" in query) { const getOrCreateOutput = await driver.getOrCreateWithKey({ - req, + c, name: query.getOrCreateForKey.name, key: query.getOrCreateForKey.key, input: query.getOrCreateForKey.input, @@ -190,7 +407,7 @@ export async function queryWorker( }; } else if ("create" in query) { const createOutput = await driver.createWorker({ - req, + c, name: query.create.name, key: query.create.key, input: query.create.input, @@ -210,3 +427,34 @@ export async function queryWorker( }); return { workerId: workerOutput.workerId, meta: workerOutput.meta }; } + +/** + * Removes the on-change library's proxy recursively from a value so we can clone it with `structuredClone`. + */ +function unproxyRecursive(objProxied: T): T { + const obj = onChange.target(objProxied); + + // Short circuit if this object was proxied + // + // If the reference is different, then this value was proxied and no + // nested values are proxied + if (obj !== objProxied) return obj; + + // Handle null/undefined + if (!obj || typeof obj !== "object") { + return obj; + } + + // Handle arrays + if (Array.isArray(obj)) { + return obj.map((x) => unproxyRecursive(x)) as T; + } + + // Handle objects + const result: any = {}; + for (const key in obj) { + result[key] = unproxyRecursive(obj[key]); + } + + return result; +} diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 1a09ffbd1..1620f3ac7 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -14,6 +14,7 @@ import type { WorkerCoreApp } from "@/mod"; import type { AnyWorkerDefinition } from "@/worker/definition"; import type * as wsToServer from "@/worker/protocol/message/to-server"; import type { EventSource } from "eventsource"; +import type { Context as HonoContext } from "hono"; import { createHttpClientDriver } from "./http-client-driver"; import { HonoRequest } from "hono"; @@ -160,7 +161,7 @@ export const TRANSPORT_SYMBOL = Symbol("transport"); export interface ClientDriver { action = unknown[], Response = unknown>( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encoding: Encoding, params: unknown, @@ -168,23 +169,24 @@ export interface ClientDriver { ...args: Args ): Promise; resolveWorkerId( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, ): Promise; connectWebSocket( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, + params: unknown, ): Promise; connectSse( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, params: unknown, ): Promise; sendHttpMessage( - req: HonoRequest | undefined, + c: HonoContext | undefined, workerId: string, encoding: Encoding, connectionId: string, diff --git a/packages/core/src/client/errors.ts b/packages/core/src/client/errors.ts index 63f3a3218..15724aa63 100644 --- a/packages/core/src/client/errors.ts +++ b/packages/core/src/client/errors.ts @@ -25,6 +25,8 @@ export class MalformedResponseMessage extends WorkerClientError { } export class WorkerError extends WorkerClientError { + __type = "WorkerError"; + constructor( public readonly code: string, message: string, diff --git a/packages/core/src/client/http-client-driver.ts b/packages/core/src/client/http-client-driver.ts index c4becd631..86ef73b92 100644 --- a/packages/core/src/client/http-client-driver.ts +++ b/packages/core/src/client/http-client-driver.ts @@ -25,7 +25,7 @@ import { import type { ActionRequest } from "@/worker/protocol/http/action"; import type { ActionResponse } from "@/worker/protocol/message/to-client"; import { ClientDriver } from "./client"; -import { HonoRequest } from "hono"; +import { HonoRequest, Context as HonoContext } from "hono"; /** * Client driver that communicates with the manager via HTTP. @@ -46,7 +46,7 @@ export function createHttpClientDriver(managerEndpoint: string): ClientDriver { const driver: ClientDriver = { action: async = unknown[], Response = unknown>( - _req: HonoRequest | undefined, + _c: HonoContext | undefined, workerQuery: WorkerQuery, encoding: Encoding, params: unknown, @@ -79,7 +79,7 @@ export function createHttpClientDriver(managerEndpoint: string): ClientDriver { }, resolveWorkerId: async ( - _req: HonoRequest | undefined, + _c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, ): Promise => { @@ -115,9 +115,10 @@ export function createHttpClientDriver(managerEndpoint: string): ClientDriver { }, connectWebSocket: async ( - _req: HonoRequest | undefined, + _c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, + params: unknown, ): Promise => { const { WebSocket } = await dynamicImports; @@ -140,11 +141,22 @@ export function createHttpClientDriver(managerEndpoint: string): ClientDriver { assertUnreachable(encodingKind); } + ws.addEventListener("open", () => { + // Send init message with the initialization data + // + // We can't pass this data in the query string since it might include sensitive data which would get logged + const messageSerialized = serializeWithEncoding(encodingKind, { + b: { i: { p: params } }, + }); + ws.send(messageSerialized); + logger().debug("sent websocket init message"); + }); + return ws; }, connectSse: async ( - _req: HonoRequest | undefined, + _c: HonoContext | undefined, workerQuery: WorkerQuery, encodingKind: Encoding, params: unknown, @@ -175,7 +187,7 @@ export function createHttpClientDriver(managerEndpoint: string): ClientDriver { }, sendHttpMessage: async ( - _req: HonoRequest | undefined, + _c: HonoContext | undefined, workerId: string, encoding: Encoding, connectionId: string, diff --git a/packages/core/src/client/utils.ts b/packages/core/src/client/utils.ts index b1ba3a266..2265920c8 100644 --- a/packages/core/src/client/utils.ts +++ b/packages/core/src/client/utils.ts @@ -74,7 +74,7 @@ export async function sendHttpRequest< : {}), "User-Agent": httpUserAgent(), }, - body: bodyData + body: bodyData, }), ); } catch (error) { diff --git a/packages/core/src/client/worker-conn.ts b/packages/core/src/client/worker-conn.ts index c9fbda5c2..3c749af06 100644 --- a/packages/core/src/client/worker-conn.ts +++ b/packages/core/src/client/worker-conn.ts @@ -19,7 +19,11 @@ import { } from "./client"; import * as errors from "./errors"; import { logger } from "./log"; -import { type WebSocketMessage as ConnMessage, messageLength, serializeWithEncoding } from "./utils"; +import { + type WebSocketMessage as ConnMessage, + messageLength, + serializeWithEncoding, +} from "./utils"; import { HEADER_WORKER_ID, HEADER_WORKER_QUERY, @@ -254,20 +258,11 @@ enc undefined, this.#workerQuery, this.#encodingKind, + this.#params, ); this.#transport = { websocket: ws }; ws.onopen = () => { logger().debug("websocket open"); - - // Set init message - this.#sendMessage( - { - b: { i: { p: this.#params } }, - }, - { ephemeral: true }, - ); - - // #handleOnOpen is called on "i" event from the server }; ws.onmessage = async (ev) => { this.#handleOnMessage(ev); @@ -610,8 +605,7 @@ enc message, ); this.#transport.websocket.send(messageSerialized); - logger().debug("sent websocket message", { - message: message, + logger().trace("sent websocket message", { len: messageLength(messageSerialized), }); } catch (error) { @@ -650,6 +644,10 @@ enc if (!this.#workerId || !this.#connectionId || !this.#connectionToken) throw new errors.InternalError("Missing connection ID or token."); + logger().trace("sent http message", { + message: JSON.stringify(message).substring(0, 100) + "...", + }); + const res = await this.#driver.sendHttpMessage( undefined, this.#workerId, @@ -758,9 +756,10 @@ enc // Nothing to do } else if ("websocket" in this.#transport) { const { promise, resolve } = Promise.withResolvers(); - this.#transport.websocket.addEventListener("close", () => - resolve(undefined), - ); + this.#transport.websocket.addEventListener("close", () => { + logger().debug("ws closed"); + resolve(undefined); + }); this.#transport.websocket.close(); await promise; } else if ("sse" in this.#transport) { diff --git a/packages/core/src/common/log.ts b/packages/core/src/common/log.ts index d5ccb97a9..bc69f3d20 100644 --- a/packages/core/src/common/log.ts +++ b/packages/core/src/common/log.ts @@ -110,7 +110,7 @@ function formatter(log: LogRecord): string { return stringify( //["ts", formatTimestamp(log.datetime)], ["level", LevelNameMap[log.level]], - //["target", log.loggerName], + ["target", log.loggerName], ["msg", log.msg], ...args, ); diff --git a/packages/core/src/common/router.ts b/packages/core/src/common/router.ts index 45a5cab96..79a299027 100644 --- a/packages/core/src/common/router.ts +++ b/packages/core/src/common/router.ts @@ -1,7 +1,10 @@ import type { Context as HonoContext, Next } from "hono"; import { getLogger, Logger } from "./log"; import { deconstructError } from "./utils"; -import { getRequestEncoding } from "@/worker/router-endpoints"; +import { + getRequestEncoding, + getRequestExposeInternalError, +} from "@/worker/router-endpoints"; import { serialize } from "@/worker/protocol/serde"; import { ResponseError } from "@/worker/protocol/http/error"; @@ -34,7 +37,19 @@ export function handleRouteNotFound(c: HonoContext) { return c.text("Not Found (WorkerCore)", 404); } -export function handleRouteError(error: unknown, c: HonoContext) { +export interface HandleRouterErrorOpts { + enableExposeInternalError?: boolean; +} + +export function handleRouteError( + opts: HandleRouterErrorOpts, + error: unknown, + c: HonoContext, +) { + const exposeInternalError = + opts.enableExposeInternalError && + getRequestExposeInternalError(c.req, false); + const { statusCode, code, message, metadata } = deconstructError( error, logger(), @@ -42,6 +57,7 @@ export function handleRouteError(error: unknown, c: HonoContext) { method: c.req.method, path: c.req.path, }, + exposeInternalError, ); const encoding = getRequestEncoding(c.req, false); diff --git a/packages/core/src/common/utils.ts b/packages/core/src/common/utils.ts index 0ee637805..692902fa4 100644 --- a/packages/core/src/common/utils.ts +++ b/packages/core/src/common/utils.ts @@ -116,7 +116,9 @@ export function isJsonSerializable( } export interface DeconstructedError { + __type: "WorkerError"; statusCode: ContentfulStatusCode; + public: boolean; code: string; message: string; metadata?: unknown; @@ -127,18 +129,21 @@ export function deconstructError( error: unknown, logger: Logger, extraLog: Record, -) { + exposeInternalError = false, +): DeconstructedError { // Build response error information. Only return errors if flagged as public in order to prevent leaking internal behavior. // // We log the error here instead of after generating the code & message because we need to log the original error, not the masked internal error. let statusCode: ContentfulStatusCode; + let public_: boolean; let code: string; let message: string; let metadata: unknown = undefined; if (errors.WorkerError.isWorkerError(error) && error.public) { statusCode = 400; + public_ = true; code = error.code; - message = String(error); + message = getErrorMessage(error); metadata = error.metadata; logger.info("public error", { @@ -146,8 +151,34 @@ export function deconstructError( message, ...extraLog, }); + } else if (exposeInternalError) { + if (errors.WorkerError.isWorkerError(error)) { + statusCode = 500; + public_ = false; + code = error.code; + message = getErrorMessage(error); + metadata = error.metadata; + + logger.info("internal error", { + code, + message, + ...extraLog, + }); + } else { + statusCode = 500; + public_ = false; + code = errors.INTERNAL_ERROR_CODE; + message = getErrorMessage(error); + + logger.info("internal error", { + code, + message, + ...extraLog, + }); + } } else { statusCode = 500; + public_ = false; code = errors.INTERNAL_ERROR_CODE; message = errors.INTERNAL_ERROR_DESCRIPTION; metadata = { @@ -155,13 +186,20 @@ export function deconstructError( } satisfies errors.InternalErrorMetadata; logger.warn("internal error", { - error: String(error), + error: getErrorMessage(error), stack: (error as Error)?.stack, ...extraLog, }); } - return { statusCode, code, message, metadata }; + return { + __type: "WorkerError", + statusCode, + public: public_, + code, + message, + metadata, + }; } export function stringifyError(error: unknown): string { @@ -180,6 +218,19 @@ export function stringifyError(error: unknown): string { return "[cannot stringify error]"; } } else { - return `Unknown error: ${String(error)}`; + return `Unknown error: ${getErrorMessage(error)}`; + } +} + +function getErrorMessage(err: unknown): string { + if ( + err && + typeof err === "object" && + "message" in err && + typeof err.message === "string" + ) { + return err.message; + } else { + return String(err); } } diff --git a/packages/core/src/driver-helpers/config.ts b/packages/core/src/driver-helpers/config.ts index 46aca7ee5..e96a9be9b 100644 --- a/packages/core/src/driver-helpers/config.ts +++ b/packages/core/src/driver-helpers/config.ts @@ -13,13 +13,12 @@ import type { import type { CoordinateDriver } from "@/topologies/coordinate/driver"; import type { ManagerDriver } from "@/manager/driver"; import type { WorkerDriver } from "@/worker/driver"; +import { UpgradeWebSocket } from "@/utils"; export const TopologySchema = z.enum(["standalone", "partition", "coordinate"]); export type Topology = z.infer; -export type GetUpgradeWebSocket = ( - app: Hono, -) => (createEvents: (c: HonoContext) => any) => HonoHandler; +export type GetUpgradeWebSocket = (app: Hono) => UpgradeWebSocket; /** Base config used for the worker config across all platforms. */ export const DriverConfigSchema = z.object({ diff --git a/packages/core/src/driver-test-suite/mod.ts b/packages/core/src/driver-test-suite/mod.ts index d953fbfa5..fed0e87b6 100644 --- a/packages/core/src/driver-test-suite/mod.ts +++ b/packages/core/src/driver-test-suite/mod.ts @@ -8,16 +8,12 @@ import { import { runWorkerDriverTests } from "./tests/worker-driver"; import { runManagerDriverTests } from "./tests/manager-driver"; import { describe } from "vitest"; -import { - type WorkerCoreApp, - CoordinateTopology, - StandaloneTopology, -} from "@/mod"; +import { CoordinateTopology, StandaloneTopology, WorkerCoreApp } from "@/mod"; import { createNodeWebSocket, type NodeWebSocket } from "@hono/node-ws"; import invariant from "invariant"; import { bundleRequire } from "bundle-require"; import { getPort } from "@/test/mod"; -import { Client, Transport } from "@/client/mod"; +import { Transport } from "@/client/mod"; import { runWorkerConnTests } from "./tests/worker-conn"; import { runWorkerHandleTests } from "./tests/worker-handle"; import { runActionFeaturesTests } from "./tests/action-features"; @@ -25,7 +21,6 @@ import { runWorkerVarsTests } from "./tests/worker-vars"; import { runWorkerConnStateTests } from "./tests/worker-conn-state"; import { runWorkerMetadataTests } from "./tests/worker-metadata"; import { runWorkerErrorHandlingTests } from "./tests/worker-error-handling"; -import { ClientDriver } from "@/client/client"; export interface DriverTestConfig { /** Deploys an app and returns the connection endpoint. */ @@ -54,7 +49,6 @@ type ClientType = "http" | "inline"; export interface DriverDeployOutput { endpoint: string; - inlineClientDriver: ClientDriver; /** Cleans up the test. */ cleanup(): Promise; @@ -74,7 +68,9 @@ export function runDriverTests( runWorkerDriverTests(driverTestConfig); runManagerDriverTests(driverTestConfig); - for (const transport of ["websocket", "sse"] as Transport[]) { + // TODO: Add back SSE once fixed in Rivet driver & CF lifecycle + // for (const transport of ["websocket", "sse"] as Transport[]) { + for (const transport of ["websocket"] as Transport[]) { describe(`transport (${transport})`, () => { runWorkerConnTests({ ...driverTestConfig, @@ -105,7 +101,7 @@ export function runDriverTests( */ export async function createTestRuntime( appPath: string, - driverFworkery: (app: WorkerCoreApp) => Promise<{ + driverFactory: (app: WorkerCoreApp) => Promise<{ workerDriver: WorkerDriver; managerDriver: ManagerDriver; coordinateDriver?: CoordinateDriver; @@ -114,17 +110,21 @@ export async function createTestRuntime( ): Promise { const { mod: { app }, - } = await bundleRequire({ + } = await bundleRequire<{ app: WorkerCoreApp }>({ filepath: appPath, }); + // TODO: Find a cleaner way of flagging an app as test mode (ideally not in the config itself) + // Force enable test + app.config.test.enabled = true; + // Build drivers const { workerDriver, managerDriver, coordinateDriver, cleanup: driverCleanup, - } = await driverFworkery(app); + } = await driverFactory(app); // Build driver config let injectWebSocket: NodeWebSocket["injectWebSocket"] | undefined; @@ -156,6 +156,7 @@ export async function createTestRuntime( }); invariant(injectWebSocket !== undefined, "should have injectWebSocket"); injectWebSocket(server); + const endpoint = `http://127.0.0.1:${port}`; // Cleanup const cleanup = async () => { @@ -167,8 +168,7 @@ export async function createTestRuntime( }; return { - endpoint: `http://127.0.0.1:${port}`, - inlineClientDriver: topology.clientDriver, + endpoint, cleanup, }; } diff --git a/packages/core/src/driver-test-suite/test-apps.ts b/packages/core/src/driver-test-suite/test-apps.ts deleted file mode 100644 index aec94f149..000000000 --- a/packages/core/src/driver-test-suite/test-apps.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { resolve } from "node:path"; - -export type { App as CounterApp } from "../../fixtures/driver-test-suite/counter"; -export type { App as ScheduledApp } from "../../fixtures/driver-test-suite/scheduled"; -export type { App as ConnParamsApp } from "../../fixtures/driver-test-suite/conn-params"; -export type { App as LifecycleApp } from "../../fixtures/driver-test-suite/lifecycle"; -export type { App as ActionTimeoutApp } from "../../fixtures/driver-test-suite/action-timeout"; -export type { App as ActionTypesApp } from "../../fixtures/driver-test-suite/action-types"; -export type { App as VarsApp } from "../../fixtures/driver-test-suite/vars"; -export type { App as ConnStateApp } from "../../fixtures/driver-test-suite/conn-state"; -export type { App as MetadataApp } from "../../fixtures/driver-test-suite/metadata"; -export type { App as ErrorHandlingApp } from "../../fixtures/driver-test-suite/error-handling"; -export type { App as ActionInputsApp } from "../../fixtures/driver-test-suite/action-inputs"; - -export const COUNTER_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/counter.ts", -); -export const SCHEDULED_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/scheduled.ts", -); -export const CONN_PARAMS_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/conn-params.ts", -); -export const LIFECYCLE_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/lifecycle.ts", -); -export const ACTION_TIMEOUT_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/action-timeout.ts", -); -export const ACTION_TYPES_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/action-types.ts", -); -export const VARS_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/vars.ts", -); -export const CONN_STATE_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/conn-state.ts", -); -export const METADATA_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/metadata.ts", -); -export const ERROR_HANDLING_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/error-handling.ts", -); -export const ACTION_INPUTS_APP_PATH = resolve( - __dirname, - "../../fixtures/driver-test-suite/action-inputs.ts", -); \ No newline at end of file diff --git a/packages/core/src/driver-test-suite/test-inline-client-driver.ts b/packages/core/src/driver-test-suite/test-inline-client-driver.ts new file mode 100644 index 000000000..09b701159 --- /dev/null +++ b/packages/core/src/driver-test-suite/test-inline-client-driver.ts @@ -0,0 +1,246 @@ +import { ClientDriver } from "@/client/client"; +import { type Encoding } from "@/worker/protocol/serde"; +import type * as wsToServer from "@/worker/protocol/message/to-server"; +import type { WorkerQuery } from "@/manager/protocol/query"; +import { Context as HonoContext } from "hono"; +import type { EventSource } from "eventsource"; +import { Transport } from "@/client/mod"; +import { logger } from "./log"; +import { + TestInlineDriverCallRequest, + TestInlineDriverCallResponse, +} from "@/manager/router"; +import { assertUnreachable } from "@/worker/utils"; +import * as cbor from "cbor-x"; +import { WorkerError as ClientWorkerError } from "@/client/errors"; + +/** + * Creates a client driver used for testing the inline client driver. This will send a request to the HTTP server which will then internally call the internal client and return the response. + */ +export function createTestInlineClientDriver( + endpoint: string, + transport: Transport, +): ClientDriver { + return { + action: async = unknown[], Response = unknown>( + c: HonoContext | undefined, + workerQuery: WorkerQuery, + encoding: Encoding, + params: unknown, + name: string, + ...args: Args + ): Promise => { + return makeInlineRequest( + endpoint, + encoding, + transport, + "action", + [undefined, workerQuery, encoding, params, name, ...args], + ); + }, + + resolveWorkerId: async ( + c: HonoContext | undefined, + workerQuery: WorkerQuery, + encodingKind: Encoding, + ): Promise => { + return makeInlineRequest( + endpoint, + encodingKind, + transport, + "resolveWorkerId", + [undefined, workerQuery, encodingKind], + ); + }, + + connectWebSocket: async ( + c: HonoContext | undefined, + workerQuery: WorkerQuery, + encodingKind: Encoding, + params: unknown, + ): Promise => { + logger().info("creating websocket connection via test inline driver", { + workerQuery, + encodingKind, + }); + + // Create WebSocket connection to the test endpoint + const wsUrl = new URL( + `${endpoint}/.test/inline-driver/connect-websocket`, + ); + wsUrl.searchParams.set("workerQuery", JSON.stringify(workerQuery)); + if (params !== undefined) + wsUrl.searchParams.set("params", JSON.stringify(params)); + wsUrl.searchParams.set("encodingKind", encodingKind); + + // Convert http/https to ws/wss + const wsProtocol = wsUrl.protocol === "https:" ? "wss:" : "ws:"; + const finalWsUrl = `${wsProtocol}//${wsUrl.host}${wsUrl.pathname}${wsUrl.search}`; + + logger().debug("connecting to websocket", { url: finalWsUrl }); + + // Create and return the WebSocket + return new WebSocket(finalWsUrl); + }, + + connectSse: async ( + c: HonoContext | undefined, + workerQuery: WorkerQuery, + encodingKind: Encoding, + params: unknown, + ): Promise => { + logger().info("creating sse connection via test inline driver", { + workerQuery, + encodingKind, + params, + }); + + // Dynamically import EventSource if needed + const EventSourceImport = await import("eventsource"); + // Handle both ES modules (default) and CommonJS export patterns + const EventSourceConstructor = + (EventSourceImport as any).default || EventSourceImport; + + // Encode parameters for the URL + const workerQueryParam = encodeURIComponent(JSON.stringify(workerQuery)); + const encodingParam = encodeURIComponent(encodingKind); + const paramsParam = params + ? encodeURIComponent(JSON.stringify(params)) + : null; + + // Create SSE connection URL + const sseUrl = new URL(`${endpoint}/.test/inline-driver/connect-sse`); + sseUrl.searchParams.set("workerQueryRaw", workerQueryParam); + sseUrl.searchParams.set("encodingKind", encodingParam); + if (paramsParam) { + sseUrl.searchParams.set("params", paramsParam); + } + + logger().debug("connecting to sse", { url: sseUrl.toString() }); + + // Create and return the EventSource + const eventSource = new EventSourceConstructor(sseUrl.toString()); + + // Wait for the connection to be established before returning + await new Promise((resolve, reject) => { + eventSource.onopen = () => { + logger().debug("sse connection established"); + resolve(); + }; + + eventSource.onerror = (event: Event) => { + logger().error("sse connection failed", { event }); + reject(new Error("Failed to establish SSE connection")); + }; + + // Set a timeout in case the connection never establishes + setTimeout(() => { + if (eventSource.readyState !== EventSourceConstructor.OPEN) { + reject(new Error("SSE connection timed out")); + } + }, 10000); // 10 second timeout + }); + + return eventSource; + }, + + sendHttpMessage: async ( + c: HonoContext | undefined, + workerId: string, + encoding: Encoding, + connectionId: string, + connectionToken: string, + message: wsToServer.ToServer, + ): Promise => { + logger().info("sending http message via test inline driver", { + workerId, + encoding, + connectionId, + transport, + }); + + const result = await fetch(`${endpoint}/.test/inline-driver/call`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + encoding, + transport, + method: "sendHttpMessage", + args: [ + undefined, + workerId, + encoding, + connectionId, + connectionToken, + message, + ], + } satisfies TestInlineDriverCallRequest), + }); + + if (!result.ok) { + throw new Error(`Failed to send HTTP message: ${result.statusText}`); + } + + // Need to create a Response object from the proxy response + return new Response(await result.text(), { + status: result.status, + statusText: result.statusText, + headers: result.headers, + }); + }, + }; +} + +async function makeInlineRequest( + endpoint: string, + encoding: Encoding, + transport: Transport, + method: string, + args: unknown[], +): Promise { + logger().info("sending inline request", { + encoding, + transport, + method, + args, + }); + + // Call driver + const response = await fetch(`${endpoint}/.test/inline-driver/call`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: cbor.encode({ + encoding, + transport, + method, + args, + } satisfies TestInlineDriverCallRequest), + }); + + if (!response.ok) { + throw new Error(`Failed to call inline ${method}: ${response.statusText}`); + } + + // Parse response + const buffer = await response.arrayBuffer(); + const callResponse: TestInlineDriverCallResponse = cbor.decode( + new Uint8Array(buffer), + ); + + // Throw or OK + if ("ok" in callResponse) { + return callResponse.ok; + } else if ("err" in callResponse) { + throw new ClientWorkerError( + callResponse.err.code, + callResponse.err.message, + callResponse.err.metadata, + ); + } else { + assertUnreachable(callResponse); + } +} diff --git a/packages/core/src/driver-test-suite/tests/action-features.ts b/packages/core/src/driver-test-suite/tests/action-features.ts index f8461a9ec..ee93c3f00 100644 --- a/packages/core/src/driver-test-suite/tests/action-features.ts +++ b/packages/core/src/driver-test-suite/tests/action-features.ts @@ -1,12 +1,6 @@ import { describe, test, expect } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { - ACTION_TIMEOUT_APP_PATH, - ACTION_TYPES_APP_PATH, - type ActionTimeoutApp, - type ActionTypesApp, -} from "../test-apps"; import { WorkerError } from "@/client/errors"; export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { @@ -16,10 +10,10 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { let usesFakeTimers = !driverTestConfig.useRealTimers; test("should timeout actions that exceed the configured timeout", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TIMEOUT_APP_PATH, + ); // The quick action should complete successfully @@ -35,10 +29,10 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { }); test("should respect the default timeout", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TIMEOUT_APP_PATH, + ); // This action should complete within the default timeout @@ -49,22 +43,22 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { }); test("non-promise action results should not be affected by timeout", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TIMEOUT_APP_PATH, + ); // Synchronous action should not be affected by timeout - const result = await client.syncWorker.getOrCreate().syncAction(); + const result = await client.syncTimeoutWorker.getOrCreate().syncAction(); expect(result).toBe("sync response"); }); test("should allow configuring different timeouts for different workers", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TIMEOUT_APP_PATH, + ); // The short timeout worker should fail @@ -82,13 +76,13 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { describe("Action Sync & Async", () => { test("should support synchronous actions", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TYPES_APP_PATH, + ); - const instance = client.syncWorker.getOrCreate(); + const instance = client.syncActionWorker.getOrCreate(); // Test increment action let result = await instance.increment(5); @@ -109,13 +103,13 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { }); test("should support asynchronous actions", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TYPES_APP_PATH, + ); - const instance = client.asyncWorker.getOrCreate(); + const instance = client.asyncActionWorker.getOrCreate(); // Test delayed increment const result = await instance.delayedIncrement(5); @@ -135,16 +129,15 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) { await instance.asyncWithError(true); expect.fail("did not error"); } catch (error) { - expect(error).toBeInstanceOf(WorkerError); expect((error as WorkerError).message).toBe("Intentional error"); } }); test("should handle promises returned from actions correctly", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_TYPES_APP_PATH, + ); const instance = client.promiseWorker.getOrCreate(); diff --git a/packages/core/src/driver-test-suite/tests/manager-driver.ts b/packages/core/src/driver-test-suite/tests/manager-driver.ts index cac701ee0..1a8dbf3fe 100644 --- a/packages/core/src/driver-test-suite/tests/manager-driver.ts +++ b/packages/core/src/driver-test-suite/tests/manager-driver.ts @@ -1,22 +1,16 @@ import { describe, test, expect, vi } from "vitest"; import { setupDriverTest } from "../utils"; import { WorkerError } from "@/client/mod"; -import { - COUNTER_APP_PATH, - ACTION_INPUTS_APP_PATH, - type CounterApp, - type ActionInputsApp, -} from "../test-apps"; import { DriverTestConfig } from "../mod"; export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { describe("Manager Driver Tests", () => { describe("Client Connection Methods", () => { test("connect() - finds or creates a worker", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Basic connect() with no parameters creates a default worker @@ -37,10 +31,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("throws WorkerAlreadyExists when creating duplicate workers", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create a unique worker with specific key @@ -53,7 +47,6 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { await client.counter.create(uniqueKey); expect.fail("did not error on duplicate create"); } catch (err) { - expect(err).toBeInstanceOf(WorkerError); expect((err as WorkerError).code).toBe("worker_already_exists"); } @@ -65,10 +58,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { describe("Connection Options", () => { test("get without create prevents worker creation", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Try to get a nonexistent worker with no create @@ -79,7 +72,6 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { await client.counter.get([nonexistentId]).resolve(); expect.fail("did not error for get"); } catch (err) { - expect(err).toBeInstanceOf(WorkerError); expect((err as WorkerError).code).toBe("worker_not_found"); } @@ -95,10 +87,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("connection params are passed to workers", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create a worker with connection params @@ -121,10 +113,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { describe("Worker Creation & Retrieval", () => { test("creates and retrieves workers by ID", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create a unique ID for this test @@ -141,10 +133,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("passes input to worker during creation", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_INPUTS_APP_PATH, + ); // Test data to pass as input @@ -170,10 +162,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("input is undefined when not provided", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_INPUTS_APP_PATH, + ); // Create worker without providing input @@ -190,10 +182,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("getOrCreate passes input to worker during creation", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ACTION_INPUTS_APP_PATH, + ); // Create a unique key for this test @@ -232,7 +224,7 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { // TODO: Correctly test region for each provider //test("creates and retrieves workers with region", async (c) => { - // const { client } = await setupDriverTest(c, + // const { client } = await setupDriverTest(c, // driverTestConfig, // COUNTER_APP_PATH // ); @@ -259,10 +251,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { describe("Key Matching", () => { test("matches workers only with exactly the same keys", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create worker with multiple keys @@ -297,10 +289,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("string key matches array with single string key", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create worker with string key @@ -314,10 +306,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("undefined key matches empty array key and no key", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create worker with undefined key @@ -336,10 +328,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("no keys does not match workers with keys", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create counter with keys @@ -356,10 +348,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("workers with keys match workers with no keys", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create a counter with no keys @@ -379,12 +371,12 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); describe("Multiple Worker Instances", () => { - // TODO: This test is flakey https://github.com/rivet-gg/worker-core/issues/873 + // TODO: This test is flakey https://github.com/rivet-gg/actor-core/issues/873 test("creates multiple worker instances of the same type", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create multiple instances with different IDs @@ -409,10 +401,10 @@ export function runManagerDriverTests(driverTestConfig: DriverTestConfig) { }); test("handles default instance with no explicit ID", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Get default instance (no ID specified) diff --git a/packages/core/src/driver-test-suite/tests/worker-conn-state.ts b/packages/core/src/driver-test-suite/tests/worker-conn-state.ts index 6920ffa9c..97cd8ca70 100644 --- a/packages/core/src/driver-test-suite/tests/worker-conn-state.ts +++ b/packages/core/src/driver-test-suite/tests/worker-conn-state.ts @@ -1,267 +1,273 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { - CONN_STATE_APP_PATH, - type ConnStateApp, -} from "../test-apps"; - -export function runWorkerConnStateTests( - driverTestConfig: DriverTestConfig -) { - describe("Worker Connection State Tests", () => { - describe("Connection State Initialization", () => { - test("should retrieve connection state", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Connect to the worker - const connection = client.connStateWorker.getOrCreate().connect(); - - // Get the connection state - const connState = await connection.getConnectionState(); - - // Verify the connection state structure - expect(connState.id).toBeDefined(); - expect(connState.username).toBeDefined(); - expect(connState.role).toBeDefined(); - expect(connState.counter).toBeDefined(); - expect(connState.createdAt).toBeDefined(); - - // Clean up - await connection.dispose(); - }); - - test("should initialize connection state with custom parameters", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Connect with custom parameters - const connection = client.connStateWorker.getOrCreate([], { - params: { - username: "testuser", - role: "admin" - } - }).connect(); - - // Get the connection state - const connState = await connection.getConnectionState(); - - // Verify the connection state was initialized with custom values - expect(connState.username).toBe("testuser"); - expect(connState.role).toBe("admin"); - - // Clean up - await connection.dispose(); - }); - }); - - describe("Connection State Management", () => { - test("should maintain unique state for each connection", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Create multiple connections - const conn1 = client.connStateWorker.getOrCreate([], { - params: { username: "user1" } - }).connect(); - - const conn2 = client.connStateWorker.getOrCreate([], { - params: { username: "user2" } - }).connect(); - - // Update connection state for each connection - await conn1.incrementConnCounter(5); - await conn2.incrementConnCounter(10); - - // Get state for each connection - const state1 = await conn1.getConnectionState(); - const state2 = await conn2.getConnectionState(); - - // Verify states are separate - expect(state1.counter).toBe(5); - expect(state2.counter).toBe(10); - expect(state1.username).toBe("user1"); - expect(state2.username).toBe("user2"); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - - test("should track connections in shared state", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Create two connections - const handle = client.connStateWorker.getOrCreate(); - const conn1 = handle.connect(); - const conn2 = handle.connect(); - - // Get state1 for reference - const state1 = await conn1.getConnectionState(); - - // Get connection IDs tracked by the worker - const connectionIds = await conn1.getConnectionIds(); - - // There should be at least 2 connections tracked - expect(connectionIds.length).toBeGreaterThanOrEqual(2); - - // Should include the ID of the first connection - expect(connectionIds).toContain(state1.id); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - - test("should identify different connections in the same worker", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Create two connections to the same worker - const handle = client.connStateWorker.getOrCreate(); - const conn1 = handle.connect(); - const conn2 = handle.connect(); - - // Get all connection states - const allStates = await conn1.getAllConnectionStates(); - - // Should have at least 2 states - expect(allStates.length).toBeGreaterThanOrEqual(2); - - // IDs should be unique - const ids = allStates.map(state => state.id); - const uniqueIds = [...new Set(ids)]; - expect(uniqueIds.length).toBe(ids.length); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - }); - - describe("Connection Lifecycle", () => { - test("should track connection and disconnection events", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Create a connection - const handle = client.connStateWorker.getOrCreate(); - const conn = handle.connect(); - - // Get the connection state - const connState = await conn.getConnectionState(); - - // Verify the connection is tracked - const connectionIds = await conn.getConnectionIds(); - expect(connectionIds).toContain(connState.id); - - // Initial disconnection count - const initialDisconnections = await conn.getDisconnectionCount(); - - // Dispose the connection - await conn.dispose(); - - // Create a new connection to check the disconnection count - const newConn = handle.connect(); - const newDisconnections = await newConn.getDisconnectionCount(); - - // Verify disconnection was tracked - expect(newDisconnections).toBeGreaterThan(initialDisconnections); - - // Clean up - await newConn.dispose(); - }); - - test("should update connection state", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Create a connection - const conn = client.connStateWorker.getOrCreate().connect(); - - // Get the initial state - const initialState = await conn.getConnectionState(); - expect(initialState.username).toBe("anonymous"); - - // Update the connection state - const updatedState = await conn.updateConnection({ - username: "newname", - role: "moderator" - }); - - // Verify the state was updated - expect(updatedState.username).toBe("newname"); - expect(updatedState.role).toBe("moderator"); - - // Get the state again to verify persistence - const latestState = await conn.getConnectionState(); - expect(latestState.username).toBe("newname"); - expect(latestState.role).toBe("moderator"); - - // Clean up - await conn.dispose(); - }); - }); - - describe("Connection Communication", () => { - test("should send messages to specific connections", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_STATE_APP_PATH, - ); - - // Create two connections - const handle = client.connStateWorker.getOrCreate(); - const conn1 = handle.connect(); - const conn2 = handle.connect(); - - // Get connection states - const state1 = await conn1.getConnectionState(); - const state2 = await conn2.getConnectionState(); - - // Set up event listener on second connection - const receivedMessages: any[] = []; - conn2.on("directMessage", (data) => { - receivedMessages.push(data); - }); - - // Send message from first connection to second - const success = await conn1.sendToConnection(state2.id, "Hello from conn1"); - expect(success).toBe(true); - - // Verify message was received - expect(receivedMessages.length).toBe(1); - expect(receivedMessages[0].from).toBe(state1.id); - expect(receivedMessages[0].message).toBe("Hello from conn1"); - - // Clean up - await conn1.dispose(); - await conn2.dispose(); - }); - }); - }); + +export function runWorkerConnStateTests(driverTestConfig: DriverTestConfig) { + describe("Worker Connection State Tests", () => { + describe("Connection State Initialization", () => { + test("should retrieve connection state", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Connect to the worker + const connection = client.connStateWorker.getOrCreate().connect(); + + // Get the connection state + const connState = await connection.getConnectionState(); + + // Verify the connection state structure + expect(connState.id).toBeDefined(); + expect(connState.username).toBeDefined(); + expect(connState.role).toBeDefined(); + expect(connState.counter).toBeDefined(); + expect(connState.createdAt).toBeDefined(); + + // Clean up + await connection.dispose(); + }); + + test("should initialize connection state with custom parameters", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Connect with custom parameters + const connection = client.connStateWorker + .getOrCreate([], { + params: { + username: "testuser", + role: "admin", + }, + }) + .connect(); + + // Get the connection state + const connState = await connection.getConnectionState(); + + // Verify the connection state was initialized with custom values + expect(connState.username).toBe("testuser"); + expect(connState.role).toBe("admin"); + + // Clean up + await connection.dispose(); + }); + }); + + describe("Connection State Management", () => { + test("should maintain unique state for each connection", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Create multiple connections + const conn1 = client.connStateWorker + .getOrCreate([], { + params: { username: "user1" }, + }) + .connect(); + + const conn2 = client.connStateWorker + .getOrCreate([], { + params: { username: "user2" }, + }) + .connect(); + + // Update connection state for each connection + await conn1.incrementConnCounter(5); + await conn2.incrementConnCounter(10); + + // Get state for each connection + const state1 = await conn1.getConnectionState(); + const state2 = await conn2.getConnectionState(); + + // Verify states are separate + expect(state1.counter).toBe(5); + expect(state2.counter).toBe(10); + expect(state1.username).toBe("user1"); + expect(state2.username).toBe("user2"); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + + test("should track connections in shared state", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Create two connections + const handle = client.connStateWorker.getOrCreate(); + const conn1 = handle.connect(); + const conn2 = handle.connect(); + + // Get state1 for reference + const state1 = await conn1.getConnectionState(); + + // Get connection IDs tracked by the worker + const connectionIds = await conn1.getConnectionIds(); + + // There should be at least 2 connections tracked + expect(connectionIds.length).toBeGreaterThanOrEqual(2); + + // Should include the ID of the first connection + expect(connectionIds).toContain(state1.id); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + + test("should identify different connections in the same worker", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Create two connections to the same worker + const handle = client.connStateWorker.getOrCreate(); + const conn1 = handle.connect(); + const conn2 = handle.connect(); + + // Get all connection states + const allStates = await conn1.getAllConnectionStates(); + + // Should have at least 2 states + expect(allStates.length).toBeGreaterThanOrEqual(2); + + // IDs should be unique + const ids = allStates.map((state) => state.id); + const uniqueIds = [...new Set(ids)]; + expect(uniqueIds.length).toBe(ids.length); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + }); + + describe("Connection Lifecycle", () => { + test("should track connection and disconnection events", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Create a connection + const handle = client.connStateWorker.getOrCreate(); + const conn = handle.connect(); + + // Get the connection state + const connState = await conn.getConnectionState(); + + // Verify the connection is tracked + const connectionIds = await conn.getConnectionIds(); + expect(connectionIds).toContain(connState.id); + + // Initial disconnection count + const initialDisconnections = await conn.getDisconnectionCount(); + + // Dispose the connection + await conn.dispose(); + + // Create a new connection to check the disconnection count + const newConn = handle.connect(); + + // Verify disconnection was tracked + await vi.waitFor(async () => { + const newDisconnections = await newConn.getDisconnectionCount(); + + expect(newDisconnections).toBeGreaterThan(initialDisconnections); + }); + + // Clean up + await newConn.dispose(); + }); + + test("should update connection state", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Create a connection + const conn = client.connStateWorker.getOrCreate().connect(); + + // Get the initial state + const initialState = await conn.getConnectionState(); + expect(initialState.username).toBe("anonymous"); + + // Update the connection state + const updatedState = await conn.updateConnection({ + username: "newname", + role: "moderator", + }); + + // Verify the state was updated + expect(updatedState.username).toBe("newname"); + expect(updatedState.role).toBe("moderator"); + + // Get the state again to verify persistence + const latestState = await conn.getConnectionState(); + expect(latestState.username).toBe("newname"); + expect(latestState.role).toBe("moderator"); + + // Clean up + await conn.dispose(); + }); + }); + + describe("Connection Communication", () => { + test("should send messages to specific connections", async (c) => { + const { client } = await setupDriverTest( + c, + driverTestConfig, + + ); + + // Create two connections + const handle = client.connStateWorker.getOrCreate(); + const conn1 = handle.connect(); + const conn2 = handle.connect(); + + // Get connection states + const state1 = await conn1.getConnectionState(); + const state2 = await conn2.getConnectionState(); + + // Set up event listener on second connection + const receivedMessages: any[] = []; + conn2.on("directMessage", (data) => { + receivedMessages.push(data); + }); + + // Send message from first connection to second + const success = await conn1.sendToConnection( + state2.id, + "Hello from conn1", + ); + expect(success).toBe(true); + + // Verify message was received + expect(receivedMessages.length).toBe(1); + expect(receivedMessages[0].from).toBe(state1.id); + expect(receivedMessages[0].message).toBe("Hello from conn1"); + + // Clean up + await conn1.dispose(); + await conn2.dispose(); + }); + }); + }); } diff --git a/packages/core/src/driver-test-suite/tests/worker-conn.ts b/packages/core/src/driver-test-suite/tests/worker-conn.ts index d347a9f96..743ccae58 100644 --- a/packages/core/src/driver-test-suite/tests/worker-conn.ts +++ b/packages/core/src/driver-test-suite/tests/worker-conn.ts @@ -1,24 +1,12 @@ -import { describe, test, expect } from "vitest"; +import { describe, test, expect, vi } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { - COUNTER_APP_PATH, - CONN_PARAMS_APP_PATH, - LIFECYCLE_APP_PATH, - type CounterApp, - type ConnParamsApp, - type LifecycleApp, -} from "../test-apps"; export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { describe("Worker Connection Tests", () => { describe("Connection Methods", () => { test("should connect using .get().connect()", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker await client.counter.create(["test-get"]); @@ -36,14 +24,12 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { }); test("should connect using .getForId().connect()", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create a worker first to get its ID - const handle = client.counter.getOrCreate(["test-get-for-id"]); + const handle = client.counter.getOrCreate([ + "test-get-for-id", + ]); await handle.increment(3); const workerId = await handle.resolve(); @@ -60,14 +46,12 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { }); test("should connect using .getOrCreate().connect()", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Get or create worker and connect - const handle = client.counter.getOrCreate(["test-get-or-create"]); + const handle = client.counter.getOrCreate([ + "test-get-or-create", + ]); const connection = handle.connect(); // Verify connection works @@ -79,11 +63,7 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { }); test("should connect using (await create()).connect()", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker and connect const handle = await client.counter.create(["test-create"]); @@ -100,11 +80,7 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { describe("Event Communication", () => { test("should receive events via broadcast", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker and connect const handle = client.counter.getOrCreate(["test-broadcast"]); @@ -129,11 +105,7 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { }); test("should handle one-time events with once()", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker and connect const handle = client.counter.getOrCreate(["test-once"]); @@ -158,14 +130,12 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { }); test("should unsubscribe from events", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker and connect - const handle = client.counter.getOrCreate(["test-unsubscribe"]); + const handle = client.counter.getOrCreate([ + "test-unsubscribe", + ]); const connection = handle.connect(); // Set up event listener with unsubscribe @@ -194,17 +164,13 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { describe("Connection Parameters", () => { test("should pass connection parameters", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - CONN_PARAMS_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create two connections with different params - const handle1 = client.counter.getOrCreate(["test-params"], { + const handle1 = client.counterWithParams.getOrCreate(["test-params"], { params: { name: "user1" }, }); - const handle2 = client.counter.getOrCreate(["test-params"], { + const handle2 = client.counterWithParams.getOrCreate(["test-params"], { params: { name: "user2" }, }); @@ -230,38 +196,37 @@ export function runWorkerConnTests(driverTestConfig: DriverTestConfig) { describe("Lifecycle Hooks", () => { test("should trigger lifecycle hooks", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - LIFECYCLE_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); - const handle = client.counter.getOrCreate(["test-lifecycle"]); + const handle = client.counterWithLifecycle.getOrCreate(["test-lifecycle"]); // Create and connect - const connHandle = client.counter.getOrCreate(["test-lifecycle"], { - params: { trackLifecycle: true }, - }); + const connHandle = client.counterWithLifecycle.getOrCreate( + ["test-lifecycle"], + { + params: { trackLifecycle: true }, + }, + ); const connection = connHandle.connect(); // Verify lifecycle events were triggered const events = await connection.getEvents(); - - // Check lifecycle hooks were called in the correct order - expect(events).toContain("onStart"); - expect(events).toContain("onBeforeConnect"); - expect(events).toContain("onConnect"); + expect(events).toEqual(["onStart", "onBeforeConnect", "onConnect"]); // Disconnect should trigger onDisconnect await connection.dispose(); // Reconnect to check if onDisconnect was called const newConnection = handle.connect(); - + //await vi.waitFor(async () => { const finalEvents = await newConnection.getEvents(); - expect(finalEvents).toContain("onDisconnect"); - - // Clean up + expect(finalEvents).toEqual([ + "onStart", + "onBeforeConnect", + "onConnect", + "onDisconnect", + ]); + //}); await newConnection.dispose(); }); }); diff --git a/packages/core/src/driver-test-suite/tests/worker-error-handling.ts b/packages/core/src/driver-test-suite/tests/worker-error-handling.ts index 9a40f3f7a..bf4753c2c 100644 --- a/packages/core/src/driver-test-suite/tests/worker-error-handling.ts +++ b/packages/core/src/driver-test-suite/tests/worker-error-handling.ts @@ -1,16 +1,22 @@ import { describe, test, expect } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { ERROR_HANDLING_APP_PATH, type ErrorHandlingApp } from "../test-apps"; - -export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) { +import { assertUnreachable } from "@/worker/utils"; +import { + INTERNAL_ERROR_CODE, + INTERNAL_ERROR_DESCRIPTION, +} from "@/worker/errors"; + +export function runWorkerErrorHandlingTests( + driverTestConfig: DriverTestConfig, +) { describe("Worker Error Handling Tests", () => { describe("UserError Handling", () => { test("should handle simple UserError with message", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); // Try to call an action that throws a simple UserError @@ -31,10 +37,10 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) }); test("should handle detailed UserError with code and metadata", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); // Try to call an action that throws a detailed UserError @@ -57,10 +63,10 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) describe("Internal Error Handling", () => { test("should convert internal errors to safe format", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); // Try to call an action that throws an internal error @@ -71,10 +77,18 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) // If we get here, the test should fail expect(true).toBe(false); // This should not be reached } catch (error: any) { - // Verify the error is converted to a safe format - expect(error.code).toBe("internal_error"); - // Original error details should not be exposed - expect(error.message).not.toBe("This is an internal error"); + if (driverTestConfig.clientType === "http") { + // Verify the error is converted to a safe format + expect(error.code).toBe(INTERNAL_ERROR_CODE); + // Original error details should not be exposed + expect(error.message).toBe(INTERNAL_ERROR_DESCRIPTION); + } else if (driverTestConfig.clientType === "inline") { + // Verify that original error is preserved + expect(error.code).toBe(INTERNAL_ERROR_CODE); + expect(error.message).toBe("This is an internal error"); + } else { + assertUnreachable(driverTestConfig.clientType); + } } }); }); @@ -82,10 +96,10 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) // TODO: Does not work with fake timers describe.skip("Action Timeout", () => { test("should handle action timeouts with custom duration", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); // Call an action that should time out @@ -106,10 +120,10 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) }); test("should successfully run actions within timeout", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); // Call an action with a delay shorter than the timeout @@ -121,10 +135,10 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) }); test("should respect different timeouts for different workers", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); // The following workers have different timeout settings: @@ -150,10 +164,10 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) describe("Error Recovery", () => { test("should continue working after errors", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - ERROR_HANDLING_APP_PATH, + ); const handle = client.errorHandlingWorker.getOrCreate(); @@ -172,4 +186,3 @@ export function runWorkerErrorHandlingTests(driverTestConfig: DriverTestConfig) }); }); } - diff --git a/packages/core/src/driver-test-suite/tests/worker-handle.ts b/packages/core/src/driver-test-suite/tests/worker-handle.ts index aadc339d2..61595e24c 100644 --- a/packages/core/src/driver-test-suite/tests/worker-handle.ts +++ b/packages/core/src/driver-test-suite/tests/worker-handle.ts @@ -1,22 +1,12 @@ import { describe, test, expect, vi } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest, waitFor } from "../utils"; -import { - COUNTER_APP_PATH, - LIFECYCLE_APP_PATH, - type CounterApp, - type LifecycleApp, -} from "../test-apps"; export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { describe("Worker Handle Tests", () => { describe("Access Methods", () => { test("should use .get() to access a worker", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker first await client.counter.create(["test-get-handle"]); @@ -33,11 +23,7 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { }); test("should use .getForId() to access a worker by ID", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create a worker first to get its ID const handle = client.counter.getOrCreate(["test-get-for-id-handle"]); @@ -56,11 +42,7 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { }); test("should use .getOrCreate() to access or create a worker", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Access using getOrCreate - should create the worker const handle = client.counter.getOrCreate([ @@ -80,11 +62,7 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { }); test("should use (await create()) to create and return a handle", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create worker and get handle const handle = await client.counter.create(["test-create-handle"]); @@ -100,11 +78,7 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { describe("Action Functionality", () => { test("should call actions directly on the handle", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); const handle = client.counter.getOrCreate(["test-action-handle"]); @@ -120,11 +94,7 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { }); test("should handle independent handles to the same worker", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create two handles to the same worker const handle1 = client.counter.getOrCreate(["test-multiple-handles"]); @@ -145,11 +115,7 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { }); test("should resolve a worker's ID", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - COUNTER_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); const handle = client.counter.getOrCreate(["test-resolve-id"]); @@ -172,21 +138,19 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { describe("Lifecycle Hooks", () => { test("should trigger lifecycle hooks on worker creation", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - LIFECYCLE_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Get or create a new worker - this should trigger onStart - const handle = client.counter.getOrCreate(["test-lifecycle-handle"]); + const handle = client.counterWithLifecycle.getOrCreate([ + "test-lifecycle-handle", + ]); // Verify onStart was triggered const initialEvents = await handle.getEvents(); expect(initialEvents).toContain("onStart"); // Create a separate handle to the same worker - const sameHandle = client.counter.getOrCreate([ + const sameHandle = client.counterWithLifecycle.getOrCreate([ "test-lifecycle-handle", ]); @@ -198,15 +162,13 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { }); test("should trigger lifecycle hooks for each Action call", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - LIFECYCLE_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create a normal handle to view events - const viewHandle = client.counter.getOrCreate(["test-lifecycle-action"]); - + const viewHandle = client.counterWithLifecycle.getOrCreate([ + "test-lifecycle-action", + ]); + // Initial state should only have onStart const initialEvents = await viewHandle.getEvents(); expect(initialEvents).toContain("onStart"); @@ -215,72 +177,82 @@ export function runWorkerHandleTests(driverTestConfig: DriverTestConfig) { expect(initialEvents).not.toContain("onDisconnect"); // Create a handle with trackLifecycle enabled for testing Action calls - const trackingHandle = client.counter.getOrCreate( + const trackingHandle = client.counterWithLifecycle.getOrCreate( ["test-lifecycle-action"], - { params: { trackLifecycle: true } } + { params: { trackLifecycle: true } }, ); - + // Make an Action call await trackingHandle.increment(5); - + // Check that it triggered the lifecycle hooks const eventsAfterAction = await viewHandle.getEvents(); - + // Should have onBeforeConnect, onConnect, and onDisconnect for the Action call expect(eventsAfterAction).toContain("onBeforeConnect"); expect(eventsAfterAction).toContain("onConnect"); expect(eventsAfterAction).toContain("onDisconnect"); - + // Each should have count 1 - expect(eventsAfterAction.filter(e => e === "onBeforeConnect").length).toBe(1); - expect(eventsAfterAction.filter(e => e === "onConnect").length).toBe(1); - expect(eventsAfterAction.filter(e => e === "onDisconnect").length).toBe(1); - + expect( + eventsAfterAction.filter((e) => e === "onBeforeConnect").length, + ).toBe(1); + expect(eventsAfterAction.filter((e) => e === "onConnect").length).toBe( + 1, + ); + expect( + eventsAfterAction.filter((e) => e === "onDisconnect").length, + ).toBe(1); + // Make another Action call await trackingHandle.increment(10); - + // Check that it triggered another set of lifecycle hooks const eventsAfterSecondAction = await viewHandle.getEvents(); - + // Each hook should now have count 2 - expect(eventsAfterSecondAction.filter(e => e === "onBeforeConnect").length).toBe(2); - expect(eventsAfterSecondAction.filter(e => e === "onConnect").length).toBe(2); - expect(eventsAfterSecondAction.filter(e => e === "onDisconnect").length).toBe(2); + expect( + eventsAfterSecondAction.filter((e) => e === "onBeforeConnect").length, + ).toBe(2); + expect( + eventsAfterSecondAction.filter((e) => e === "onConnect").length, + ).toBe(2); + expect( + eventsAfterSecondAction.filter((e) => e === "onDisconnect").length, + ).toBe(2); }); test("should trigger lifecycle hooks for each Action call across multiple handles", async (c) => { - const { client } = await setupDriverTest( - c, - driverTestConfig, - LIFECYCLE_APP_PATH, - ); + const { client } = await setupDriverTest(c, driverTestConfig); // Create a normal handle to view events - const viewHandle = client.counter.getOrCreate(["test-lifecycle-multi-handle"]); - + const viewHandle = client.counterWithLifecycle.getOrCreate([ + "test-lifecycle-multi-handle", + ]); + // Create two tracking handles to the same worker - const trackingHandle1 = client.counter.getOrCreate( + const trackingHandle1 = client.counterWithLifecycle.getOrCreate( ["test-lifecycle-multi-handle"], - { params: { trackLifecycle: true } } + { params: { trackLifecycle: true } }, ); - - const trackingHandle2 = client.counter.getOrCreate( + + const trackingHandle2 = client.counterWithLifecycle.getOrCreate( ["test-lifecycle-multi-handle"], - { params: { trackLifecycle: true } } + { params: { trackLifecycle: true } }, ); - + // Make Action calls on both handles await trackingHandle1.increment(5); await trackingHandle2.increment(10); - + // Check lifecycle hooks const events = await viewHandle.getEvents(); - + // Should have 1 onStart, 2 each of onBeforeConnect, onConnect, and onDisconnect - expect(events.filter(e => e === "onStart").length).toBe(1); - expect(events.filter(e => e === "onBeforeConnect").length).toBe(2); - expect(events.filter(e => e === "onConnect").length).toBe(2); - expect(events.filter(e => e === "onDisconnect").length).toBe(2); + expect(events.filter((e) => e === "onStart").length).toBe(1); + expect(events.filter((e) => e === "onBeforeConnect").length).toBe(2); + expect(events.filter((e) => e === "onConnect").length).toBe(2); + expect(events.filter((e) => e === "onDisconnect").length).toBe(2); }); }); }); diff --git a/packages/core/src/driver-test-suite/tests/worker-metadata.ts b/packages/core/src/driver-test-suite/tests/worker-metadata.ts index ebf005eb9..7b620ce73 100644 --- a/packages/core/src/driver-test-suite/tests/worker-metadata.ts +++ b/packages/core/src/driver-test-suite/tests/worker-metadata.ts @@ -1,10 +1,6 @@ import { describe, test, expect } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { - METADATA_APP_PATH, - type MetadataApp, -} from "../test-apps"; export function runWorkerMetadataTests( driverTestConfig: DriverTestConfig @@ -12,10 +8,10 @@ export function runWorkerMetadataTests( describe("Worker Metadata Tests", () => { describe("Worker Name", () => { test("should provide access to worker name", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - METADATA_APP_PATH, + ); // Get the worker name @@ -27,10 +23,10 @@ export function runWorkerMetadataTests( }); test("should preserve worker name in state during onStart", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - METADATA_APP_PATH, + ); // Get the stored worker name @@ -44,10 +40,10 @@ export function runWorkerMetadataTests( describe("Worker Tags", () => { test("should provide access to tags", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - METADATA_APP_PATH, + ); // Create worker and set up test tags @@ -68,10 +64,10 @@ export function runWorkerMetadataTests( }); test("should allow accessing individual tags", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - METADATA_APP_PATH, + ); // Create worker and set up test tags @@ -95,10 +91,10 @@ export function runWorkerMetadataTests( describe("Metadata Structure", () => { test("should provide complete metadata object", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - METADATA_APP_PATH, + ); // Create worker and set up test metadata @@ -125,10 +121,10 @@ export function runWorkerMetadataTests( describe("Region Information", () => { test("should retrieve region information", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - METADATA_APP_PATH, + ); // Create worker and set up test region @@ -143,4 +139,4 @@ export function runWorkerMetadataTests( }); }); }); -} \ No newline at end of file +} diff --git a/packages/core/src/driver-test-suite/tests/worker-schedule.ts b/packages/core/src/driver-test-suite/tests/worker-schedule.ts index 1c9ec2b22..49e3b2852 100644 --- a/packages/core/src/driver-test-suite/tests/worker-schedule.ts +++ b/packages/core/src/driver-test-suite/tests/worker-schedule.ts @@ -1,10 +1,6 @@ import { describe, test, expect } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest, waitFor } from "../utils"; -import { - SCHEDULED_APP_PATH, - type ScheduledApp, -} from "../test-apps"; export function runWorkerScheduleTests( driverTestConfig: DriverTestConfig @@ -12,10 +8,10 @@ export function runWorkerScheduleTests( describe("Worker Schedule Tests", () => { describe("Scheduled Alarms", () => { test("executes c.schedule.at() with specific timestamp", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - SCHEDULED_APP_PATH, + ); // Create instance @@ -37,10 +33,10 @@ export function runWorkerScheduleTests( }); test("executes c.schedule.after() with delay", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - SCHEDULED_APP_PATH, + ); // Create instance @@ -61,10 +57,10 @@ export function runWorkerScheduleTests( }); test("scheduled tasks persist across worker restarts", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - SCHEDULED_APP_PATH, + ); // Create instance and schedule @@ -90,10 +86,10 @@ export function runWorkerScheduleTests( }); test("multiple scheduled tasks execute in order", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - SCHEDULED_APP_PATH, + ); // Create instance @@ -124,4 +120,4 @@ export function runWorkerScheduleTests( }); }); }); -} \ No newline at end of file +} diff --git a/packages/core/src/driver-test-suite/tests/worker-state.ts b/packages/core/src/driver-test-suite/tests/worker-state.ts index a6ec99b20..abc6c59fc 100644 --- a/packages/core/src/driver-test-suite/tests/worker-state.ts +++ b/packages/core/src/driver-test-suite/tests/worker-state.ts @@ -1,10 +1,6 @@ import { describe, test, expect } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { - COUNTER_APP_PATH, - type CounterApp, -} from "../test-apps"; export function runWorkerStateTests( driverTestConfig: DriverTestConfig @@ -12,10 +8,10 @@ export function runWorkerStateTests( describe("Worker State Tests", () => { describe("State Persistence", () => { test("persists state between worker instances", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create instance and increment @@ -30,10 +26,10 @@ export function runWorkerStateTests( }); test("restores state after worker disconnect/reconnect", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create worker and set initial state @@ -47,10 +43,10 @@ export function runWorkerStateTests( }); test("maintains separate state for different workers", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - COUNTER_APP_PATH, + ); // Create first counter with specific key @@ -69,4 +65,4 @@ export function runWorkerStateTests( }); }); }); -} \ No newline at end of file +} diff --git a/packages/core/src/driver-test-suite/tests/worker-vars.ts b/packages/core/src/driver-test-suite/tests/worker-vars.ts index abfd2a027..95910e757 100644 --- a/packages/core/src/driver-test-suite/tests/worker-vars.ts +++ b/packages/core/src/driver-test-suite/tests/worker-vars.ts @@ -1,16 +1,15 @@ import { describe, test, expect } from "vitest"; import type { DriverTestConfig } from "../mod"; import { setupDriverTest } from "../utils"; -import { VARS_APP_PATH, type VarsApp } from "../test-apps"; export function runWorkerVarsTests(driverTestConfig: DriverTestConfig) { describe("Worker Variables", () => { describe("Static vars", () => { test("should provide access to static vars", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - VARS_APP_PATH, + ); const instance = client.staticVarWorker.getOrCreate(); @@ -27,10 +26,10 @@ export function runWorkerVarsTests(driverTestConfig: DriverTestConfig) { describe("Deep cloning of static vars", () => { test("should deep clone static vars between worker instances", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - VARS_APP_PATH, + ); // Create two separate instances @@ -53,10 +52,10 @@ export function runWorkerVarsTests(driverTestConfig: DriverTestConfig) { describe("createVars", () => { test("should support dynamic vars creation", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - VARS_APP_PATH, + ); // Create an instance @@ -72,10 +71,10 @@ export function runWorkerVarsTests(driverTestConfig: DriverTestConfig) { }); test("should create different vars for different instances", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - VARS_APP_PATH, + ); // Create two separate instances @@ -93,10 +92,10 @@ export function runWorkerVarsTests(driverTestConfig: DriverTestConfig) { describe("Driver Context", () => { test("should provide access to driver context", async (c) => { - const { client } = await setupDriverTest( + const { client } = await setupDriverTest( c, driverTestConfig, - VARS_APP_PATH, + ); // Create an instance @@ -111,4 +110,4 @@ export function runWorkerVarsTests(driverTestConfig: DriverTestConfig) { }); }); }); -} \ No newline at end of file +} diff --git a/packages/core/src/driver-test-suite/utils.ts b/packages/core/src/driver-test-suite/utils.ts index c5461609c..5b7ba1c52 100644 --- a/packages/core/src/driver-test-suite/utils.ts +++ b/packages/core/src/driver-test-suite/utils.ts @@ -1,37 +1,41 @@ -import type { WorkerCoreApp } from "@/mod"; import { type TestContext, vi } from "vitest"; import { createClient, type Client } from "@/client/mod"; import type { DriverTestConfig } from "./mod"; import { assertUnreachable } from "@/worker/utils"; -import { createInlineClientDriver } from "@/app/inline-client-driver"; import { createClientWithDriver } from "@/client/client"; +import { createTestInlineClientDriver } from "./test-inline-client-driver"; +import { resolve } from "node:path"; +import type { App } from "../../fixtures/driver-test-suite/app"; // Must use `TestContext` since global hooks do not work when running concurrently -export async function setupDriverTest>( +export async function setupDriverTest( c: TestContext, driverTestConfig: DriverTestConfig, - appPath: string, ): Promise<{ - client: Client; + client: Client; }> { if (!driverTestConfig.useRealTimers) { vi.useFakeTimers(); } // Build drivers - const { endpoint, inlineClientDriver, cleanup } = - await driverTestConfig.start(appPath); + const projectPath = resolve(__dirname, "../../fixtures/driver-test-suite"); + const { endpoint, cleanup } = await driverTestConfig.start(projectPath); c.onTestFinished(cleanup); - let client: Client; + let client: Client; if (driverTestConfig.clientType === "http") { // Create client - client = createClient(endpoint, { + client = createClient(endpoint, { transport: driverTestConfig.transport, }); } else if (driverTestConfig.clientType === "inline") { // Use inline client from driver - client = createClientWithDriver(inlineClientDriver); + const clientDriver = createTestInlineClientDriver( + endpoint, + driverTestConfig.transport ?? "websocket", + ); + client = createClientWithDriver(clientDriver); } else { assertUnreachable(driverTestConfig.clientType); } diff --git a/packages/core/src/manager/driver.ts b/packages/core/src/manager/driver.ts index 36897954e..cbc63fae6 100644 --- a/packages/core/src/manager/driver.ts +++ b/packages/core/src/manager/driver.ts @@ -1,3 +1,4 @@ +import { ClientDriver } from "@/client/client"; import type { WorkerKey } from "@/common/utils"; import type { ManagerInspector } from "@/inspector/manager"; import type { Env, Context as HonoContext, HonoRequest } from "hono"; @@ -11,18 +12,18 @@ export interface ManagerDriver { inspector?: ManagerInspector; } export interface GetForIdInput { - req?: HonoRequest | undefined; + c?: HonoContext | undefined; workerId: string; } export interface GetWithKeyInput { - req?: HonoRequest | undefined; + c?: HonoContext | undefined; name: string; key: WorkerKey; } export interface GetOrCreateWithKeyInput { - req?: HonoRequest | undefined; + c?: HonoContext | undefined; name: string; key: WorkerKey; input?: unknown; @@ -30,7 +31,7 @@ export interface GetOrCreateWithKeyInput { } export interface CreateInput { - req?: HonoRequest | undefined; + c?: HonoContext | undefined; name: string; key: WorkerKey; input?: unknown; diff --git a/packages/core/src/manager/router.ts b/packages/core/src/manager/router.ts index d9c9c78c2..347a42f48 100644 --- a/packages/core/src/manager/router.ts +++ b/packages/core/src/manager/router.ts @@ -1,7 +1,12 @@ import * as errors from "@/worker/errors"; +import * as cbor from "cbor-x"; import type * as protoHttpResolve from "@/worker/protocol/http/resolve"; import type { ToClient } from "@/worker/protocol/message/to-client"; -import { type Encoding, serialize } from "@/worker/protocol/serde"; +import { + type Encoding, + EncodingSchema, + serialize, +} from "@/worker/protocol/serde"; import { type ConnectionHandlers, getRequestEncoding, @@ -25,7 +30,7 @@ import { handleRouteNotFound, loggerMiddleware, } from "@/common/router"; -import { deconstructError } from "@/common/utils"; +import { DeconstructedError, deconstructError } from "@/common/utils"; import type { DriverConfig } from "@/driver-helpers/config"; import { type ManagerInspectorConnHandler, @@ -50,6 +55,8 @@ import { import type { WorkerQuery } from "./protocol/query"; import { VERSION } from "@/utils"; import { ConnRoutingHandler } from "@/worker/conn-routing-handler"; +import { ClientDriver, createClientWithDriver } from "@/client/client"; +import { Transport, TransportSchema } from "@/worker/protocol/message/mod"; type ManagerRouterHandler = { onConnectInspector?: ManagerInspectorConnHandler; @@ -105,6 +112,7 @@ function buildOpenApiResponses(schema: T) { export function createManagerRouter( appConfig: AppConfig, driverConfig: DriverConfig, + inlineClientDriver: ClientDriver, handler: ManagerRouterHandler, ) { if (!driverConfig.drivers?.manager) { @@ -354,6 +362,188 @@ export function createManagerRouter( ); } + if (appConfig.test.enabled) { + // Add HTTP endpoint to test the inline client + // + // We have to do this in a router since this needs to run in the same server as the RivetKit app. Some test contexts to not run in the same server. + app.post(".test/inline-driver/call", async (c) => { + // TODO: use openapi instead + const buffer = await c.req.arrayBuffer(); + const { encoding, transport, method, args }: TestInlineDriverCallRequest = + cbor.decode(new Uint8Array(buffer)); + + logger().info("received inline request", { + encoding, + transport, + method, + args, + }); + + // Forward inline driver request + let response: TestInlineDriverCallResponse; + try { + const output = await ((inlineClientDriver as any)[method] as any)( + ...args, + ); + response = { ok: output }; + } catch (rawErr) { + const err = deconstructError(rawErr, logger(), {}, true); + response = { err }; + } + + return c.body(cbor.encode(response)); + }); + + if (upgradeWebSocket) { + app.get( + ".test/inline-driver/connect-websocket", + upgradeWebSocket(async (c) => { + const { + workerQuery: workerQueryRaw, + params: paramsRaw, + encodingKind, + } = c.req.query() as { + workerQuery: string; + params?: string; + encodingKind: Encoding; + }; + const workerQuery = JSON.parse(workerQueryRaw); + const params = + paramsRaw !== undefined ? JSON.parse(paramsRaw) : undefined; + + logger().debug("received test inline driver websocket", { + workerQuery, + params, + encodingKind, + }); + + // Connect to the worker using the inline client driver - this returns a Promise + const clientWsPromise = inlineClientDriver.connectWebSocket( + undefined, + workerQuery, + encodingKind, + params, + ); + + // Store a reference to the resolved WebSocket + let clientWs: WebSocket | null = null; + + // Create WebSocket proxy handlers to relay messages between client and server + return { + onOpen: async (_evt: any, serverWs: WSContext) => { + logger().debug("test websocket connection opened"); + + try { + // Resolve the client WebSocket promise + clientWs = await clientWsPromise; + + // Add message handler to forward messages from client to server + clientWs.onmessage = (clientEvt: MessageEvent) => { + logger().debug("test websocket connection message"); + + if (serverWs.readyState === 1) { + // OPEN + serverWs.send(clientEvt.data); + } + }; + + // Add close handler to close server when client closes + clientWs.onclose = (clientEvt: CloseEvent) => { + logger().debug("test websocket connection closed"); + + if (serverWs.readyState !== 3) { + // Not CLOSED + serverWs.close(clientEvt.code, clientEvt.reason); + } + }; + + // Add error handler + clientWs.onerror = () => { + logger().debug("test websocket connection error"); + + if (serverWs.readyState !== 3) { + // Not CLOSED + serverWs.close(1011, "Error in client websocket"); + } + }; + } catch (error) { + logger().error( + "failed to establish client websocket connection", + { error }, + ); + serverWs.close(1011, "Failed to establish connection"); + } + }, + onMessage: async (evt: { data: any }, serverWs: WSContext) => { + // If clientWs hasn't been resolved yet, messages will be lost + if (!clientWs) { + logger().debug( + "received server message before client WebSocket connected", + ); + return; + } + + logger().debug("received message from server", { + dataType: typeof evt.data, + }); + + // Forward messages from server websocket to client websocket + if (clientWs.readyState === 1) { + // OPEN + clientWs.send(evt.data); + } + }, + onClose: async ( + event: { + wasClean: boolean; + code: number; + reason: string; + }, + serverWs: WSContext, + ) => { + logger().debug("server websocket closed", { + wasClean: event.wasClean, + code: event.code, + reason: event.reason, + }); + + // HACK: Close socket in order to fix bug with Cloudflare leaving WS in closing state + // https://github.com/cloudflare/workerd/issues/2569 + serverWs.close(1000, "hack_force_close"); + + // Close the client websocket when the server websocket closes + if ( + clientWs && + clientWs.readyState !== clientWs.CLOSED && + clientWs.readyState !== clientWs.CLOSING + ) { + clientWs.close(event.code, event.reason); + } + }, + onError: async (error: unknown) => { + logger().error("error in server websocket", { error }); + + // Close the client websocket on error + if ( + clientWs && + clientWs.readyState !== clientWs.CLOSED && + clientWs.readyState !== clientWs.CLOSING + ) { + clientWs.close(1011, "Error in server websocket"); + } + }, + }; + }), + ); + } else { + app.get(".test/inline-driver/connect-websocket", (c) => { + throw new Error( + "websocket unsupported, fix the test to exclude websockets for this platform", + ); + }); + } + } + app.doc("/openapi.json", { openapi: "3.0.0", info: { @@ -363,11 +553,26 @@ export function createManagerRouter( }); app.notFound(handleRouteNotFound); - app.onError(handleRouteError); + app.onError(handleRouteError.bind(undefined, {})); return app as unknown as Hono; } +export interface TestInlineDriverCallRequest { + encoding: Encoding; + transport: Transport; + method: string; + args: unknown[]; +} + +export type TestInlineDriverCallResponse = + | { + ok: T; + } + | { + err: DeconstructedError; + }; + /** * Query the manager driver to get or create a worker based on the provided query */ @@ -380,14 +585,14 @@ export async function queryWorker( let workerOutput: { workerId: string; meta?: unknown }; if ("getForId" in query) { const output = await driver.getForId({ - req: c.req, + c, workerId: query.getForId.workerId, }); if (!output) throw new errors.WorkerNotFound(query.getForId.workerId); workerOutput = output; } else if ("getForKey" in query) { const existingWorker = await driver.getWithKey({ - req: c.req, + c, name: query.getForKey.name, key: query.getForKey.key, }); @@ -399,7 +604,7 @@ export async function queryWorker( workerOutput = existingWorker; } else if ("getOrCreateForKey" in query) { const getOrCreateOutput = await driver.getOrCreateWithKey({ - req: c.req, + c, name: query.getOrCreateForKey.name, key: query.getOrCreateForKey.key, input: query.getOrCreateForKey.input, @@ -411,7 +616,7 @@ export async function queryWorker( }; } else if ("create" in query) { const createOutput = await driver.createWorker({ - req: c.req, + c, name: query.create.name, key: query.create.key, input: query.create.input, @@ -450,7 +655,7 @@ async function handleSseConnectRequest( const params = ConnectRequestSchema.safeParse({ query: getRequestQuery(c, false), encoding: c.req.header(HEADER_ENCODING), - params: c.req.header(HEADER_CONN_PARAMS), + connParams: c.req.header(HEADER_CONN_PARAMS), }); if (!params.success) { @@ -481,7 +686,9 @@ async function handleSseConnectRequest( } else if ("custom" in handler.routingHandler) { logger().debug("using custom proxy mode for sse connection"); const url = new URL("http://worker/connect/sse"); - const proxyRequest = new Request(url, c.req.raw); + + // Always build fresh request to prevent forwarding unwanted headers + const proxyRequest = new Request(url); proxyRequest.headers.set(HEADER_ENCODING, params.data.encoding); if (params.data.connParams) { proxyRequest.headers.set(HEADER_CONN_PARAMS, params.data.connParams); @@ -608,11 +815,18 @@ async function handleWebSocketConnectRequest( })(c, noopNext()); } else if ("custom" in handler.routingHandler) { logger().debug("using custom proxy mode for websocket connection"); + + // Proxy the WebSocket connection to the worker + // The proxyWebSocket handler will: + // 1. Validate the WebSocket upgrade request + // 2. Forward the request to the worker with the appropriate path + // 3. Handle the WebSocket pair and proxy messages between client and worker return await handler.routingHandler.custom.proxyWebSocket( c, `/connect/websocket?encoding=${params.data.encoding}`, workerId, meta, + upgradeWebSocket, ); } else { assertUnreachable(handler.routingHandler); @@ -700,9 +914,13 @@ async function handleMessageRequest( ); } else if ("custom" in handler.routingHandler) { logger().debug("using custom proxy mode for connection message"); - const url = new URL(`http://worker/connections/message`); + const url = new URL("http://worker/connections/message"); - const proxyRequest = new Request(url, c.req.raw); + // Always build fresh request to prevent forwarding unwanted headers + const proxyRequest = new Request(url, { + method: "POST", + body: c.req.raw.body, + }); proxyRequest.headers.set(HEADER_ENCODING, encoding); proxyRequest.headers.set(HEADER_CONN_ID, connId); proxyRequest.headers.set(HEADER_CONN_TOKEN, connToken); @@ -744,7 +962,7 @@ async function handleActionRequest( const params = ConnectRequestSchema.safeParse({ query: getRequestQuery(c, false), encoding: c.req.header(HEADER_ENCODING), - params: c.req.header(HEADER_CONN_PARAMS), + connParams: c.req.header(HEADER_CONN_PARAMS), }); if (!params.success) { @@ -774,12 +992,19 @@ async function handleActionRequest( } else if ("custom" in handler.routingHandler) { logger().debug("using custom proxy mode for action call"); - // TODO: Encoding - // TODO: Parameters const url = new URL( `http://worker/action/${encodeURIComponent(actionName)}`, ); - const proxyRequest = new Request(url, c.req.raw); + + // Always build fresh request to prevent forwarding unwanted headers + const proxyRequest = new Request(url, { + method: "POST", + body: c.req.raw.body, + }); + proxyRequest.headers.set(HEADER_ENCODING, params.data.encoding); + if (params.data.connParams) + proxyRequest.headers.set(HEADER_CONN_PARAMS, params.data.connParams); + return await handler.routingHandler.custom.proxyRequest( c, proxyRequest, diff --git a/packages/core/src/topologies/coordinate/topology.ts b/packages/core/src/topologies/coordinate/topology.ts index 42f0e57bc..3b7d1af9c 100644 --- a/packages/core/src/topologies/coordinate/topology.ts +++ b/packages/core/src/topologies/coordinate/topology.ts @@ -129,18 +129,20 @@ export class CoordinateTopology { this.clientDriver = createInlineClientDriver(managerDriver, routingHandler); // Build manager router - const managerRouter = createManagerRouter(appConfig, driverConfig, { - routingHandler, - onConnectInspector: () => { - throw new errors.Unsupported("inspect"); + const managerRouter = createManagerRouter( + appConfig, + driverConfig, + this.clientDriver, + { + routingHandler, + onConnectInspector: () => { + throw new errors.Unsupported("inspect"); + }, }, - }); + ); app.route("/", managerRouter); - app.notFound(handleRouteNotFound); - app.onError(handleRouteError); - this.router = app; } } diff --git a/packages/core/src/topologies/partition/log.ts b/packages/core/src/topologies/partition/log.ts index 46c3d4dfe..b35019b8f 100644 --- a/packages/core/src/topologies/partition/log.ts +++ b/packages/core/src/topologies/partition/log.ts @@ -1,6 +1,6 @@ import { getLogger } from "@/common//log"; -export const LOGGER_NAME = "worker-standalone"; +export const LOGGER_NAME = "worker-partition"; export function logger() { return getLogger(LOGGER_NAME); diff --git a/packages/core/src/topologies/partition/topology.ts b/packages/core/src/topologies/partition/topology.ts index 2365ca423..009d3ea0d 100644 --- a/packages/core/src/topologies/partition/topology.ts +++ b/packages/core/src/topologies/partition/topology.ts @@ -76,33 +76,38 @@ export class PartitionTopologyManager { invariant(managerDriver, "missing manager driver"); this.clientDriver = createInlineClientDriver(managerDriver, routingHandler); - this.router = createManagerRouter(appConfig, driverConfig, { - routingHandler, - onConnectInspector: async () => { - const inspector = driverConfig.drivers?.manager?.inspector; - if (!inspector) throw new errors.Unsupported("inspector"); - - let conn: ManagerInspectorConnection | undefined; - return { - onOpen: async (ws) => { - conn = inspector.createConnection(ws); - }, - onMessage: async (message) => { - if (!conn) { - logger().warn("`conn` does not exist"); - return; - } + this.router = createManagerRouter( + appConfig, + driverConfig, + this.clientDriver, + { + routingHandler, + onConnectInspector: async () => { + const inspector = driverConfig.drivers?.manager?.inspector; + if (!inspector) throw new errors.Unsupported("inspector"); + + let conn: ManagerInspectorConnection | undefined; + return { + onOpen: async (ws) => { + conn = inspector.createConnection(ws); + }, + onMessage: async (message) => { + if (!conn) { + logger().warn("`conn` does not exist"); + return; + } - inspector.processMessage(conn, message); - }, - onClose: async () => { - if (conn) { - inspector.removeConnection(conn); - } - }, - }; + inspector.processMessage(conn, message); + }, + onClose: async () => { + if (conn) { + inspector.removeConnection(conn); + } + }, + }; + }, }, - }); + ); } } @@ -135,7 +140,8 @@ export class PartitionTopologyWorker { // TODO: Store this worker router globally so we're not re-initializing it for every DO this.router = createWorkerRouter(appConfig, driverConfig, { getWorkerId: async () => { - if (this.#workerStartedPromise) await this.#workerStartedPromise.promise; + if (this.#workerStartedPromise) + await this.#workerStartedPromise.promise; return this.worker.id; }, connectionHandlers: { @@ -150,7 +156,10 @@ export class PartitionTopologyWorker { const connId = generateConnId(); const connToken = generateConnToken(); - const connState = await worker.prepareConn(opts.params, opts.req?.raw); + const connState = await worker.prepareConn( + opts.params, + opts.req?.raw, + ); let conn: AnyConn | undefined; return { @@ -200,7 +209,10 @@ export class PartitionTopologyWorker { const connId = generateConnId(); const connToken = generateConnToken(); - const connState = await worker.prepareConn(opts.params, opts.req?.raw); + const connState = await worker.prepareConn( + opts.params, + opts.req?.raw, + ); let conn: AnyConn | undefined; return { @@ -290,7 +302,8 @@ export class PartitionTopologyWorker { }, }, onConnectInspector: async () => { - if (this.#workerStartedPromise) await this.#workerStartedPromise.promise; + if (this.#workerStartedPromise) + await this.#workerStartedPromise.promise; const worker = this.#worker; if (!worker) throw new Error("Worker should be defined"); diff --git a/packages/core/src/topologies/partition/worker-router.ts b/packages/core/src/topologies/partition/worker-router.ts index 501f1b98e..cb46cdce4 100644 --- a/packages/core/src/topologies/partition/worker-router.ts +++ b/packages/core/src/topologies/partition/worker-router.ts @@ -182,7 +182,12 @@ export function createWorkerRouter( } app.notFound(handleRouteNotFound); - app.onError(handleRouteError); + app.onError( + handleRouteError.bind(undefined, { + // All headers to this endpoint are considered secure, so we can enable the expose internal error header for requests from the internal client + enableExposeInternalError: true, + }), + ); return app; } diff --git a/packages/core/src/topologies/standalone/topology.ts b/packages/core/src/topologies/standalone/topology.ts index f0b856f26..f4a9498cc 100644 --- a/packages/core/src/topologies/standalone/topology.ts +++ b/packages/core/src/topologies/standalone/topology.ts @@ -271,7 +271,7 @@ export class StandaloneTopology { this.clientDriver = createInlineClientDriver(managerDriver, routingHandler); // Build manager router - const managerRouter = createManagerRouter(appConfig, driverConfig, { + const managerRouter = createManagerRouter(appConfig, driverConfig, this.clientDriver, { routingHandler, onConnectInspector: async () => { const inspector = driverConfig.drivers?.manager?.inspector; diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index 4fd61cbce..1c7fa7126 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -1,5 +1,6 @@ export { assertUnreachable } from "./common/utils"; export { stringifyError } from "@/common/utils"; +import { Context as HonoContext, Handler as HonoHandler } from "hono"; import pkgJson from "../package.json" with { type: "json" }; @@ -24,3 +25,7 @@ export function httpUserAgent(): string { return userAgent; } + +export type UpgradeWebSocket = ( + createEvents: (c: HonoContext) => any, +) => HonoHandler; diff --git a/packages/core/src/worker/conn-routing-handler.ts b/packages/core/src/worker/conn-routing-handler.ts index 041d1f455..5edbdc5e9 100644 --- a/packages/core/src/worker/conn-routing-handler.ts +++ b/packages/core/src/worker/conn-routing-handler.ts @@ -1,3 +1,5 @@ +import { UpgradeWebSocket } from "@/utils"; +import { Encoding } from "./protocol/serde"; import type { ConnectionHandlers as ConnHandlers } from "./router-endpoints"; import type { Context as HonoContext, HonoRequest } from "hono"; @@ -35,7 +37,8 @@ export type SendRequestHandler = ( export type OpenWebSocketHandler = ( workerId: string, - meta?: unknown, + meta: unknown | undefined, + encodingKind: Encoding, ) => Promise; export type ProxyRequestHandler = ( @@ -50,4 +53,5 @@ export type ProxyWebSocketHandler = ( path: string, workerId: string, meta?: unknown, + upgradeWebSocket?: UpgradeWebSocket, ) => Promise; diff --git a/packages/core/src/worker/errors.ts b/packages/core/src/worker/errors.ts index 176ae7e91..5d85ad262 100644 --- a/packages/core/src/worker/errors.ts +++ b/packages/core/src/worker/errors.ts @@ -1,3 +1,5 @@ +import { DeconstructedError } from "@/common/utils"; + export const INTERNAL_ERROR_CODE = "internal_error"; export const INTERNAL_ERROR_DESCRIPTION = "Internal error. Read the worker logs for more details."; @@ -17,11 +19,22 @@ export class WorkerError extends Error { public public: boolean; public metadata?: unknown; - public statusCode: number = 500; - - public static isWorkerError(error: unknown): error is WorkerError { + public statusCode = 500; + + public static isWorkerError( + error: unknown, + ): error is WorkerError | DeconstructedError { + console.trace( + "checking error", + error, + typeof error, + (error as WorkerError | DeconstructedError).__type, + typeof error === "object" && + (error as WorkerError | DeconstructedError).__type === "WorkerError", + ); return ( - typeof error === "object" && (error as WorkerError).__type === "WorkerError" + typeof error === "object" && + (error as WorkerError | DeconstructedError).__type === "WorkerError" ); } diff --git a/packages/core/src/worker/instance.ts b/packages/core/src/worker/instance.ts index 8f4294644..b1e01109e 100644 --- a/packages/core/src/worker/instance.ts +++ b/packages/core/src/worker/instance.ts @@ -920,6 +920,9 @@ export class WorkerInstance { isPromise: output instanceof Promise, }); + // This output *might* reference a part of the state (using onChange), but + // that's OK since this value always gets serialized and sent over the + // network. return output; } catch (error) { if (error instanceof DeadlineError) { diff --git a/packages/core/src/worker/router-endpoints.ts b/packages/core/src/worker/router-endpoints.ts index 7e3b9c294..6fadaf418 100644 --- a/packages/core/src/worker/router-endpoints.ts +++ b/packages/core/src/worker/router-endpoints.ts @@ -89,6 +89,10 @@ export function handleWebSocketConnect( ) { return async () => { const encoding = getRequestEncoding(context.req, true); + const exposeInternalError = getRequestExposeInternalError( + context.req, + false, + ); let sharedWs: WSContext | undefined = undefined; @@ -158,7 +162,12 @@ export function handleWebSocketConnect( // Allow all other events to proceed onInitResolve(wsHandler); } catch (error) { - deconstructError(error, logger(), { wsEvent: "open" }); + deconstructError( + error, + logger(), + { wsEvent: "open" }, + exposeInternalError, + ); onInitReject(error); ws.close(1011, "internal error"); } @@ -171,9 +180,14 @@ export function handleWebSocketConnect( await wsHandler.onMessage(message); } } catch (error) { - const { code } = deconstructError(error, logger(), { - wsEvent: "message", - }); + const { code } = deconstructError( + error, + logger(), + { + wsEvent: "message", + }, + exposeInternalError, + ); ws.close(1011, code); } }, @@ -199,7 +213,7 @@ export function handleWebSocketConnect( }); } - // HACK: Close socket in order to fix bug with Cloudflare Durable Objects leaving WS in closing state + // HACK: Close socket in order to fix bug with Cloudflare leaving WS in closing state // https://github.com/cloudflare/workerd/issues/2569 ws.close(1000, "hack_force_close"); @@ -207,7 +221,12 @@ export function handleWebSocketConnect( const wsHandler = await onInitPromise; await wsHandler.onClose(); } catch (error) { - deconstructError(error, logger(), { wsEvent: "close" }); + deconstructError( + error, + logger(), + { wsEvent: "close" }, + exposeInternalError, + ); } }, onError: async (_error: unknown) => { @@ -215,7 +234,12 @@ export function handleWebSocketConnect( // Workers don't need to know about this, since it's abstracted away logger().warn("websocket error"); } catch (error) { - deconstructError(error, logger(), { wsEvent: "error" }); + deconstructError( + error, + logger(), + { wsEvent: "error" }, + exposeInternalError, + ); } }, }; @@ -245,18 +269,30 @@ export async function handleSseConnect( return streamSSE(c, async (stream) => { try { await sseHandler.onOpen(stream); + logger().debug("sse open"); + + // HACK: This is required so the abort handler below works + // + // See https://github.com/honojs/hono/issues/1770#issuecomment-2461966225 + stream.onAbort(() => {}); // Wait for close const abortResolver = Promise.withResolvers(); c.req.raw.signal.addEventListener("abort", async () => { try { - abortResolver.resolve(undefined); + logger().debug("sse shutting down"); await sseHandler.onClose(); + abortResolver.resolve(undefined); } catch (error) { logger().error("error closing sse connection", { error }); } }); + // HACK: Will throw if not configured + try { + c.executionCtx.waitUntil(abortResolver.promise); + } catch {} + // Wait until connection aborted await abortResolver.promise; } catch (error) { @@ -280,7 +316,7 @@ export async function handleAction( const encoding = getRequestEncoding(c.req, false); const parameters = getRequestConnParams(c.req, appConfig, driverConfig); - logger().debug("handling action", { actionName, encoding }); + logger().debug("handling action", { actionName, encoding }); // Validate incoming request let actionArgs: unknown[]; @@ -292,7 +328,9 @@ export async function handleAction( } if (!Array.isArray(actionArgs)) { - throw new errors.InvalidActionRequest("Action arguments must be an array"); + throw new errors.InvalidActionRequest( + "Action arguments must be an array", + ); } } else if (encoding === "cbor") { try { @@ -304,7 +342,8 @@ export async function handleAction( ); // Validate using the action schema - const result = protoHttpAction.ActionRequestSchema.safeParse(deserialized); + const result = + protoHttpAction.ActionRequestSchema.safeParse(deserialized); if (!result.success) { throw new errors.InvalidActionRequest("Invalid action request format"); } @@ -415,6 +454,20 @@ export function getRequestEncoding( return result.data; } +export function getRequestExposeInternalError( + req: HonoRequest, + useQuery: boolean, +): boolean { + const param = useQuery + ? req.query("expose-internal-error") + : req.header(HEADER_EXPOSE_INTERNAL_ERROR); + if (!param) { + return false; + } + + return param === "true"; +} + export function getRequestQuery(c: HonoContext, useQuery: boolean): unknown { // Get query parameters for worker lookup const queryParam = useQuery @@ -438,6 +491,7 @@ export function getRequestQuery(c: HonoContext, useQuery: boolean): unknown { export const HEADER_WORKER_QUERY = "X-AC-Query"; export const HEADER_ENCODING = "X-AC-Encoding"; +export const HEADER_EXPOSE_INTERNAL_ERROR = "X-AC-Expose-Internal-Error"; // IMPORTANT: Params must be in headers or in an E2EE part of the request (i.e. NOT the URL or query string) in order to ensure that tokens can be securely passed in params. export const HEADER_CONN_PARAMS = "X-AC-Conn-Params"; diff --git a/packages/core/src/worker/router.ts b/packages/core/src/worker/router.ts deleted file mode 100644 index d3996af5c..000000000 --- a/packages/core/src/worker/router.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Hono, type Context as HonoContext } from "hono"; -import type { UpgradeWebSocket } from "hono/ws"; -import { logger } from "./log"; -import { cors } from "hono/cors"; -import { - handleRouteError, - handleRouteNotFound, - loggerMiddleware, -} from "@/common/router"; -import type { DriverConfig } from "@/driver-helpers/config"; -import type { AppConfig } from "@/app/config"; -import { - type WorkerInspectorConnHandler, - createWorkerInspectorRouter, -} from "@/inspector/worker"; -import invariant from "invariant"; -import { - type ConnectWebSocketOpts, - type ConnectWebSocketOutput, - type ConnectSseOpts, - type ConnectSseOutput, - type ActionOpts, - type ActionOutput, - type ConnsMessageOpts, - type ConnectionHandlers, - handleWebSocketConnect, - handleSseConnect, - handleAction, - handleConnectionMessage, - HEADER_CONN_TOKEN, - HEADER_CONN_ID, - ALL_HEADERS, -} from "./router-endpoints"; - -export type { - ConnectWebSocketOpts, - ConnectWebSocketOutput, - ConnectSseOpts, - ConnectSseOutput, - ActionOpts, - ActionOutput, - ConnsMessageOpts, -}; - -export interface WorkerRouterHandler { - getWorkerId: () => Promise; - - // Connection handlers as a required subobject - connectionHandlers: ConnectionHandlers; - - onConnectInspector?: WorkerInspectorConnHandler; -} - -/** - * Creates a router that handles requests for the protocol and passes it off to the handler. - * - * This allows for creating a universal protocol across all platforms. - */ -export function createWorkerRouter( - appConfig: AppConfig, - driverConfig: DriverConfig, - handler: WorkerRouterHandler, -): Hono { - const app = new Hono(); - - const upgradeWebSocket = driverConfig.getUpgradeWebSocket?.(app); - - app.use("*", loggerMiddleware(logger())); - - // Apply CORS middleware if configured - // - //This is only relevant if the worker is exposed directly publicly - if (appConfig.cors) { - const corsConfig = appConfig.cors; - - app.use("*", async (c, next) => { - const path = c.req.path; - - // Don't apply to WebSocket routes, see https://hono.dev/docs/helpers/websocket#upgradewebsocket - if (path === "/connect/websocket" || path === "/inspect") { - return next(); - } - - return cors({ - ...corsConfig, - allowHeaders: [...(appConfig.cors?.allowHeaders ?? []), ...ALL_HEADERS], - })(c, next); - }); - } - - app.get("/", (c) => { - return c.text( - "This is an WorkerCore server.\n\nLearn more at https://workercore.org", - ); - }); - - app.get("/health", (c) => { - return c.text("ok"); - }); - - // Use the handlers from connectionHandlers - const handlers = handler.connectionHandlers; - - if (upgradeWebSocket && handlers.onConnectWebSocket) { - app.get( - "/connect/websocket", - upgradeWebSocket(async (c) => { - const workerId = await handler.getWorkerId(); - return handleWebSocketConnect( - c as HonoContext, - appConfig, - driverConfig, - handlers.onConnectWebSocket!, - workerId, - )(); - }), - ); - } else { - app.get("/connect/websocket", (c) => { - return c.text( - "WebSockets are not enabled for this driver. Use SSE instead.", - 400, - ); - }); - } - - app.get("/connect/sse", async (c) => { - if (!handlers.onConnectSse) { - throw new Error("onConnectSse handler is required"); - } - const workerId = await handler.getWorkerId(); - return handleSseConnect( - c, - appConfig, - driverConfig, - handlers.onConnectSse, - workerId, - ); - }); - - app.post("/action/:action", async (c) => { - if (!handlers.onAction) { - throw new Error("onAction handler is required"); - } - const actionName = c.req.param("action"); - const workerId = await handler.getWorkerId(); - return handleAction( - c, - appConfig, - driverConfig, - handlers.onAction, - actionName, - workerId, - ); - }); - - app.post("/connections/message", async (c) => { - if (!handlers.onConnMessage) { - throw new Error("onConnMessage handler is required"); - } - const connId = c.req.header(HEADER_CONN_ID); - const connToken = c.req.header(HEADER_CONN_TOKEN); - const workerId = await handler.getWorkerId(); - if (!connId || !connToken) { - throw new Error("Missing required parameters"); - } - return handleConnectionMessage( - c, - appConfig, - handlers.onConnMessage, - connId, - connToken, - workerId, - ); - }); - - if (appConfig.inspector.enabled) { - app.route( - "/inspect", - createWorkerInspectorRouter( - upgradeWebSocket, - handler.onConnectInspector, - appConfig.inspector, - ), - ); - } - - app.notFound(handleRouteNotFound); - app.onError(handleRouteError); - - return app; -} diff --git a/packages/core/tests/driver-test-suite.test.ts b/packages/core/tests/driver-test-suite.test.ts index 281711802..729a14ce3 100644 --- a/packages/core/tests/driver-test-suite.test.ts +++ b/packages/core/tests/driver-test-suite.test.ts @@ -1,14 +1,12 @@ -import { - runDriverTests, - createTestRuntime, -} from "@/driver-test-suite/mod"; +import { runDriverTests, createTestRuntime } from "@/driver-test-suite/mod"; import { TestGlobalState } from "@/test/driver/global-state"; import { TestWorkerDriver } from "@/test/driver/worker"; import { TestManagerDriver } from "@/test/driver/manager"; +import { join } from "node:path"; runDriverTests({ - async start(appPath: string) { - return await createTestRuntime(appPath, async (app) => { + async start(projectPath: string) { + return await createTestRuntime(join(projectPath, "app.ts"), async (app) => { const memoryState = new TestGlobalState(); return { workerDriver: new TestWorkerDriver(memoryState), diff --git a/packages/core/tsup.config.bundled_an4diesgzb.mjs b/packages/core/tsup.config.bundled_an4diesgzb.mjs new file mode 100644 index 000000000..f4f2243ae --- /dev/null +++ b/packages/core/tsup.config.bundled_an4diesgzb.mjs @@ -0,0 +1,22 @@ +// ../../tsup.base.ts +var tsup_base_default = { + target: "node16", + platform: "node", + format: ["cjs", "esm"], + sourcemap: true, + clean: true, + dts: true, + minify: false, + // IMPORTANT: Splitting is required to fix a bug with ESM (https://github.com/egoist/tsup/issues/992#issuecomment-1763540165) + splitting: true, + skipNodeModulesBundle: true, + publicDir: true +}; + +// tsup.config.ts +import { defineConfig } from "tsup"; +var tsup_config_default = defineConfig(tsup_base_default); +export { + tsup_config_default as default +}; +//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsiLi4vLi4vdHN1cC5iYXNlLnRzIiwgInRzdXAuY29uZmlnLnRzIl0sCiAgInNvdXJjZXNDb250ZW50IjogWyJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9ob21lL25hdGhhbi9yaXZldGtpdC90c3VwLmJhc2UudHNcIjtjb25zdCBfX2luamVjdGVkX2Rpcm5hbWVfXyA9IFwiL2hvbWUvbmF0aGFuL3JpdmV0a2l0XCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9ob21lL25hdGhhbi9yaXZldGtpdC90c3VwLmJhc2UudHNcIjtpbXBvcnQgdHlwZSB7IE9wdGlvbnMgfSBmcm9tIFwidHN1cFwiO1xuXG5leHBvcnQgZGVmYXVsdCB7XG5cdHRhcmdldDogXCJub2RlMTZcIixcblx0cGxhdGZvcm06IFwibm9kZVwiLFxuXHRmb3JtYXQ6IFtcImNqc1wiLCBcImVzbVwiXSxcblx0c291cmNlbWFwOiB0cnVlLFxuXHRjbGVhbjogdHJ1ZSxcblx0ZHRzOiB0cnVlLFxuXHRtaW5pZnk6IGZhbHNlLFxuXHQvLyBJTVBPUlRBTlQ6IFNwbGl0dGluZyBpcyByZXF1aXJlZCB0byBmaXggYSBidWcgd2l0aCBFU00gKGh0dHBzOi8vZ2l0aHViLmNvbS9lZ29pc3QvdHN1cC9pc3N1ZXMvOTkyI2lzc3VlY29tbWVudC0xNzYzNTQwMTY1KVxuXHRzcGxpdHRpbmc6IHRydWUsXG5cdHNraXBOb2RlTW9kdWxlc0J1bmRsZTogdHJ1ZSxcblx0cHVibGljRGlyOiB0cnVlLFxufSBzYXRpc2ZpZXMgT3B0aW9ucztcbiIsICJjb25zdCBfX2luamVjdGVkX2ZpbGVuYW1lX18gPSBcIi9ob21lL25hdGhhbi9yaXZldGtpdC9wYWNrYWdlcy9jb3JlL3RzdXAuY29uZmlnLnRzXCI7Y29uc3QgX19pbmplY3RlZF9kaXJuYW1lX18gPSBcIi9ob21lL25hdGhhbi9yaXZldGtpdC9wYWNrYWdlcy9jb3JlXCI7Y29uc3QgX19pbmplY3RlZF9pbXBvcnRfbWV0YV91cmxfXyA9IFwiZmlsZTovLy9ob21lL25hdGhhbi9yaXZldGtpdC9wYWNrYWdlcy9jb3JlL3RzdXAuY29uZmlnLnRzXCI7aW1wb3J0IGRlZmF1bHRDb25maWcgZnJvbSBcIi4uLy4uL3RzdXAuYmFzZS50c1wiO1xuaW1wb3J0IHsgZGVmaW5lQ29uZmlnIH0gZnJvbSBcInRzdXBcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKGRlZmF1bHRDb25maWcpO1xuIl0sCiAgIm1hcHBpbmdzIjogIjtBQUVBLElBQU8sb0JBQVE7QUFBQSxFQUNkLFFBQVE7QUFBQSxFQUNSLFVBQVU7QUFBQSxFQUNWLFFBQVEsQ0FBQyxPQUFPLEtBQUs7QUFBQSxFQUNyQixXQUFXO0FBQUEsRUFDWCxPQUFPO0FBQUEsRUFDUCxLQUFLO0FBQUEsRUFDTCxRQUFRO0FBQUE7QUFBQSxFQUVSLFdBQVc7QUFBQSxFQUNYLHVCQUF1QjtBQUFBLEVBQ3ZCLFdBQVc7QUFDWjs7O0FDYkEsU0FBUyxvQkFBb0I7QUFFN0IsSUFBTyxzQkFBUSxhQUFhLGlCQUFhOyIsCiAgIm5hbWVzIjogW10KfQo= diff --git a/packages/platforms/cloudflare-workers/src/handler.ts b/packages/platforms/cloudflare-workers/src/handler.ts index 5751989bf..f9ab5ac30 100644 --- a/packages/platforms/cloudflare-workers/src/handler.ts +++ b/packages/platforms/cloudflare-workers/src/handler.ts @@ -9,8 +9,11 @@ import type { Hono } from "hono"; import { PartitionTopologyManager } from "rivetkit/topologies/partition"; import { logger } from "./log"; import { CloudflareWorkersManagerDriver } from "./manager-driver"; -import { WorkerCoreApp } from "rivetkit"; +import { Encoding, WorkerCoreApp } from "rivetkit"; import { upgradeWebSocket } from "./websocket"; +import invariant from "invariant"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { InternalError } from "rivetkit/errors"; /** Cloudflare Workers env */ export interface Bindings { @@ -18,6 +21,19 @@ export interface Bindings { WORKER_DO: DurableObjectNamespace; } +/** + * Stores the env for the current request. Required since some contexts like the inline client driver does not have access to the Hono context. + * + * Use getCloudflareAmbientEnv unless using CF_AMBIENT_ENV.run. + */ +export const CF_AMBIENT_ENV = new AsyncLocalStorage(); + +export function getCloudflareAmbientEnv(): Bindings { + const env = CF_AMBIENT_ENV.getStore(); + invariant(env, "missing CF_AMBIENT_ENV"); + return env; +} + export function createHandler( app: WorkerCoreApp, inputConfig?: InputConfig, @@ -30,7 +46,9 @@ export function createHandler( // Create Cloudflare handler const handler = { - fetch: router.fetch, + fetch: (request, env, ctx) => { + return CF_AMBIENT_ENV.run(env, () => router.fetch(request, env, ctx)); + }, } satisfies ExportedHandler; return { handler, WorkerHandler }; @@ -45,7 +63,7 @@ export function createRouter( } { const driverConfig = ConfigSchema.parse(inputConfig); - // Configur drivers + // Configure drivers // // Worker driver will get set in `WorkerHandler` if (!driverConfig.drivers) driverConfig.drivers = {}; @@ -65,7 +83,64 @@ export function createRouter( app.config, driverConfig, { - onProxyRequest: async (c, workerRequest, workerId): Promise => { + sendRequest: async ( + workerId, + meta, + workerRequest, + ): Promise => { + const env = getCloudflareAmbientEnv(); + + logger().debug("sending request to durable object", { + workerId, + method: workerRequest.method, + url: workerRequest.url, + }); + + const id = env.WORKER_DO.idFromString(workerId); + const stub = env.WORKER_DO.get(id); + + return await stub.fetch(workerRequest); + }, + + openWebSocket: async ( + workerId, + meta, + encodingKind: Encoding, + ): Promise => { + const env = getCloudflareAmbientEnv(); + + logger().debug("opening websocket to durable object", { workerId }); + + // Make a fetch request to the Durable Object with WebSocket upgrade + const id = env.WORKER_DO.idFromString(workerId); + const stub = env.WORKER_DO.get(id); + + // TODO: this doesn't call on open + const url = `http://worker/connect/websocket?encoding=${encodingKind}&expose-internal-error=true`; + const response = await stub.fetch(url, { + headers: { + Upgrade: "websocket", + Connection: "Upgrade", + }, + }); + const webSocket = response.webSocket; + + if (!webSocket) { + throw new InternalError( + "missing websocket connection in response from DO", + ); + } + + logger().debug("durable object websocket connection open", { + workerId, + }); + + webSocket.accept(); + + return webSocket; + }, + + proxyRequest: async (c, workerRequest, workerId): Promise => { logger().debug("forwarding request to durable object", { workerId, method: workerRequest.method, @@ -77,7 +152,7 @@ export function createRouter( return await stub.fetch(workerRequest); }, - onProxyWebSocket: async (c, path, workerId) => { + proxyWebSocket: async (c, path, workerId) => { logger().debug("forwarding websocket to durable object", { workerId, path, diff --git a/packages/platforms/cloudflare-workers/src/manager-driver.ts b/packages/platforms/cloudflare-workers/src/manager-driver.ts index e72ea735f..d010dc6fc 100644 --- a/packages/platforms/cloudflare-workers/src/manager-driver.ts +++ b/packages/platforms/cloudflare-workers/src/manager-driver.ts @@ -10,6 +10,7 @@ import { WorkerAlreadyExists } from "rivetkit/errors"; import { Bindings } from "./mod"; import { logger } from "./log"; import { serializeNameAndKey, serializeKey } from "./util"; +import { getCloudflareAmbientEnv } from "./handler"; // Worker metadata structure interface WorkerData { @@ -36,10 +37,10 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { c, workerId, }: GetForIdInput<{ Bindings: Bindings }>): Promise { - if (!c) throw new Error("Missing Hono context"); + const env = getCloudflareAmbientEnv(); // Get worker metadata from KV (combined name and key) - const workerData = (await c.env.WORKER_KV.get(KEYS.WORKER.metadata(workerId), { + const workerData = (await env.WORKER_KV.get(KEYS.WORKER.metadata(workerId), { type: "json", })) as WorkerData | null; @@ -49,7 +50,7 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { } // Generate durable ID from workerId for meta - const durableId = c.env.WORKER_DO.idFromString(workerId); + const durableId = env.WORKER_DO.idFromString(workerId); return { workerId, @@ -66,24 +67,23 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { }: GetWithKeyInput<{ Bindings: Bindings }>): Promise< WorkerOutput | undefined > { - if (!c) throw new Error("Missing Hono context"); - const log = logger(); + const env = getCloudflareAmbientEnv(); - log.debug("getWithKey: searching for worker", { name, key }); + logger().debug("getWithKey: searching for worker", { name, key }); // Generate deterministic ID from the name and key // This is aligned with how createWorker generates IDs const nameKeyString = serializeNameAndKey(name, key); - const durableId = c.env.WORKER_DO.idFromName(nameKeyString); + const durableId = env.WORKER_DO.idFromName(nameKeyString); const workerId = durableId.toString(); // Check if the worker metadata exists - const workerData = await c.env.WORKER_KV.get(KEYS.WORKER.metadata(workerId), { + const workerData = await env.WORKER_KV.get(KEYS.WORKER.metadata(workerId), { type: "json", }); if (!workerData) { - log.debug("getWithKey: no worker found with matching name and key", { + logger().debug("getWithKey: no worker found with matching name and key", { name, key, workerId, @@ -91,7 +91,7 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { return undefined; } - log.debug("getWithKey: found worker with matching name and key", { + logger().debug("getWithKey: found worker with matching name and key", { workerId, name, key, @@ -117,8 +117,7 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { key, input, }: CreateInput<{ Bindings: Bindings }>): Promise { - if (!c) throw new Error("Missing Hono context"); - const log = logger(); + const env = getCloudflareAmbientEnv(); // Check if worker with the same name and key already exists const existingWorker = await this.getWithKey({ c, name, key }); @@ -129,11 +128,11 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { // Create a deterministic ID from the worker name and key // This ensures that workers with the same name and key will have the same ID const nameKeyString = serializeNameAndKey(name, key); - const durableId = c.env.WORKER_DO.idFromName(nameKeyString); + const durableId = env.WORKER_DO.idFromName(nameKeyString); const workerId = durableId.toString(); // Init worker - const worker = c.env.WORKER_DO.get(durableId); + const worker = env.WORKER_DO.get(durableId); await worker.initialize({ name, key, @@ -142,13 +141,13 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { // Store combined worker metadata (name and key) const workerData: WorkerData = { name, key }; - await c.env.WORKER_KV.put( + await env.WORKER_KV.put( KEYS.WORKER.metadata(workerId), JSON.stringify(workerData), ); // Add to key index for lookups by name and key - await c.env.WORKER_KV.put(KEYS.WORKER.keyIndex(name, key), workerId); + await env.WORKER_KV.put(KEYS.WORKER.keyIndex(name, key), workerId); return { workerId, @@ -163,7 +162,9 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { c: any, workerId: string, ): Promise { - const workerData = (await c.env.WORKER_KV.get(KEYS.WORKER.metadata(workerId), { + const env = getCloudflareAmbientEnv(); + + const workerData = (await env.WORKER_KV.get(KEYS.WORKER.metadata(workerId), { type: "json", })) as WorkerData | null; @@ -172,7 +173,7 @@ export class CloudflareWorkersManagerDriver implements ManagerDriver { } // Generate durable ID for meta - const durableId = c.env.WORKER_DO.idFromString(workerId); + const durableId = env.WORKER_DO.idFromString(workerId); return { workerId, diff --git a/packages/platforms/cloudflare-workers/src/worker-handler-do.ts b/packages/platforms/cloudflare-workers/src/worker-handler-do.ts index 67a13cd55..3e2bdf2ac 100644 --- a/packages/platforms/cloudflare-workers/src/worker-handler-do.ts +++ b/packages/platforms/cloudflare-workers/src/worker-handler-do.ts @@ -7,6 +7,8 @@ import { CloudflareDurableObjectGlobalState, CloudflareWorkersWorkerDriver, } from "./worker-driver"; +import { Bindings, CF_AMBIENT_ENV } from "./handler"; +import { ExecutionContext } from "hono"; export const KEYS = { INITIALIZED: "rivetkit:initialized", @@ -32,8 +34,8 @@ interface InitializedData { } export type DurableObjectConstructor = new ( - ...args: ConstructorParameters -) => DurableObject; + ...args: ConstructorParameters> +) => DurableObject; interface LoadedWorker { workerTopology: PartitionTopologyWorker; @@ -52,7 +54,7 @@ export function createWorkerDurableObject( * 3. Start service requests */ return class WorkerHandler - extends DurableObject + extends DurableObject implements WorkerHandlerInterface { #initialized?: InitializedData; @@ -61,6 +63,8 @@ export function createWorkerDurableObject( #worker?: LoadedWorker; async #loadWorker(): Promise { + // This is always called from another context using CF_AMBIENT_ENV + // Wait for init if (!this.#initialized) { // Wait for init @@ -131,31 +135,51 @@ export function createWorkerDurableObject( async initialize(req: WorkerInitRequest) { // TODO: Need to add this to a core promise that needs to be resolved before start - await this.ctx.storage.put({ - [KEYS.INITIALIZED]: true, - [KEYS.NAME]: req.name, - [KEYS.KEY]: req.key, - [KEYS.INPUT]: req.input, + return await CF_AMBIENT_ENV.run(this.env, async () => { + await this.ctx.storage.put({ + [KEYS.INITIALIZED]: true, + [KEYS.NAME]: req.name, + [KEYS.KEY]: req.key, + [KEYS.INPUT]: req.input, + }); + this.#initialized = { + name: req.name, + key: req.key, + }; + + logger().debug("initialized worker", { key: req.key }); + + // Preemptively worker so the lifecycle hooks are called + await this.#loadWorker(); }); - this.#initialized = { - name: req.name, - key: req.key, - }; - - logger().debug("initialized worker", { key: req.key }); - - // Preemptively worker so the lifecycle hooks are called - await this.#loadWorker(); } async fetch(request: Request): Promise { - const { workerTopology } = await this.#loadWorker(); - return await workerTopology.router.fetch(request); + return await CF_AMBIENT_ENV.run(this.env, async () => { + const { workerTopology } = await this.#loadWorker(); + + const ctx = this.ctx; + return await workerTopology.router.fetch( + request, + this.env, + // Implement execution context so we can wait on requests + { + waitUntil(promise: Promise) { + ctx.waitUntil(promise); + }, + passThroughOnException() { + // Do nothing + }, + } satisfies ExecutionContext, + ); + }); } async alarm(): Promise { - const { workerTopology } = await this.#loadWorker(); - await workerTopology.worker.onAlarm(); + return await CF_AMBIENT_ENV.run(this.env, async () => { + const { workerTopology } = await this.#loadWorker(); + await workerTopology.worker.onAlarm(); + }); } }; } diff --git a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts index aeac5d4b2..0c1ced9fd 100644 --- a/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts +++ b/packages/platforms/cloudflare-workers/tests/driver-tests.test.ts @@ -13,109 +13,38 @@ const execPromise = promisify(exec); runDriverTests({ useRealTimers: true, HACK_skipCleanupNet: true, - async start(appPath: string) { + async start(projectPath: string) { + // Setup project + if (!setupProjectOnce) { + setupProjectOnce = setupProject(projectPath); + } + const projectDir = await setupProjectOnce; + + console.log("project dir", projectDir); + // Get an available port const port = await getPort(); const inspectorPort = await getPort(); - // Create a temporary directory for the test - const uuid = crypto.randomUUID(); - const tmpDir = path.join(os.tmpdir(), `worker-core-cloudflare-test-${uuid}`); - await fs.mkdir(tmpDir, { recursive: true }); - - // Create package.json with workspace dependencies - const packageJson = { - name: "rivetkit-test", - private: true, - version: "1.0.0", - type: "module", - scripts: { - start: `wrangler dev --port ${port} --inspector-port ${inspectorPort} --local`, - }, - dependencies: { - wrangler: "4.8.0", - "@rivetkit/cloudflare-workers": "workspace:*", - "rivetkit": "workspace:*", - }, - packageManager: - "yarn@4.7.0+sha512.5a0afa1d4c1d844b3447ee3319633797bcd6385d9a44be07993ae52ff4facabccafb4af5dcd1c2f9a94ac113e5e9ff56f6130431905884414229e284e37bb7c9", - }; - await fs.writeFile( - path.join(tmpDir, "package.json"), - JSON.stringify(packageJson, null, 2), - ); - - // Disable PnP - const yarnPnp = "nodeLinker: node-modules"; - await fs.writeFile(path.join(tmpDir, ".yarnrc.yml"), yarnPnp); - - // Get the current workspace root path and link the workspace - const workspaceRoot = path.resolve(__dirname, "../../../.."); - await execPromise(`yarn link -A ${workspaceRoot}`, { cwd: tmpDir }); - - // Install deps - await execPromise("yarn install", { cwd: tmpDir }); - - // Create a wrangler.json file - const wranglerConfig = { - name: "rivetkit-test", - main: "src/index.ts", - compatibility_date: "2025-01-29", - compatibility_flags: ["nodejs_compat"], - dev: { - port, - }, - migrations: [ - { - new_classes: ["WorkerHandler"], - tag: "v1", - }, - ], - durable_objects: { - bindings: [ - { - class_name: "WorkerHandler", - name: "WORKER_DO", - }, - ], - }, - kv_namespaces: [ - { - binding: "WORKER_KV", - id: "test", // Will be replaced with a mock in dev mode - }, + // Start wrangler dev + const wranglerProcess = spawn( + "yarn", + [ + "start", + "src/index.ts", + "--port", + `${port}`, + "--inspector-port", + `${inspectorPort}`, + "--persist-to", + `/tmp/workers-test-${crypto.randomUUID()}`, ], - observability: { - enabled: true, + { + cwd: projectDir, + stdio: "pipe", }, - }; - await fs.writeFile( - path.join(tmpDir, "wrangler.json"), - JSON.stringify(wranglerConfig, null, 2), ); - // Create src directory - const srcDir = path.join(tmpDir, "src"); - await fs.mkdir(srcDir, { recursive: true }); - - // Write the index.ts file based on the app path - const indexContent = `import { createHandler } from "@rivetkit/cloudflare-workers"; -import { app } from "${appPath.replace(/\.ts$/, "")}"; - -// Create handlers for Cloudflare Workers -const { handler, WorkerHandler } = createHandler(app); - -// Export the handlers for Cloudflare -export { handler as default, WorkerHandler }; -`; - await fs.writeFile(path.join(srcDir, "index.ts"), indexContent); - - // Start wrangler dev - const wranglerProcess = spawn("yarn", ["start"], { - cwd: tmpDir, - stdio: "pipe", - }); - // Wait for wrangler to start await new Promise((resolve, reject) => { let isResolved = false; @@ -165,10 +94,105 @@ export { handler as default, WorkerHandler }; async cleanup() { // Shut down wrangler process wranglerProcess.kill(); - - // Clean up temporary directory - await fs.rm(tmpDir, { recursive: true, force: true }); }, }; }, }); + +let setupProjectOnce: Promise | undefined = undefined; + +async function setupProject(projectPath: string) { + // Create a temporary directory for the test + const uuid = crypto.randomUUID(); + const tmpDir = path.join(os.tmpdir(), `worker-core-cloudflare-test-${uuid}`); + await fs.mkdir(tmpDir, { recursive: true }); + + // Create package.json with workspace dependencies + const packageJson = { + name: "rivetkit-test", + private: true, + version: "1.0.0", + type: "module", + scripts: { + start: "wrangler dev", + }, + dependencies: { + wrangler: "4.8.0", + "@rivetkit/cloudflare-workers": "workspace:*", + rivetkit: "workspace:*", + }, + packageManager: + "yarn@4.7.0+sha512.5a0afa1d4c1d844b3447ee3319633797bcd6385d9a44be07993ae52ff4facabccafb4af5dcd1c2f9a94ac113e5e9ff56f6130431905884414229e284e37bb7c9", + }; + await fs.writeFile( + path.join(tmpDir, "package.json"), + JSON.stringify(packageJson, null, 2), + ); + + // Disable PnP + const yarnPnp = "nodeLinker: node-modules"; + await fs.writeFile(path.join(tmpDir, ".yarnrc.yml"), yarnPnp); + + // Get the current workspace root path and link the workspace + const workspaceRoot = path.resolve(__dirname, "../../../.."); + await execPromise(`yarn link -A ${workspaceRoot}`, { cwd: tmpDir }); + + // Install deps + await execPromise("yarn install", { cwd: tmpDir }); + + // Create a wrangler.json file + const wranglerConfig = { + name: "rivetkit-test", + compatibility_date: "2025-01-29", + compatibility_flags: ["nodejs_compat"], + migrations: [ + { + new_classes: ["WorkerHandler"], + tag: "v1", + }, + ], + durable_objects: { + bindings: [ + { + class_name: "WorkerHandler", + name: "WORKER_DO", + }, + ], + }, + kv_namespaces: [ + { + binding: "WORKER_KV", + id: "test", // Will be replaced with a mock in dev mode + }, + ], + observability: { + enabled: true, + }, + }; + await fs.writeFile( + path.join(tmpDir, "wrangler.json"), + JSON.stringify(wranglerConfig, null, 2), + ); + + // Copy project to test directory + const projectDestDir = path.join(tmpDir, "src", "workers"); + await fs.cp(projectPath, projectDestDir, { recursive: true }); + + // Write script + const indexContent = `import { createHandler } from "@rivetkit/cloudflare-workers"; +import { app } from "./workers/app"; + +// TODO: Find a cleaner way of flagging an app as test mode (ideally not in the config itself) +// Force enable test +app.config.test.enabled = true; + +// Create handlers for Cloudflare Workers +const { handler, WorkerHandler } = createHandler(app); + +// Export the handlers for Cloudflare +export { handler as default, WorkerHandler }; +`; + await fs.writeFile(path.join(tmpDir, "src/index.ts"), indexContent); + + return tmpDir; +} diff --git a/packages/platforms/rivet/TESTING.md b/packages/platforms/rivet/TESTING.md new file mode 100644 index 000000000..1a6f0230c --- /dev/null +++ b/packages/platforms/rivet/TESTING.md @@ -0,0 +1,12 @@ +# Testing + +The `RIVET_CLOUD_TOKEN` and other `RIVET_*` env vars are required to run tests. + +``` +export RIVET_CLOUD_TOKEN=xxxxx +# If not using Rivet Cloud +export RIVET_ENDPOINT=http://localhost:8080 +rivet shell +yarn test +``` + diff --git a/packages/platforms/rivet/package.json b/packages/platforms/rivet/package.json index 67f028e88..f4c34f4aa 100644 --- a/packages/platforms/rivet/package.json +++ b/packages/platforms/rivet/package.json @@ -18,11 +18,31 @@ "default": "./dist/mod.cjs" } }, + "./worker": { + "import": { + "types": "./dist/worker.d.ts", + "default": "./dist/worker.js" + }, + "require": { + "types": "./dist/worker.d.cts", + "default": "./dist/worker.cjs" + } + }, + "./manager": { + "import": { + "types": "./dist/manager.d.ts", + "default": "./dist/manager.js" + }, + "require": { + "types": "./dist/manager.d.cts", + "default": "./dist/manager.cjs" + } + }, "./tsconfig": "./dist/tsconfig.json" }, "sideEffects": false, "scripts": { - "build": "tsup src/mod.ts", + "build": "tsup src/mod.ts src/worker.ts src/manager.ts", "check-types": "tsc --noEmit", "test": "vitest run" }, @@ -31,6 +51,7 @@ }, "devDependencies": { "@rivet-gg/actor-core": "^25.1.0", + "@rivet-gg/api": "^25.4.2", "@types/deno": "^2.0.0", "@types/invariant": "^2", "@types/node": "^22.13.1", @@ -40,6 +61,8 @@ "vitest": "^3.1.1" }, "dependencies": { + "@hono/node-server": "^1.14.4", + "@hono/node-ws": "^1.1.7", "hono": "^4.7.0", "invariant": "^2.2.4", "zod": "^3.24.2" diff --git a/packages/platforms/rivet/src/config.ts b/packages/platforms/rivet/src/config.ts index b52520cf2..31cab579f 100644 --- a/packages/platforms/rivet/src/config.ts +++ b/packages/platforms/rivet/src/config.ts @@ -1,9 +1,6 @@ -import type { WorkerCoreApp } from "rivetkit"; import { DriverConfigSchema } from "rivetkit/driver-helpers"; import { z } from "zod"; -export const ConfigSchema = DriverConfigSchema.extend({ - app: z.custom>(), -}); +export const ConfigSchema = DriverConfigSchema.default({}); export type InputConfig = z.input; export type Config = z.infer; diff --git a/packages/platforms/rivet/src/manager-driver.ts b/packages/platforms/rivet/src/manager-driver.ts index 441bca19e..398dd4bc7 100644 --- a/packages/platforms/rivet/src/manager-driver.ts +++ b/packages/platforms/rivet/src/manager-driver.ts @@ -28,37 +28,39 @@ export class RivetManagerDriver implements ManagerDriver { this.#clientConfig = clientConfig; } - async getForId({ workerId }: GetForIdInput): Promise { + async getForId({ + workerId, + }: GetForIdInput): Promise { try { - // Get worker - const res = await rivetRequest( + // Get actor + const res = await rivetRequest( this.#clientConfig, "GET", - `/workers/${encodeURIComponent(workerId)}`, + `/actors/${encodeURIComponent(workerId)}`, ); // Check if worker exists and not destroyed - if (res.worker.destroyedAt) { + if (res.actor.destroyedAt) { return undefined; } // Ensure worker has required tags - if (!("name" in res.worker.tags)) { - throw new Error(`Worker ${res.worker.id} missing 'name' in tags.`); + if (!("name" in res.actor.tags)) { + throw new Error(`Worker ${res.actor.id} missing 'name' in tags.`); } - if (res.worker.tags.role !== "worker") { - throw new Error(`Worker ${res.worker.id} does not have a worker role.`); + if (res.actor.tags.role !== "worker") { + throw new Error(`Worker ${res.actor.id} does not have a worker role.`); } - if (res.worker.tags.framework !== "rivetkit") { - throw new Error(`Worker ${res.worker.id} is not an WorkerCore worker.`); + if (res.actor.tags.framework !== "rivetkit") { + throw new Error(`Worker ${res.actor.id} is not an WorkerCore worker.`); } return { - workerId: res.worker.id, - name: res.worker.tags.name, - key: this.#extractKeyFromRivetTags(res.worker.tags), + workerId: res.actor.id, + name: res.actor.tags.name, + key: this.#extractKeyFromRivetTags(res.actor.tags), meta: { - endpoint: buildWorkerEndpoint(res.worker), + endpoint: buildWorkerEndpoint(res.actor), } satisfies GetWorkerMeta, }; } catch (error) { @@ -74,15 +76,15 @@ export class RivetManagerDriver implements ManagerDriver { // Convert key array to Rivet's tag format const rivetTags = this.#convertKeyToRivetTags(name, key); - // Query workers with matching tags - const { workers } = await rivetRequest( + // Query actors with matching tags + const { actors } = await rivetRequest( this.#clientConfig, "GET", - `/workers?tags_json=${encodeURIComponent(JSON.stringify(rivetTags))}`, + `/actors?tags_json=${encodeURIComponent(JSON.stringify(rivetTags))}`, ); // Filter workers to ensure they're valid - const validWorkers = workers.filter((a: RivetWorker) => { + const validActors = actors.filter((a: RivetActor) => { // Verify all ports have hostname and port for (const portName in a.network.ports) { const port = a.network.ports[portName]; @@ -91,27 +93,27 @@ export class RivetManagerDriver implements ManagerDriver { return true; }); - if (validWorkers.length === 0) { + if (validActors.length === 0) { return undefined; } - // For consistent results, sort by ID if multiple workers match - const worker = - validWorkers.length > 1 - ? validWorkers.sort((a, b) => a.id.localeCompare(b.id))[0] - : validWorkers[0]; + // For consistent results, sort by ID if multiple actors match + const actor = + validActors.length > 1 + ? validActors.sort((a, b) => a.id.localeCompare(b.id))[0] + : validActors[0]; - // Ensure worker has required tags - if (!("name" in worker.tags)) { - throw new Error(`Worker ${worker.id} missing 'name' in tags.`); + // Ensure actor has required tags + if (!("name" in actor.tags)) { + throw new Error(`Worker ${actor.id} missing 'name' in tags.`); } return { - workerId: worker.id, - name: worker.tags.name, - key: this.#extractKeyFromRivetTags(worker.tags), + workerId: actor.id, + name: actor.tags.name, + key: this.#extractKeyFromRivetTags(actor.tags), meta: { - endpoint: buildWorkerEndpoint(worker), + endpoint: buildWorkerEndpoint(actor), } satisfies GetWorkerMeta, }; } @@ -151,7 +153,6 @@ export class RivetManagerDriver implements ManagerDriver { const createRequest = { tags: this.#convertKeyToRivetTags(name, key), build_tags: { - name, role: "worker", framework: "rivetkit", current: "true", @@ -177,19 +178,22 @@ export class RivetManagerDriver implements ManagerDriver { }, }; - logger().info("creating worker", { ...createRequest }); + logger().info("creating actor", { ...createRequest }); // Create the worker - const { worker } = await rivetRequest< + const { actor } = await rivetRequest< typeof createRequest, - { worker: RivetWorker } - >(this.#clientConfig, "POST", "/workers", createRequest); + { actor: RivetActor } + >(this.#clientConfig, "POST", "/actors", createRequest); // Initialize the worker try { - const endpoint = buildWorkerEndpoint(worker); + const endpoint = buildWorkerEndpoint(actor); const url = `${endpoint}/initialize`; - logger().debug("initializing worker", { url, input: JSON.stringify(input) }); + logger().debug("initializing worker", { + url, + input: JSON.stringify(input), + }); const res = await fetch(url, { method: "POST", @@ -205,26 +209,26 @@ export class RivetManagerDriver implements ManagerDriver { } } catch (error) { logger().error("failed to initialize worker, destroying worker", { - workerId: worker.id, + workerId: actor.id, error, }); // Destroy the worker since it failed to initialize - await rivetRequest( + await rivetRequest( this.#clientConfig, "DELETE", - `/workers/${worker.id}`, + `/actors/${actor.id}`, ); throw error; } return { - workerId: worker.id, + workerId: actor.id, name, - key: this.#extractKeyFromRivetTags(worker.tags), + key: this.#extractKeyFromRivetTags(actor.tags), meta: { - endpoint: buildWorkerEndpoint(worker), + endpoint: buildWorkerEndpoint(actor), } satisfies GetWorkerMeta, }; } @@ -265,7 +269,7 @@ export class RivetManagerDriver implements ManagerDriver { } } -function buildWorkerEndpoint(worker: RivetWorker): string { +function buildWorkerEndpoint(worker: RivetActor): string { // Fetch port const httpPort = worker.network.ports.http; if (!httpPort) throw new Error("missing http port"); @@ -299,6 +303,6 @@ function buildWorkerEndpoint(worker: RivetWorker): string { } // biome-ignore lint/suspicious/noExplicitAny: will add api types later -type RivetWorker = any; +type RivetActor = any; // biome-ignore lint/suspicious/noExplicitAny: will add api types later type RivetBuild = any; diff --git a/packages/platforms/rivet/src/manager-handler.ts b/packages/platforms/rivet/src/manager-handler.ts deleted file mode 100644 index eda892f5b..000000000 --- a/packages/platforms/rivet/src/manager-handler.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { setupLogging } from "rivetkit/log"; -import { stringifyError } from "rivetkit/utils"; -import type { WorkerContext } from "@rivet-gg/worker-core"; -import { logger } from "./log"; -import { GetWorkerMeta, RivetManagerDriver } from "./manager-driver"; -import type { RivetClientConfig } from "./rivet-client"; -import type { RivetHandler } from "./util"; -import { createWebSocketProxy } from "./ws-proxy"; -import { PartitionTopologyManager } from "rivetkit/topologies/partition"; -import { type InputConfig, ConfigSchema } from "./config"; -import { proxy } from "hono/proxy"; -import invariant from "invariant"; -import { upgradeWebSocket } from "hono/deno"; - -export function createManagerHandler(inputConfig: InputConfig): RivetHandler { - try { - return createManagerHandlerInner(inputConfig); - } catch (error) { - logger().error("failed to start manager", { error: stringifyError(error) }); - Deno.exit(1); - } -} - -export function createManagerHandlerInner( - inputConfig: InputConfig, -): RivetHandler { - const driverConfig = ConfigSchema.parse(inputConfig); - - const handler = { - async start(ctx: WorkerContext): Promise { - setupLogging(); - - const portStr = Deno.env.get("PORT_HTTP"); - if (!portStr) { - throw "Missing port"; - } - const port = Number.parseInt(portStr); - if (!Number.isFinite(port)) { - throw "Invalid port"; - } - - const endpoint = Deno.env.get("RIVET_API_ENDPOINT"); - if (!endpoint) throw new Error("missing RIVET_API_ENDPOINT"); - const token = Deno.env.get("RIVET_SERVICE_TOKEN"); - if (!token) throw new Error("missing RIVET_SERVICE_TOKEN"); - - const clientConfig: RivetClientConfig = { - endpoint, - token, - project: ctx.metadata.project.slug, - environment: ctx.metadata.environment.slug, - }; - - // Force disable inspector - driverConfig.app.config.inspector = { - enabled: false, - }; - - const corsConfig = driverConfig.app.config.cors; - - // Enable CORS for Rivet domains - driverConfig.app.config.cors = { - ...driverConfig.app.config.cors, - origin: (origin, c) => { - const isRivetOrigin = - origin.endsWith(".rivet.gg") || origin.includes("localhost:"); - const configOrigin = corsConfig?.origin; - - if (isRivetOrigin) { - return origin; - } - if (typeof configOrigin === "function") { - return configOrigin(origin, c); - } - if (typeof configOrigin === "string") { - return configOrigin; - } - return null; - }, - }; - - // Setup manager driver - if (!driverConfig.drivers) driverConfig.drivers = {}; - if (!driverConfig.drivers.manager) { - driverConfig.drivers.manager = new RivetManagerDriver(clientConfig); - } - - // Setup WebSocket upgrader - if (!driverConfig.getUpgradeWebSocket) { - driverConfig.getUpgradeWebSocket = () => upgradeWebSocket; - } - - // Create manager topology - driverConfig.topology = driverConfig.topology ?? "partition"; - const managerTopology = new PartitionTopologyManager( - driverConfig.app.config, - driverConfig, - { - onProxyRequest: async (c, workerRequest, _workerId, metaRaw) => { - invariant(metaRaw, "meta not provided"); - const meta = metaRaw as GetWorkerMeta; - - const parsedRequestUrl = new URL(workerRequest.url); - const workerUrl = `${meta.endpoint}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; - - logger().debug("proxying request to rivet worker", { - method: workerRequest.method, - url: workerUrl, - }); - - const proxyRequest = new Request(workerUrl, workerRequest); - return await proxy(proxyRequest); - }, - onProxyWebSocket: async (c, path, workerId, metaRaw) => { - invariant(metaRaw, "meta not provided"); - const meta = metaRaw as GetWorkerMeta; - - const workerUrl = `${meta.endpoint}${path}`; - - logger().debug("proxying websocket to rivet worker", { - url: workerUrl, - }); - - // TODO: fix as any - return createWebSocketProxy(c, workerUrl) as any; - }, - }, - ); - - const app = managerTopology.router; - - logger().info("server running", { port }); - const server = Deno.serve( - { - port, - hostname: "0.0.0.0", - // Remove "Listening on ..." message - onListen() {}, - }, - app.fetch, - ); - await server.finished; - }, - } satisfies RivetHandler; - - return handler; -} diff --git a/packages/platforms/rivet/src/manager.ts b/packages/platforms/rivet/src/manager.ts new file mode 100644 index 000000000..4f0821aef --- /dev/null +++ b/packages/platforms/rivet/src/manager.ts @@ -0,0 +1,210 @@ +import { setupLogging } from "rivetkit/log"; +import { serve as honoServe } from "@hono/node-server"; +import { createNodeWebSocket, NodeWebSocket } from "@hono/node-ws"; +import { logger } from "./log"; +import { GetWorkerMeta, RivetManagerDriver } from "./manager-driver"; +import type { RivetClientConfig } from "./rivet-client"; +import { PartitionTopologyManager } from "rivetkit/topologies/partition"; +import { proxy } from "hono/proxy"; +import invariant from "invariant"; +import { ConfigSchema, InputConfig } from "./config"; +import type { WorkerCoreApp } from "rivetkit"; +import { createWebSocketProxy } from "./ws-proxy"; + +export async function startManager( + app: WorkerCoreApp, + inputConfig?: InputConfig, +): Promise { + setupLogging(); + + const driverConfig = ConfigSchema.parse(inputConfig); + + const portStr = process.env.PORT_HTTP; + if (!portStr) { + throw "Missing port"; + } + const port = Number.parseInt(portStr); + if (!Number.isFinite(port)) { + throw "Invalid port"; + } + + const endpoint = process.env.RIVET_API_ENDPOINT; + if (!endpoint) throw new Error("missing RIVET_API_ENDPOINT"); + const token = process.env.RIVET_SERVICE_TOKEN; + if (!token) throw new Error("missing RIVET_SERVICE_TOKEN"); + const project = process.env.RIVET_PROJECT; + if (!project) throw new Error("missing RIVET_PROJECT"); + const environment = process.env.RIVET_ENVIRONMENT; + if (!environment) throw new Error("missing RIVET_ENVIRONMENT"); + + const clientConfig: RivetClientConfig = { + endpoint, + token, + project, + environment, + }; + + //// Force disable inspector + //driverConfig.app.config.inspector = { + // enabled: false, + //}; + + //const corsConfig = driverConfig.app.config.cors; + // + //// Enable CORS for Rivet domains + //driverConfig.app.config.cors = { + // ...driverConfig.app.config.cors, + // origin: (origin, c) => { + // const isRivetOrigin = + // origin.endsWith(".rivet.gg") || origin.includes("localhost:"); + // const configOrigin = corsConfig?.origin; + // + // if (isRivetOrigin) { + // return origin; + // } + // if (typeof configOrigin === "function") { + // return configOrigin(origin, c); + // } + // if (typeof configOrigin === "string") { + // return configOrigin; + // } + // return null; + // }, + //}; + + // Setup manager driver + if (!driverConfig.drivers) driverConfig.drivers = {}; + if (!driverConfig.drivers.manager) { + driverConfig.drivers.manager = new RivetManagerDriver(clientConfig); + } + + // Setup WebSocket routing for Node + // + // Save `injectWebSocket` for after server is created + let injectWebSocket: NodeWebSocket["injectWebSocket"] | undefined; + if (!driverConfig.getUpgradeWebSocket) { + driverConfig.getUpgradeWebSocket = (app) => { + const webSocket = createNodeWebSocket({ app }); + injectWebSocket = webSocket.injectWebSocket; + return webSocket.upgradeWebSocket; + }; + } + + // Create manager topology + driverConfig.topology = driverConfig.topology ?? "partition"; + const managerTopology = new PartitionTopologyManager( + app.config, + driverConfig, + { + sendRequest: async (workerId, meta, workerRequest) => { + invariant(meta, "meta not provided"); + const workerMeta = meta as GetWorkerMeta; + + const parsedRequestUrl = new URL(workerRequest.url); + const workerUrl = `${workerMeta.endpoint}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; + + logger().debug("proxying request to rivet worker", { + method: workerRequest.method, + url: workerUrl, + }); + + const proxyRequest = new Request(workerUrl, workerRequest); + return await fetch(proxyRequest); + }, + openWebSocket: async (workerId, meta, encodingKind) => { + invariant(meta, "meta not provided"); + const workerMeta = meta as GetWorkerMeta; + + // Create WebSocket URL with encoding parameter + const wsEndpoint = workerMeta.endpoint.replace(/^http/, "ws"); + const url = `${wsEndpoint}/connect/websocket?encoding=${encodingKind}&expose-internal-error=true`; + + logger().debug("opening websocket to worker", { + workerId, + url, + }); + + // Open WebSocket connection + return new WebSocket(url); + }, + proxyRequest: async (c, workerRequest, _workerId, metaRaw) => { + invariant(metaRaw, "meta not provided"); + const meta = metaRaw as GetWorkerMeta; + + const parsedRequestUrl = new URL(workerRequest.url); + const workerUrl = `${meta.endpoint}${parsedRequestUrl.pathname}${parsedRequestUrl.search}`; + + logger().debug("proxying request to rivet worker", { + method: workerRequest.method, + url: workerUrl, + }); + + const proxyRequest = new Request(workerUrl, workerRequest); + return await proxy(proxyRequest); + }, + proxyWebSocket: async (c, path, _workerId, metaRaw, upgradeWebSocket) => { + invariant(metaRaw, "meta not provided"); + const meta = metaRaw as GetWorkerMeta; + + const workerUrl = `${meta.endpoint}${path}`; + + logger().debug("proxying websocket to rivet worker", { + url: workerUrl, + }); + + const handlers = createWebSocketProxy(workerUrl); + + // upgradeWebSocket is middleware, so we need to pass fake handlers + invariant(upgradeWebSocket, "missing upgradeWebSocket"); + return upgradeWebSocket((c) => createWebSocketProxy(workerUrl))( + c, + async () => {}, + ); + }, + }, + ); + + // Start server with ambient env wrapper + logger().info("server running", { port }); + const server = honoServe({ + fetch: managerTopology.router.fetch, + hostname: "0.0.0.0", + port, + }); + if (!injectWebSocket) throw new Error("injectWebSocket not defined"); + injectWebSocket(server); +} + +// import { Hono } from "hono"; +// import { serve } from "@hono/node-server"; +// import { upgradeWebSocket } from "hono/cloudflare-workers"; +// import { logger as honoLogger } from "hono/logger"; +// +// export async function startManager( +// app: WorkerCoreApp, +// inputConfig?: InputConfig, +// ): Promise { +// const port = parseInt(process.env.PORT_HTTP!); +// +// const router = new Hono(); +// router.use(honoLogger()); +// +// const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ +// app: router, +// }); +// +// router.get("/", (c) => { +// return c.text("Hello Hono!"); +// }); +// +// console.log(`Server is running on port ${port}`); +// +// const server = serve({ +// fetch: router.fetch, +// hostname: "0.0.0.0", +// port, +// }); +// injectWebSocket(server); +// +// console.log(`WS injected`); +// } diff --git a/packages/platforms/rivet/src/mod.ts b/packages/platforms/rivet/src/mod.ts index 4b73b634c..65bef516c 100644 --- a/packages/platforms/rivet/src/mod.ts +++ b/packages/platforms/rivet/src/mod.ts @@ -1,3 +1 @@ -export { createWorkerHandler } from "./worker-handler"; -export { createManagerHandler } from "./manager-handler"; export type { InputConfig as Config } from "./config"; diff --git a/packages/platforms/rivet/src/util.ts b/packages/platforms/rivet/src/util.ts index 3bcab0a68..83a975c8a 100644 --- a/packages/platforms/rivet/src/util.ts +++ b/packages/platforms/rivet/src/util.ts @@ -1,8 +1,7 @@ -import type { WorkerContext } from "@rivet-gg/worker-core"; -import invariant from "invariant"; +import type { ActorContext } from "@rivet-gg/actor-core"; export interface RivetHandler { - start(ctx: WorkerContext): Promise; + start(ctx: ActorContext): Promise; } // Constants for key handling diff --git a/packages/platforms/rivet/src/worker-driver.ts b/packages/platforms/rivet/src/worker-driver.ts index 9eae13099..6a1bad62e 100644 --- a/packages/platforms/rivet/src/worker-driver.ts +++ b/packages/platforms/rivet/src/worker-driver.ts @@ -1,14 +1,14 @@ -import type { WorkerContext } from "@rivet-gg/worker-core"; +import { ActorContext } from "@rivet-gg/actor-core"; import type { WorkerDriver, AnyWorkerInstance } from "rivetkit/driver-helpers"; export interface WorkerDriverContext { - ctx: WorkerContext; + ctx: ActorContext; } export class RivetWorkerDriver implements WorkerDriver { - #ctx: WorkerContext; + #ctx: ActorContext; - constructor(ctx: WorkerContext) { + constructor(ctx: ActorContext) { this.#ctx = ctx; } diff --git a/packages/platforms/rivet/src/worker-handler.ts b/packages/platforms/rivet/src/worker-handler.ts deleted file mode 100644 index 3ebd9777e..000000000 --- a/packages/platforms/rivet/src/worker-handler.ts +++ /dev/null @@ -1,184 +0,0 @@ -import { setupLogging } from "rivetkit/log"; -import { stringifyError } from "rivetkit/utils"; -import type { WorkerContext } from "@rivet-gg/worker-core"; -import { upgradeWebSocket } from "hono/deno"; -import { logger } from "./log"; -import type { RivetHandler } from "./util"; -import { deserializeKeyFromTag } from "./util"; -import { PartitionTopologyWorker } from "rivetkit/topologies/partition"; -import { ConfigSchema, type InputConfig } from "./config"; -import { RivetWorkerDriver } from "./worker-driver"; -import { rivetRequest } from "./rivet-client"; -import invariant from "invariant"; - -export function createWorkerHandler(inputConfig: InputConfig): RivetHandler { - try { - return createWorkerHandlerInner(inputConfig); - } catch (error) { - logger().error("failed to start worker", { error: stringifyError(error) }); - Deno.exit(1); - } -} - -function createWorkerHandlerInner(inputConfig: InputConfig): RivetHandler { - const driverConfig = ConfigSchema.parse(inputConfig); - - const handler = { - async start(ctx: WorkerContext): Promise { - setupLogging(); - - const portStr = Deno.env.get("PORT_HTTP"); - if (!portStr) { - throw "Missing port"; - } - const port = Number.parseInt(portStr); - if (!Number.isFinite(port)) { - throw "Invalid port"; - } - - const endpoint = Deno.env.get("RIVET_API_ENDPOINT"); - if (!endpoint) throw new Error("missing RIVET_API_ENDPOINT"); - - // Initialization promise - const initializedPromise = Promise.withResolvers(); - if ((await ctx.kv.get(["rivetkit", "initialized"])) === true) { - initializedPromise.resolve(undefined); - } - - // Setup worker driver - if (!driverConfig.drivers) driverConfig.drivers = {}; - if (!driverConfig.drivers.worker) { - driverConfig.drivers.worker = new RivetWorkerDriver(ctx); - } - - // Setup WebSocket upgrader - if (!driverConfig.getUpgradeWebSocket) { - driverConfig.getUpgradeWebSocket = () => upgradeWebSocket; - } - - driverConfig.app.config.inspector = { - enabled: true, - onRequest: async (c) => { - const url = new URL(c.req.url); - const token = url.searchParams.get("token"); - - if (!token) { - return false; - } - - try { - const response = await rivetRequest( - { endpoint, token }, - "GET", - "/cloud/auth/inspect", - ); - return "agent" in response; - } catch (e) { - return false; - } - }, - }; - - const corsConfig = driverConfig.app.config.cors; - - // Enable CORS for Rivet domains - driverConfig.app.config.cors = { - ...driverConfig.app.config.cors, - origin: (origin, c) => { - const isRivetOrigin = - origin.endsWith(".rivet.gg") || origin.includes("localhost:"); - const configOrigin = corsConfig?.origin; - - if (isRivetOrigin) { - return origin; - } - if (typeof configOrigin === "function") { - return configOrigin(origin, c); - } - if (typeof configOrigin === "string") { - return configOrigin; - } - return null; - }, - }; - - // Create worker topology - driverConfig.topology = driverConfig.topology ?? "partition"; - const workerTopology = new PartitionTopologyWorker( - driverConfig.app.config, - driverConfig, - ); - - // Set a catch-all route - const router = workerTopology.router; - - // TODO: This needs to be secured - // TODO: This needs to assert this has only been called once - // Initialize with data - router.post("/initialize", async (c) => { - const body = await c.req.json(); - - logger().debug("received initialize request", { - hasInput: !!body.input, - }); - - // Write input - if (body.input) { - await ctx.kv.putBatch( - new Map([ - [["rivetkit", "input", "exists"], true], - [["rivetkit", "input", "data"], body.input], - ]), - ); - } - - // Finish initialization - initializedPromise.resolve(undefined); - - return c.json({}, 200); - }); - - // Start server - logger().info("server running", { port }); - const server = Deno.serve( - { - port, - hostname: "0.0.0.0", - // Remove "Listening on ..." message - onListen() {}, - }, - router.fetch, - ); - - // Assert name exists - if (!("name" in ctx.metadata.worker.tags)) { - throw new Error( - `Tags for worker ${ctx.metadata.worker.id} do not contain property name: ${JSON.stringify(ctx.metadata.worker.tags)}`, - ); - } - - // Extract key from Rivet's tag format - const key = extractKeyFromRivetTags(ctx.metadata.worker.tags); - - // Start worker after initialized - await initializedPromise.promise; - await workerTopology.start( - ctx.metadata.worker.id, - ctx.metadata.worker.tags.name, - key, - ctx.metadata.region.id, - ); - - // Wait for server - await server.finished; - }, - } satisfies RivetHandler; - - return handler; -} - -// Helper function to extract key array from Rivet's tag format -function extractKeyFromRivetTags(tags: Record): string[] { - invariant(typeof tags.key === "string", "key tag does not exist"); - return deserializeKeyFromTag(tags.key); -} diff --git a/packages/platforms/rivet/src/worker.ts b/packages/platforms/rivet/src/worker.ts new file mode 100644 index 000000000..af8420367 --- /dev/null +++ b/packages/platforms/rivet/src/worker.ts @@ -0,0 +1,191 @@ +import { setupLogging } from "rivetkit/log"; +import { upgradeWebSocket } from "hono/deno"; +import { logger } from "./log"; +import { deserializeKeyFromTag, type RivetHandler } from "./util"; +import { PartitionTopologyWorker } from "rivetkit/topologies/partition"; +import { RivetWorkerDriver } from "./worker-driver"; +import invariant from "invariant"; +import type { ActorContext } from "@rivet-gg/actor-core"; +import { WorkerCoreApp } from "rivetkit"; +import { type Config, ConfigSchema, type InputConfig } from "./config"; +import { stringifyError } from "rivetkit/utils"; + +export function createWorkerHandler( + app: WorkerCoreApp, + inputConfig?: InputConfig, +): RivetHandler { + let driverConfig: Config; + try { + driverConfig = ConfigSchema.parse(inputConfig); + } catch (error) { + logger().error("failed to start manager", { error: stringifyError(error) }); + Deno.exit(1); + } + + return { + async start(ctx: ActorContext) { + const role = ctx.metadata.actor.tags.role; + if (role === "worker") { + await startWorker(ctx, app, driverConfig); + } else { + throw new Error(`Unexpected role (must be worker): ${role}`); + } + }, + }; +} + +async function startWorker( + ctx: ActorContext, + app: WorkerCoreApp, + driverConfig: Config, +): Promise { + setupLogging(); + + const portStr = Deno.env.get("PORT_HTTP"); + if (!portStr) { + throw "Missing port"; + } + const port = Number.parseInt(portStr); + if (!Number.isFinite(port)) { + throw "Invalid port"; + } + + const endpoint = Deno.env.get("RIVET_API_ENDPOINT"); + if (!endpoint) throw new Error("missing RIVET_API_ENDPOINT"); + + // Initialization promise + const initializedPromise = Promise.withResolvers(); + if ((await ctx.kv.get(["rivetkit", "initialized"])) === true) { + initializedPromise.resolve(undefined); + } + + // Setup worker driver + if (!driverConfig.drivers) driverConfig.drivers = {}; + if (!driverConfig.drivers.worker) { + driverConfig.drivers.worker = new RivetWorkerDriver(ctx); + } + + // Setup WebSocket upgrader + if (!driverConfig.getUpgradeWebSocket) { + driverConfig.getUpgradeWebSocket = () => upgradeWebSocket; + } + + //app.config.inspector = { + // enabled: true, + // onRequest: async (c) => { + // const url = new URL(c.req.url); + // const token = url.searchParams.get("token"); + // + // if (!token) { + // return false; + // } + // + // try { + // const response = await rivetRequest( + // { endpoint, token }, + // "GET", + // "/cloud/auth/inspect", + // ); + // return "agent" in response; + // } catch (e) { + // return false; + // } + // }, + //}; + + //const corsConfig = app.config.cors; + // + //// Enable CORS for Rivet domains + //app.config.cors = { + // ...app.config.cors, + // origin: (origin, c) => { + // const isRivetOrigin = + // origin.endsWith(".rivet.gg") || origin.includes("localhost:"); + // const configOrigin = corsConfig?.origin; + // + // if (isRivetOrigin) { + // return origin; + // } + // if (typeof configOrigin === "function") { + // return configOrigin(origin, c); + // } + // if (typeof configOrigin === "string") { + // return configOrigin; + // } + // return null; + // }, + //}; + + // Create worker topology + driverConfig.topology = driverConfig.topology ?? "partition"; + const workerTopology = new PartitionTopologyWorker(app.config, driverConfig); + + // Set a catch-all route + const router = workerTopology.router; + + // TODO: This needs to be secured + // TODO: This needs to assert this has only been called once + // Initialize with data + router.post("/initialize", async (c) => { + const body = await c.req.json(); + + logger().debug("received initialize request", { + hasInput: !!body.input, + }); + + // Write input + if (body.input) { + await ctx.kv.putBatch( + new Map([ + [["rivetkit", "input", "exists"], true], + [["rivetkit", "input", "data"], body.input], + ]), + ); + } + + // Finish initialization + initializedPromise.resolve(undefined); + + return c.json({}, 200); + }); + + // Start server + logger().info("server running", { port }); + const server = Deno.serve( + { + port, + hostname: "0.0.0.0", + // Remove "Listening on ..." message + onListen() {}, + }, + router.fetch, + ); + + // Assert name exists + if (!("name" in ctx.metadata.actor.tags)) { + throw new Error( + `Tags for worker ${ctx.metadata.actor.id} do not contain property name: ${JSON.stringify(ctx.metadata.actor.tags)}`, + ); + } + + // Extract key from Rivet's tag format + const key = extractKeyFromRivetTags(ctx.metadata.actor.tags); + + // Start worker after initialized + await initializedPromise.promise; + await workerTopology.start( + ctx.metadata.actor.id, + ctx.metadata.actor.tags.name, + key, + ctx.metadata.region.id, + ); + + // Wait for server + await server.finished; +} + +// Helper function to extract key array from Rivet's tag format +function extractKeyFromRivetTags(tags: Record): string[] { + invariant(typeof tags.key === "string", "key tag does not exist"); + return deserializeKeyFromTag(tags.key); +} diff --git a/packages/platforms/rivet/src/ws-proxy.ts b/packages/platforms/rivet/src/ws-proxy.ts index 8bdbeb157..0895c08d4 100644 --- a/packages/platforms/rivet/src/ws-proxy.ts +++ b/packages/platforms/rivet/src/ws-proxy.ts @@ -1,4 +1,3 @@ -import { upgradeWebSocket } from "hono/deno"; import { WSContext } from "hono/ws"; import { Context } from "hono"; import { logger } from "./log"; @@ -11,109 +10,107 @@ import invariant from "invariant"; * @param targetUrl Target WebSocket URL to proxy to * @returns Response with upgraded WebSocket */ -export function createWebSocketProxy(c: Context, targetUrl: string) { - return upgradeWebSocket((c) => { - let targetWs: WebSocket | undefined = undefined; - const messageQueue: any[] = []; - - return { - onOpen: (_evt: any, wsContext: WSContext) => { - // Create target WebSocket connection - targetWs = new WebSocket(targetUrl); - - // Set up target websocket handlers - targetWs.onopen = () => { - invariant(targetWs, "targetWs does not exist"); - - // Process any queued messages once connected - if (messageQueue.length > 0) { - for (const data of messageQueue) { - targetWs.send(data); - } - // Clear the queue after sending - messageQueue.length = 0; +export function createWebSocketProxy(targetUrl: string) { + let targetWs: WebSocket | undefined = undefined; + const messageQueue: any[] = []; + + return { + onOpen: (_evt: any, wsContext: WSContext) => { + // Create target WebSocket connection + targetWs = new WebSocket(targetUrl); + + // Set up target websocket handlers + targetWs.onopen = () => { + invariant(targetWs, "targetWs does not exist"); + + // Process any queued messages once connected + if (messageQueue.length > 0) { + for (const data of messageQueue) { + targetWs.send(data); } - }; - - targetWs.onmessage = (event) => { - wsContext.send(event.data); - }; - - targetWs.onclose = (event) => { - logger().debug("target websocket closed", { - code: event.code, - reason: event.reason, - }); - - if (wsContext.readyState === WebSocket.OPEN) { - // Forward the close code and reason from target to client - wsContext.close(event.code, event.reason); - } - }; - - targetWs.onerror = (event) => { - logger().warn("target websocket error"); - - if (wsContext.readyState === WebSocket.OPEN) { - // Use standard WebSocket error code: 1006 - Abnormal Closure - // The connection was closed abnormally, e.g., without sending or receiving a Close control frame - wsContext.close(1006, "Error in target connection"); - } - }; - }, - - // Handle messages from client to target - onMessage: (evt: { data: any }, wsContext: WSContext) => { - invariant(targetWs, "targetWs not defined"); - - // If the WebSocket is OPEN, send immediately - if (targetWs.readyState === WebSocket.OPEN) { - targetWs.send(evt.data); + // Clear the queue after sending + messageQueue.length = 0; } - // If the WebSocket is CONNECTING, queue the message - else if (targetWs.readyState === WebSocket.CONNECTING) { - messageQueue.push(evt.data); - } - // Otherwise (CLOSING or CLOSED), ignore the message - }, + }; - // Handle client WebSocket close - onClose: (evt: CloseEvent, wsContext: WSContext) => { - invariant(targetWs, "targetWs not defined"); + targetWs.onmessage = (event) => { + wsContext.send(event.data); + }; - logger().debug("client websocket closed", { - code: evt.code, - reason: evt.reason, + targetWs.onclose = (event) => { + logger().debug("target websocket closed", { + code: event.code, + reason: event.reason, }); - // Close target if it's either CONNECTING or OPEN - // - // We're only allowed to send code 1000 from the client - if ( - targetWs.readyState === WebSocket.CONNECTING || - targetWs.readyState === WebSocket.OPEN - ) { - // We can only send code 1000 from the client - targetWs.close(1000, evt.reason || "Client closed connection"); + if (wsContext.readyState === WebSocket.OPEN) { + // Forward the close code and reason from target to client + wsContext.close(event.code, event.reason); } - }, - - // Handle client WebSocket errors - onError: (_evt: Event, wsContext: WSContext) => { - invariant(targetWs, "targetWs not defined"); - - logger().warn("websocket proxy received error from client"); - - // Close target with specific error code for proxy errors - // - // We're only allowed to send code 1000 from the client - if ( - targetWs.readyState === WebSocket.CONNECTING || - targetWs.readyState === WebSocket.OPEN - ) { - targetWs.close(1000, "Error in client connection"); + }; + + targetWs.onerror = (event) => { + logger().warn("target websocket error"); + + if (wsContext.readyState === WebSocket.OPEN) { + // Use standard WebSocket error code: 1006 - Abnormal Closure + // The connection was closed abnormally, e.g., without sending or receiving a Close control frame + wsContext.close(1006, "Error in target connection"); } - }, - }; - })(c, async () => {}); + }; + }, + + // Handle messages from client to target + onMessage: (evt: { data: any }, wsContext: WSContext) => { + invariant(targetWs, "targetWs not defined"); + + // If the WebSocket is OPEN, send immediately + if (targetWs.readyState === WebSocket.OPEN) { + targetWs.send(evt.data); + } + // If the WebSocket is CONNECTING, queue the message + else if (targetWs.readyState === WebSocket.CONNECTING) { + messageQueue.push(evt.data); + } + // Otherwise (CLOSING or CLOSED), ignore the message + }, + + // Handle client WebSocket close + onClose: (evt: CloseEvent, wsContext: WSContext) => { + invariant(targetWs, "targetWs not defined"); + + logger().debug("client websocket closed", { + code: evt.code, + reason: evt.reason, + }); + + // Close target if it's either CONNECTING or OPEN + // + // We're only allowed to send code 1000 from the client + if ( + targetWs.readyState === WebSocket.CONNECTING || + targetWs.readyState === WebSocket.OPEN + ) { + // We can only send code 1000 from the client + targetWs.close(1000, evt.reason || "Client closed connection"); + } + }, + + // Handle client WebSocket errors + onError: (_evt: Event, wsContext: WSContext) => { + invariant(targetWs, "targetWs not defined"); + + logger().warn("websocket proxy received error from client"); + + // Close target with specific error code for proxy errors + // + // We're only allowed to send code 1000 from the client + if ( + targetWs.readyState === WebSocket.CONNECTING || + targetWs.readyState === WebSocket.OPEN + ) { + targetWs.close(1000, "Error in client connection"); + } + }, + }; } diff --git a/packages/platforms/rivet/tests/deployment.test.ts b/packages/platforms/rivet/tests/deployment.test.ts index d1e462f21..2da3790aa 100644 --- a/packages/platforms/rivet/tests/deployment.test.ts +++ b/packages/platforms/rivet/tests/deployment.test.ts @@ -1,8 +1,10 @@ import { describe, test, expect, beforeAll, afterAll } from "vitest"; +import os from "node:os"; import fs from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { deployToRivet } from "./rivet-deploy"; +import { randomUUID } from "node:crypto"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -32,20 +34,7 @@ export type App = typeof app; `; test("Rivet deployment tests", async () => { - // Create a temporary path for the counter worker - const tempFilePath = path.join( - __dirname, - "../../../..", - "target", - "temp-counter-app.ts", - ); - - // Ensure target directory exists - await fs.mkdir(path.dirname(tempFilePath), { recursive: true }); - - // Write the counter worker file + const tempFilePath = path.join(os.tmpdir(), `app-${randomUUID()}`); await fs.writeFile(tempFilePath, COUNTER_WORKER); - - // Run the deployment - const result = await deployToRivet(tempFilePath, true); + await deployToRivet("test-app", tempFilePath, true); }); diff --git a/packages/platforms/rivet/tests/driver-tests.test.ts b/packages/platforms/rivet/tests/driver-tests.test.ts index df8e5b0f7..6706ed5b2 100644 --- a/packages/platforms/rivet/tests/driver-tests.test.ts +++ b/packages/platforms/rivet/tests/driver-tests.test.ts @@ -1,73 +1,50 @@ import { runDriverTests } from "rivetkit/driver-test-suite"; -import { deployToRivet, RIVET_CLIENT_CONFIG } from "./rivet-deploy"; -import { type RivetClientConfig, rivetRequest } from "../src/rivet-client"; -import invariant from "invariant"; +import { deployToRivet, rivetClientConfig } from "./rivet-deploy"; +import { RivetClientConfig, rivetRequest } from "../src/rivet-client"; -let alreadyDeployedManager = false; -const alreadyDeployedApps = new Set(); -let managerEndpoint: string | undefined = undefined; +let deployProjectOnce: Promise | undefined = undefined; -const driverTestConfig = { +// IMPORTANT: Unlike other tests, Rivet tests are ran without parallelism since we reuse the same shared environment. Eventually we can create an environment per test to create isolated instances. +runDriverTests({ useRealTimers: true, HACK_skipCleanupNet: true, - async start(appPath: string) { - console.log("Starting test", { - alreadyDeployedManager, - alreadyDeployedApps, - managerEndpoint, - }); + async start(projectPath: string) { + // Setup project + if (!deployProjectOnce) { + deployProjectOnce = deployToRivet(projectPath); + } + const endpoint = await deployProjectOnce; // Cleanup workers from previous tests - await deleteAllWorkers(RIVET_CLIENT_CONFIG, !alreadyDeployedManager); - - if (!alreadyDeployedApps.has(appPath)) { - console.log(`Starting Rivet driver tests with app: ${appPath}`); + await deleteAllWorkers(rivetClientConfig); - // Deploy to Rivet - const result = await deployToRivet(appPath, !alreadyDeployedManager); - console.log( - `Deployed to Rivet at ${result.endpoint} (manager: ${!alreadyDeployedManager})`, - ); - - // Save as deployed - managerEndpoint = result.endpoint; - alreadyDeployedApps.add(appPath); - alreadyDeployedManager = true; - } else { - console.log(`Already deployed: ${appPath}`); - } - - invariant(managerEndpoint, "missing manager endpoint"); return { - endpoint: managerEndpoint, + endpoint, async cleanup() { - await deleteAllWorkers(RIVET_CLIENT_CONFIG, false); + // This takes time and slows down tests -- it's fine if we leak workers that'll be cleaned up in the next run + // await deleteAllWorkers(rivetClientConfig); }, }; }, -}; +}); + +async function deleteAllWorkers(clientConfig: RivetClientConfig) { + // TODO: This is not paginated -async function deleteAllWorkers( - clientConfig: RivetClientConfig, - deleteManager: boolean, -) { console.log("Listing workers to delete"); - const { workers } = await rivetRequest< + const { actors } = await rivetRequest< void, - { workers: { id: string; tags: Record }[] } - >(clientConfig, "GET", "/workers"); + { actors: { id: string; tags: Record }[] } + >(clientConfig, "GET", "/actors"); - for (const worker of workers) { - if (!deleteManager && worker.tags.name === "manager") continue; + for (const actor of actors) { + if (actor.tags.role !== "worker") continue; - console.log(`Deleting worker ${worker.id} (${JSON.stringify(worker.tags)})`); + console.log(`Deleting worker ${actor.id} (${JSON.stringify(actor.tags)})`); await rivetRequest( clientConfig, "DELETE", - `/workers/${worker.id}`, + `/actors/${actor.id}`, ); } } - -// Run the driver tests with our config -runDriverTests(driverTestConfig); diff --git a/packages/platforms/rivet/tests/rivet-deploy.ts b/packages/platforms/rivet/tests/rivet-deploy.ts index 84e9c99ef..4d5cddcfc 100644 --- a/packages/platforms/rivet/tests/rivet-deploy.ts +++ b/packages/platforms/rivet/tests/rivet-deploy.ts @@ -5,75 +5,216 @@ import { spawn, exec } from "node:child_process"; import crypto from "node:crypto"; import { promisify } from "node:util"; import invariant from "invariant"; -import type { RivetClientConfig } from "../src/rivet-client"; +import { RivetClient } from "@rivet-gg/api"; +import { RivetClientConfig } from "../src/rivet-client"; const execPromise = promisify(exec); -//const RIVET_API_ENDPOINT = "https://api.rivet.gg"; -const RIVET_API_ENDPOINT = "http://localhost:8080"; -const ENV = "default"; - -const rivetCloudToken = process.env.RIVET_CLOUD_TOKEN; +const apiEndpoint = process.env.RIVET_ENDPOINT!; +invariant(apiEndpoint, "missing RIVET_ENDPOINT"); +const rivetCloudToken = process.env.RIVET_CLOUD_TOKEN!; invariant(rivetCloudToken, "missing RIVET_CLOUD_TOKEN"); -export const RIVET_CLIENT_CONFIG: RivetClientConfig = { - endpoint: RIVET_API_ENDPOINT, +const project = process.env.RIVET_PROJECT!; +invariant(project, "missing RIVET_PROJECT"); +const environment = process.env.RIVET_ENVIRONMENT!; +invariant(environment, "missing RIVET_ENVIRONMENT"); + +export const rivetClientConfig: RivetClientConfig = { + endpoint: apiEndpoint, token: rivetCloudToken, + project, + environment, }; +const rivetClient = new RivetClient({ + environment: apiEndpoint, + token: rivetCloudToken, +}); + +/** + * Helper function to write a file to the filesystem + */ +async function writeFile( + dirPath: string, + filename: string, + content: string | object, +): Promise { + const filePath = path.join(dirPath, filename); + const fileContent = + typeof content === "string" ? content : JSON.stringify(content, null, 2); + + console.log(`Writing ${filename}`); + await fs.writeFile(filePath, fileContent); +} + +/** + * Pack a package using yarn pack and return the path to the packed tarball + */ +async function packPackage( + packageDir: string, + tmpDir: string, + packageName: string, +): Promise { + console.log(`Packing package from ${packageDir}...`); + // Generate a unique filename + const outputFileName = `${packageName}-${crypto.randomUUID()}.tgz`; + const outputPath = path.join(tmpDir, outputFileName); + + // Run yarn pack with specific output path + await execPromise(`yarn pack --install-if-needed --out ${outputPath}`, { + cwd: packageDir, + }); + console.log(`Generated tarball at ${outputPath}`); + return outputFileName; +} + /** * Deploy an app to Rivet and return the endpoint */ -export async function deployToRivet(appPath: string, deployManager: boolean) { +export async function deployToRivet(projectPath: string) { console.log("=== START deployToRivet ==="); - console.log(`Deploying app from path: ${appPath}`); + console.log(`Deploying app from path: ${projectPath}`); // Create a temporary directory for the test const uuid = crypto.randomUUID(); - const appName = `worker-core-test-${uuid}`; - const tmpDir = path.join(os.tmpdir(), appName); + const tmpDirName = `worker-core-test-${uuid}`; + const tmpDir = path.join(os.tmpdir(), tmpDirName); console.log(`Creating temp directory: ${tmpDir}`); await fs.mkdir(tmpDir, { recursive: true }); - // Create package.json with workspace dependencies + // Get the workspace root and package paths + const workspaceRoot = path.resolve(__dirname, "../../../.."); + const rivetPlatformPath = path.resolve(__dirname, "../"); + const rivetkitCorePath = path.resolve(workspaceRoot, "packages/core"); + + // Pack the required packages directly to the temp directory + console.log("Packing required packages..."); + const rivetPlatformFilename = await packPackage( + rivetPlatformPath, + tmpDir, + "rivetkit-rivet", + ); + const rivetkitFilename = await packPackage( + rivetkitCorePath, + tmpDir, + "rivetkit", + ); + + // Create package.json with file dependencies const packageJson = { name: "rivetkit-test", private: true, version: "1.0.0", - type: "module", scripts: { - deploy: "rivetkit deploy rivet app.ts --env prod", + build: "tsc", }, dependencies: { - "@rivetkit/rivet": "workspace:*", - "rivetkit": "workspace:*", + "@rivetkit/rivet": `file:./${rivetPlatformFilename}`, + rivetkit: `file:./${rivetkitFilename}`, + }, + devDependencies: { + typescript: "^5.3.0", }, packageManager: "yarn@4.7.0+sha512.5a0afa1d4c1d844b3447ee3319633797bcd6385d9a44be07993ae52ff4facabccafb4af5dcd1c2f9a94ac113e5e9ff56f6130431905884414229e284e37bb7c9", }; - console.log("Writing package.json"); - await fs.writeFile( - path.join(tmpDir, "package.json"), - JSON.stringify(packageJson, null, 2), - ); + await writeFile(tmpDir, "package.json", packageJson); + + // Create rivet.json with workspace dependencies + const rivetJson = { + functions: { + manager: { + tags: { role: "manager", framework: "rivetkit" }, + dockerfile: "Dockerfile", + runtime: { + environment: { + RIVET_API_ENDPOINT: apiEndpoint, + RIVET_SERVICE_TOKEN: rivetCloudToken, // TODO: This should be a service token, but both work + RIVET_PROJECT: project, + RIVET_ENVIRONMENT: environment, + _LOG_LEVEL: "DEBUG", + _WORKER_LOG_LEVEL: "DEBUG", + }, + }, + resources: { + cpu: 250, + memory: 256, + }, + }, + }, + actors: { + worker: { + tags: { role: "worker", framework: "rivetkit" }, + script: "src/worker.ts", + }, + }, + }; + await writeFile(tmpDir, "rivet.json", rivetJson); + + // Create Dockerfile + const dockerfile = ` +FROM node:22-alpine AS builder + +RUN npm i -g corepack && corepack enable + +WORKDIR /app + +COPY package.json .yarnrc.yml ./ +COPY *.tgz ./ + +RUN --mount=type=cache,target=/app/.yarn/cache \ + yarn install + +COPY . . +# HACK: Remove worker.ts bc file is invalid in Node +RUN rm src/worker.ts && yarn build + +RUN --mount=type=cache,target=/app/.yarn/cache \ + yarn workspaces focus --production + +FROM node:22-alpine AS runtime + +RUN addgroup -g 1001 -S rivet && \ + adduser -S rivet -u 1001 -G rivet + +WORKDIR /app + +COPY --from=builder --chown=rivet:rivet /app/dist ./dist +COPY --from=builder --chown=rivet:rivet /app/node_modules ./node_modules +COPY --from=builder --chown=rivet:rivet /app/package.json ./ + +USER rivet + +CMD ["node", "dist/server.js"] +`; + await writeFile(tmpDir, "Dockerfile", dockerfile); + + // Create .dockerignore + const dockerignore = ` +node_modules +`; + await writeFile(tmpDir, ".dockerignore", dockerignore); // Disable PnP const yarnPnp = "nodeLinker: node-modules"; - console.log("Configuring Yarn nodeLinker"); - await fs.writeFile(path.join(tmpDir, ".yarnrc.yml"), yarnPnp); + await writeFile(tmpDir, ".yarnrc.yml", yarnPnp); - // Get the current workspace root path and link the workspace - const workspaceRoot = path.resolve(__dirname, "../../../.."); - console.log(`Linking workspace from: ${workspaceRoot}`); - - try { - console.log("Running yarn link command..."); - const linkOutput = await execPromise(`yarn link -A ${workspaceRoot}`, { - cwd: tmpDir, - }); - console.log("Yarn link output:", linkOutput.stdout); - } catch (error) { - console.error("Error linking workspace:", error); - throw error; - } + // Create tsconfig.json + const tsconfig = { + compilerOptions: { + target: "ESNext", + module: "NodeNext", + moduleResolution: "NodeNext", + esModuleInterop: true, + strict: true, + skipLibCheck: true, + forceConsistentCasingInFileNames: true, + outDir: "dist", + sourceMap: true, + declaration: true, + }, + include: ["src/**/*.ts"], + }; + await writeFile(tmpDir, "tsconfig.json", tsconfig); // Install deps console.log("Installing dependencies..."); @@ -85,36 +226,47 @@ export async function deployToRivet(appPath: string, deployManager: boolean) { throw error; } - // Create app.ts file based on the app path - const appTsContent = `export { app } from "${appPath.replace(/\.ts$/, "")}"`; - console.log(`Creating app.ts with content: ${appTsContent}`); - await fs.writeFile(path.join(tmpDir, "app.ts"), appTsContent); + // Copy project to test directory + console.log(`Copying project from ${projectPath} to ${tmpDir}/src/workers`); + const projectDestDir = path.join(tmpDir, "src", "workers"); + await fs.cp(projectPath, projectDestDir, { recursive: true }); + + const serverTsContent = `import { startManager } from "@rivetkit/rivet/manager"; +import { app } from "./workers/app"; + +// TODO: Find a cleaner way of flagging an app as test mode (ideally not in the config itself) +// Force enable test +app.config.test.enabled = true; + +startManager(app); +`; + await writeFile(tmpDir, "src/server.ts", serverTsContent); + + const workerTsContent = `import { createWorkerHandler } from "@rivetkit/rivet/worker"; +import { app } from "./workers/app"; + +// TODO: Find a cleaner way of flagging an app as test mode (ideally not in the config itself) +// Force enable test +app.config.test.enabled = true; + +export default createWorkerHandler(app);`; + await writeFile(tmpDir, "src/worker.ts", workerTsContent); // Build and deploy to Rivet using worker-core CLI console.log("Building and deploying to Rivet..."); if (!process.env._RIVET_SKIP_DEPLOY) { - // Deploy using the worker-core CLI - console.log("Spawning rivetkit/cli deploy command..."); + // Deploy using the rivet CLI + console.log("Spawning rivet deploy command..."); const deployProcess = spawn( - "npx", - [ - "@rivetkit/cli", - "deploy", - "rivet", - "app.ts", - "--env", - ENV, - ...(deployManager ? [] : ["--skip-manager"]), - ], + "rivet", + ["deploy", "--environment", environment, "--non-interactive"], { cwd: tmpDir, env: { ...process.env, - RIVET_ENDPOINT: RIVET_API_ENDPOINT, + RIVET_ENDPOINT: apiEndpoint, RIVET_CLOUD_TOKEN: rivetCloudToken, - _RIVET_MANAGER_LOG_LEVEL: "DEBUG", - _RIVET_WORKER_LOG_LEVEL: "DEBUG", //CI: "1", }, stdio: "inherit", // Stream output directly to console @@ -124,7 +276,6 @@ export async function deployToRivet(appPath: string, deployManager: boolean) { console.log("Waiting for deploy process to complete..."); await new Promise((resolve, reject) => { deployProcess.on("exit", (code) => { - console.log(`Deploy process exited with code: ${code}`); if (code === 0) { resolve(undefined); } else { @@ -142,17 +293,31 @@ export async function deployToRivet(appPath: string, deployManager: boolean) { // Get the endpoint URL console.log("Getting Rivet endpoint..."); + // // HACK: We have to get the endpoint of the actor directly since we can't route functions with hostnames on localhost yet + // const { actors } = await rivetClient.actors.list({ + // tagsJson: JSON.stringify({ + // type: "function", + // function: "manager", + // appName, + // }), + // project, + // environment, + // }); + // const managerActor = actors[0]; + // invariant(managerActor, "missing manager actor"); + // const endpoint = managerActor.network.ports.http?.url; + // invariant(endpoint, "missing manager actor endpoint"); + + // TODO: This doesn't work in local dev since we can't route functions on localhost yet // Get the endpoint using the CLI endpoint command - console.log("Spawning rivetkit/cli endpoint command..."); + console.log("Spawning rivet function endpoint command..."); const endpointProcess = spawn( - "npx", - ["@rivetkit/cli", "endpoint", "rivet", "--env", ENV, "--plain"], + "rivet", + ["function", "endpoint", "--environment", environment, "manager"], { cwd: tmpDir, env: { ...process.env, - RIVET_ENDPOINT: RIVET_API_ENDPOINT, - RIVET_CLOUD_TOKEN: rivetCloudToken, CI: "1", }, stdio: ["inherit", "pipe", "inherit"], // Capture stdout @@ -192,9 +357,9 @@ export async function deployToRivet(appPath: string, deployManager: boolean) { const endpoint = lines[lines.length - 1]; invariant(endpoint, "endpoint not found"); + console.log("Manager endpoint", endpoint); + console.log("=== END deployToRivet ==="); - return { - endpoint, - }; + return endpoint; } diff --git a/packages/platforms/rivet/turbo.json b/packages/platforms/rivet/turbo.json index 2eb3a6aff..ecc7d8f3b 100644 --- a/packages/platforms/rivet/turbo.json +++ b/packages/platforms/rivet/turbo.json @@ -3,8 +3,14 @@ "extends": ["//"], "tasks": { "test": { - "dependsOn": ["^build", "check-types", "build", "@rivetkit/cli#build"], - "env": ["RIVET_API_ENDPOINT", "RIVET_CLOUD_TOKEN", "_RIVET_SKIP_DEPLOY"] + "dependsOn": ["^build", "check-types", "build"], + "env": [ + "RIVET_ENDPOINT", + "RIVET_CLOUD_TOKEN", + "RIVET_PROJECT", + "RIVET_ENVIRONMENT", + "_RIVET_SKIP_DEPLOY" + ] } } } diff --git a/packages/platforms/rivet/vitest.config.ts b/packages/platforms/rivet/vitest.config.ts index 6d2309ffe..62f33085a 100644 --- a/packages/platforms/rivet/vitest.config.ts +++ b/packages/platforms/rivet/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { globals: true, environment: "node", - testTimeout: 60_000, - hookTimeout: 60_000, + testTimeout: 120_000, + hookTimeout: 120_000, }, }); diff --git a/yarn.lock b/yarn.lock index 3b0ed7117..1cb9c575b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1071,6 +1071,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.14.4": + version: 1.14.4 + resolution: "@hono/node-server@npm:1.14.4" + peerDependencies: + hono: ^4 + checksum: 10c0/1dc6296ddda0b9708cbafb406e22fb3d937505b80ec991483a5066ffa162da2cb2331b4591e7b4bbc07c62e2bea7b3a751cb0fcff33f9fba870ff95a694261ef + languageName: node + linkType: hard + "@hono/node-ws@npm:^1.0.8, @hono/node-ws@npm:^1.1.1": version: 1.1.1 resolution: "@hono/node-ws@npm:1.1.1" @@ -1083,6 +1092,18 @@ __metadata: languageName: node linkType: hard +"@hono/node-ws@npm:^1.1.7": + version: 1.1.7 + resolution: "@hono/node-ws@npm:1.1.7" + dependencies: + ws: "npm:^8.17.0" + peerDependencies: + "@hono/node-server": ^1.11.1 + hono: ^4.6.0 + checksum: 10c0/b568ac441d1e0b8d348ef3571933cfad444b192b2492bac221ce18065e7e4eec31fe7f3ebe15dd6cadafb5f4bae283939aada561b3890c25cfbf5122e54f7ed9 + languageName: node + linkType: hard + "@hono/zod-openapi@npm:^0.19.6": version: 0.19.6 resolution: "@hono/zod-openapi@npm:0.19.6" @@ -1589,6 +1610,13 @@ __metadata: languageName: node linkType: hard +"@polka/url@npm:^1.0.0-next.24": + version: 1.0.0-next.29 + resolution: "@polka/url@npm:1.0.0-next.29" + checksum: 10c0/0d58e081844095cb029d3c19a659bfefd09d5d51a2f791bc61eba7ea826f13d6ee204a8a448c2f5a855c17df07b37517373ff916dd05801063c0568ae9937684 + languageName: node + linkType: hard + "@react-email/render@npm:0.0.9": version: 0.0.9 resolution: "@react-email/render@npm:0.0.9" @@ -1610,6 +1638,20 @@ __metadata: languageName: node linkType: hard +"@rivet-gg/api@npm:^25.4.2": + version: 25.4.2 + resolution: "@rivet-gg/api@npm:25.4.2" + dependencies: + form-data: "npm:^4.0.0" + js-base64: "npm:^3.7.5" + node-fetch: "npm:2" + qs: "npm:^6.11.2" + readable-stream: "npm:^4.5.2" + url-join: "npm:^5.0.0" + checksum: 10c0/eb6a25b1468b9cd8f9b548fa7cdec948d8bcc21bc1274b06507b1b519cbba739cc828974a0917ebee9ab18c92ba7fe228d8ac596b3e71c5efaf4f4f8ed12c8f1 + languageName: node + linkType: hard + "@rivetkit/bun@workspace:packages/platforms/bun": version: 0.0.0-use.local resolution: "@rivetkit/bun@workspace:packages/platforms/bun" @@ -1754,7 +1796,10 @@ __metadata: version: 0.0.0-use.local resolution: "@rivetkit/rivet@workspace:packages/platforms/rivet" dependencies: + "@hono/node-server": "npm:^1.14.4" + "@hono/node-ws": "npm:^1.1.7" "@rivet-gg/actor-core": "npm:^25.1.0" + "@rivet-gg/api": "npm:^25.4.2" "@types/deno": "npm:^2.0.0" "@types/invariant": "npm:^2" "@types/node": "npm:^22.13.1" @@ -2227,6 +2272,23 @@ __metadata: languageName: node linkType: hard +"@vitest/ui@npm:3.1.1": + version: 3.1.1 + resolution: "@vitest/ui@npm:3.1.1" + dependencies: + "@vitest/utils": "npm:3.1.1" + fflate: "npm:^0.8.2" + flatted: "npm:^3.3.3" + pathe: "npm:^2.0.3" + sirv: "npm:^3.0.1" + tinyglobby: "npm:^0.2.12" + tinyrainbow: "npm:^2.0.0" + peerDependencies: + vitest: 3.1.1 + checksum: 10c0/03bd014a4afa2c4cd6007d8000d881c653414f30d275fe35067b3d50c8a07b9f53cb2a294a8d36adaece7e4671030f90bd51aedb412d64479b981e051e7996ba + languageName: node + linkType: hard + "@vitest/utils@npm:3.1.1": version: 3.1.1 resolution: "@vitest/utils@npm:3.1.1" @@ -2252,6 +2314,15 @@ __metadata: languageName: node linkType: hard +"abort-controller@npm:^3.0.0": + version: 3.0.0 + resolution: "abort-controller@npm:3.0.0" + dependencies: + event-target-shim: "npm:^5.0.0" + checksum: 10c0/90ccc50f010250152509a344eb2e71977fbf8db0ab8f1061197e3275ddf6c61a41a6edfd7b9409c664513131dd96e962065415325ef23efa5db931b382d24ca5 + languageName: node + linkType: hard + "accepts@npm:^2.0.0": version: 2.0.0 resolution: "accepts@npm:2.0.0" @@ -2367,6 +2438,13 @@ __metadata: languageName: node linkType: hard +"asynckit@npm:^0.4.0": + version: 0.4.0 + resolution: "asynckit@npm:0.4.0" + checksum: 10c0/d73e2ddf20c4eb9337e1b3df1a0f6159481050a5de457c55b14ea2e5cb6d90bb69e004c9af54737a5ee0917fcf2c9e25de67777bbe58261847846066ba75bc9d + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -2374,6 +2452,13 @@ __metadata: languageName: node linkType: hard +"base64-js@npm:^1.3.1": + version: 1.5.1 + resolution: "base64-js@npm:1.5.1" + checksum: 10c0/f23823513b63173a001030fae4f2dabe283b99a9d324ade3ad3d148e218134676f1ee8568c877cd79ec1c53158dcf2d2ba527a97c606618928ba99dd930102bf + languageName: node + linkType: hard + "before-after-hook@npm:^2.2.0": version: 2.2.3 resolution: "before-after-hook@npm:2.2.3" @@ -2437,6 +2522,16 @@ __metadata: languageName: node linkType: hard +"buffer@npm:^6.0.3": + version: 6.0.3 + resolution: "buffer@npm:6.0.3" + dependencies: + base64-js: "npm:^1.3.1" + ieee754: "npm:^1.2.1" + checksum: 10c0/2a905fbbcde73cc5d8bd18d1caa23715d5f83a5935867c2329f0ac06104204ba7947be098fe1317fbd8830e26090ff8e764f08cd14fefc977bb248c3487bcbd0 + languageName: node + linkType: hard + "bun-types@npm:1.2.7": version: 1.2.7 resolution: "bun-types@npm:1.2.7" @@ -2694,6 +2789,15 @@ __metadata: languageName: node linkType: hard +"combined-stream@npm:^1.0.8": + version: 1.0.8 + resolution: "combined-stream@npm:1.0.8" + dependencies: + delayed-stream: "npm:~1.0.0" + checksum: 10c0/0dbb829577e1b1e839fa82b40c07ffaf7de8a09b935cadd355a73652ae70a88b4320db322f6634a4ad93424292fa80973ac6480986247f1734a1137debf271d5 + languageName: node + linkType: hard + "commander@npm:^10.0.0": version: 10.0.1 resolution: "commander@npm:10.0.1" @@ -2901,6 +3005,13 @@ __metadata: languageName: node linkType: hard +"delayed-stream@npm:~1.0.0": + version: 1.0.0 + resolution: "delayed-stream@npm:1.0.0" + checksum: 10c0/d758899da03392e6712f042bec80aa293bbe9e9ff1b2634baae6a360113e708b91326594c8a486d475c69d6259afb7efacdc3537bfcda1c6c648e390ce601b19 + languageName: node + linkType: hard + "denque@npm:^2.1.0": version: 2.1.0 resolution: "denque@npm:2.1.0" @@ -3151,6 +3262,18 @@ __metadata: languageName: node linkType: hard +"es-set-tostringtag@npm:^2.1.0": + version: 2.1.0 + resolution: "es-set-tostringtag@npm:2.1.0" + dependencies: + es-errors: "npm:^1.3.0" + get-intrinsic: "npm:^1.2.6" + has-tostringtag: "npm:^1.0.2" + hasown: "npm:^2.0.2" + checksum: 10c0/ef2ca9ce49afe3931cb32e35da4dcb6d86ab02592cfc2ce3e49ced199d9d0bb5085fc7e73e06312213765f5efa47cc1df553a6a5154584b21448e9fb8355b1af + languageName: node + linkType: hard + "esbuild@npm:0.17.19": version: 0.17.19 resolution: "esbuild@npm:0.17.19" @@ -3536,6 +3659,20 @@ __metadata: languageName: node linkType: hard +"event-target-shim@npm:^5.0.0": + version: 5.0.1 + resolution: "event-target-shim@npm:5.0.1" + checksum: 10c0/0255d9f936215fd206156fd4caa9e8d35e62075d720dc7d847e89b417e5e62cf1ce6c9b4e0a1633a9256de0efefaf9f8d26924b1f3c8620cffb9db78e7d3076b + languageName: node + linkType: hard + +"events@npm:^3.3.0": + version: 3.3.0 + resolution: "events@npm:3.3.0" + checksum: 10c0/d6b6f2adbccbcda74ddbab52ed07db727ef52e31a61ed26db9feb7dc62af7fc8e060defa65e5f8af9449b86b52cc1a1f6a79f2eafcf4e62add2b7a1fa4a432f6 + languageName: node + linkType: hard + "eventsource-parser@npm:^3.0.1": version: 3.0.1 resolution: "eventsource-parser@npm:3.0.1" @@ -3668,6 +3805,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:^0.8.2": + version: 0.8.2 + resolution: "fflate@npm:0.8.2" + checksum: 10c0/03448d630c0a583abea594835a9fdb2aaf7d67787055a761515bf4ed862913cfd693b4c4ffd5c3f3b355a70cf1e19033e9ae5aedcca103188aaff91b8bd6e293 + languageName: node + linkType: hard + "fill-range@npm:^7.1.1": version: 7.1.1 resolution: "fill-range@npm:7.1.1" @@ -3691,6 +3835,13 @@ __metadata: languageName: node linkType: hard +"flatted@npm:^3.3.3": + version: 3.3.3 + resolution: "flatted@npm:3.3.3" + checksum: 10c0/e957a1c6b0254aa15b8cce8533e24165abd98fadc98575db082b786b5da1b7d72062b81bfdcd1da2f4d46b6ed93bec2434e62333e9b4261d79ef2e75a10dd538 + languageName: node + linkType: hard + "foreground-child@npm:^3.1.0": version: 3.3.1 resolution: "foreground-child@npm:3.3.1" @@ -3701,6 +3852,19 @@ __metadata: languageName: node linkType: hard +"form-data@npm:^4.0.0": + version: 4.0.3 + resolution: "form-data@npm:4.0.3" + dependencies: + asynckit: "npm:^0.4.0" + combined-stream: "npm:^1.0.8" + es-set-tostringtag: "npm:^2.1.0" + hasown: "npm:^2.0.2" + mime-types: "npm:^2.1.12" + checksum: 10c0/f0cf45873d600110b5fadf5804478377694f73a1ed97aaa370a74c90cebd7fe6e845a081171668a5476477d0d55a73a4e03d6682968fa8661eac2a81d651fcdb + languageName: node + linkType: hard + "formdata-polyfill@npm:^4.0.10": version: 4.0.10 resolution: "formdata-polyfill@npm:4.0.10" @@ -3793,7 +3957,7 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" dependencies: @@ -3922,13 +4086,22 @@ __metadata: languageName: node linkType: hard -"has-symbols@npm:^1.1.0": +"has-symbols@npm:^1.0.3, has-symbols@npm:^1.1.0": version: 1.1.0 resolution: "has-symbols@npm:1.1.0" checksum: 10c0/dde0a734b17ae51e84b10986e651c664379018d10b91b6b0e9b293eddb32f0f069688c841fb40f19e9611546130153e0a2a48fd7f512891fb000ddfa36f5a20e languageName: node linkType: hard +"has-tostringtag@npm:^1.0.2": + version: 1.0.2 + resolution: "has-tostringtag@npm:1.0.2" + dependencies: + has-symbols: "npm:^1.0.3" + checksum: 10c0/a8b166462192bafe3d9b6e420a1d581d93dd867adb61be223a17a8d6dad147aa77a8be32c961bb2f27b3ef893cae8d36f564ab651f5e9b7938ae86f74027c48c + languageName: node + linkType: hard + "hasown@npm:^2.0.2": version: 2.0.2 resolution: "hasown@npm:2.0.2" @@ -4019,6 +4192,13 @@ __metadata: languageName: node linkType: hard +"ieee754@npm:^1.2.1": + version: 1.2.1 + resolution: "ieee754@npm:1.2.1" + checksum: 10c0/b0782ef5e0935b9f12883a2e2aa37baa75da6e66ce6515c168697b42160807d9330de9a32ec1ed73149aea02e0d822e572bca6f1e22bdcbd2149e13b050b17bb + languageName: node + linkType: hard + "ignore@npm:^5.2.4": version: 5.3.2 resolution: "ignore@npm:5.3.2" @@ -4213,6 +4393,13 @@ __metadata: languageName: node linkType: hard +"js-base64@npm:^3.7.5": + version: 3.7.7 + resolution: "js-base64@npm:3.7.7" + checksum: 10c0/3c905a7e78b601e4751b5e710edd0d6d045ce2d23eb84c9df03515371e1b291edc72808dc91e081cb9855aef6758292a2407006f4608ec3705373dd8baf2f80f + languageName: node + linkType: hard + "js-beautify@npm:^1.6.12": version: 1.15.4 resolution: "js-beautify@npm:1.15.4" @@ -4609,6 +4796,13 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:1.52.0": + version: 1.52.0 + resolution: "mime-db@npm:1.52.0" + checksum: 10c0/0557a01deebf45ac5f5777fe7740b2a5c309c6d62d40ceab4e23da9f821899ce7a900b7ac8157d4548ddbb7beffe9abc621250e6d182b0397ec7f10c7b91a5aa + languageName: node + linkType: hard + "mime-db@npm:^1.54.0": version: 1.54.0 resolution: "mime-db@npm:1.54.0" @@ -4616,6 +4810,15 @@ __metadata: languageName: node linkType: hard +"mime-types@npm:^2.1.12": + version: 2.1.35 + resolution: "mime-types@npm:2.1.35" + dependencies: + mime-db: "npm:1.52.0" + checksum: 10c0/82fb07ec56d8ff1fc999a84f2f217aa46cb6ed1033fefaabd5785b9a974ed225c90dc72fff460259e66b95b73648596dbcc50d51ed69cdf464af2d237d3149b2 + languageName: node + linkType: hard + "mime-types@npm:^3.0.0, mime-types@npm:^3.0.1": version: 3.0.1 resolution: "mime-types@npm:3.0.1" @@ -4798,6 +5001,13 @@ __metadata: languageName: node linkType: hard +"mrmime@npm:^2.0.0": + version: 2.0.1 + resolution: "mrmime@npm:2.0.1" + checksum: 10c0/af05afd95af202fdd620422f976ad67dc18e6ee29beb03dd1ce950ea6ef664de378e44197246df4c7cdd73d47f2e7143a6e26e473084b9e4aa2095c0ad1e1761 + languageName: node + linkType: hard + "ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" @@ -4848,18 +5058,7 @@ __metadata: languageName: node linkType: hard -"node-fetch@npm:3.3.1": - version: 3.3.1 - resolution: "node-fetch@npm:3.3.1" - dependencies: - data-uri-to-buffer: "npm:^4.0.0" - fetch-blob: "npm:^3.1.4" - formdata-polyfill: "npm:^4.0.10" - checksum: 10c0/78671bffed741a2f3ccb15588a42fd7e9db2bdc9f99f9f584e0c749307f9603d961692f0877d853b28a4d1375ab2253b19978dd3bfc0c3189b42adc340bef927 - languageName: node - linkType: hard - -"node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7": +"node-fetch@npm:2, node-fetch@npm:^2.6.1, node-fetch@npm:^2.6.7": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" dependencies: @@ -4873,6 +5072,17 @@ __metadata: languageName: node linkType: hard +"node-fetch@npm:3.3.1": + version: 3.3.1 + resolution: "node-fetch@npm:3.3.1" + dependencies: + data-uri-to-buffer: "npm:^4.0.0" + fetch-blob: "npm:^3.1.4" + formdata-polyfill: "npm:^4.0.10" + checksum: 10c0/78671bffed741a2f3ccb15588a42fd7e9db2bdc9f99f9f584e0c749307f9603d961692f0877d853b28a4d1375ab2253b19978dd3bfc0c3189b42adc340bef927 + languageName: node + linkType: hard + "node-gyp-build-optional-packages@npm:5.1.1": version: 5.1.1 resolution: "node-gyp-build-optional-packages@npm:5.1.1" @@ -5205,6 +5415,13 @@ __metadata: languageName: node linkType: hard +"process@npm:^0.11.10": + version: 0.11.10 + resolution: "process@npm:0.11.10" + checksum: 10c0/40c3ce4b7e6d4b8c3355479df77aeed46f81b279818ccdc500124e6a5ab882c0cc81ff7ea16384873a95a74c4570b01b120f287abbdd4c877931460eca6084b3 + languageName: node + linkType: hard + "promise-retry@npm:^2.0.1": version: 2.0.1 resolution: "promise-retry@npm:2.0.1" @@ -5260,7 +5477,7 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.14.0": +"qs@npm:^6.11.2, qs@npm:^6.14.0": version: 6.14.0 resolution: "qs@npm:6.14.0" dependencies: @@ -5316,6 +5533,19 @@ __metadata: languageName: node linkType: hard +"readable-stream@npm:^4.5.2": + version: 4.7.0 + resolution: "readable-stream@npm:4.7.0" + dependencies: + abort-controller: "npm:^3.0.0" + buffer: "npm:^6.0.3" + events: "npm:^3.3.0" + process: "npm:^0.11.10" + string_decoder: "npm:^1.3.0" + checksum: 10c0/fd86d068da21cfdb10f7a4479f2e47d9c0a9b0c862fc0c840a7e5360201580a55ac399c764b12a4f6fa291f8cee74d9c4b7562e0d53b3c4b2769f2c98155d957 + languageName: node + linkType: hard + "readdirp@npm:^4.0.1": version: 4.1.2 resolution: "readdirp@npm:4.1.2" @@ -5415,6 +5645,7 @@ __metadata: "@types/invariant": "npm:^2" "@types/node": "npm:^22.13.1" "@types/ws": "npm:^8" + "@vitest/ui": "npm:3.1.1" bundle-require: "npm:^5.1.0" cbor-x: "npm:^1.6.0" eventsource: "npm:^3.0.5" @@ -5574,7 +5805,7 @@ __metadata: languageName: node linkType: hard -"safe-buffer@npm:5.2.1": +"safe-buffer@npm:5.2.1, safe-buffer@npm:~5.2.0": version: 5.2.1 resolution: "safe-buffer@npm:5.2.1" checksum: 10c0/6501914237c0a86e9675d4e51d89ca3c21ffd6a31642efeba25ad65720bce6921c9e7e974e5be91a786b25aa058b5303285d3c15dbabf983a919f5f630d349f3 @@ -5823,6 +6054,17 @@ __metadata: languageName: node linkType: hard +"sirv@npm:^3.0.1": + version: 3.0.1 + resolution: "sirv@npm:3.0.1" + dependencies: + "@polka/url": "npm:^1.0.0-next.24" + mrmime: "npm:^2.0.0" + totalist: "npm:^3.0.0" + checksum: 10c0/7cf64b28daa69b15f77b38b0efdd02c007b72bb3ec5f107b208ebf59f01b174ef63a1db3aca16d2df925501831f4c209be6ece3302b98765919ef5088b45bf80 + languageName: node + linkType: hard + "sisteransi@npm:^1.0.5": version: 1.0.5 resolution: "sisteransi@npm:1.0.5" @@ -6006,6 +6248,15 @@ __metadata: languageName: node linkType: hard +"string_decoder@npm:^1.3.0": + version: 1.3.0 + resolution: "string_decoder@npm:1.3.0" + dependencies: + safe-buffer: "npm:~5.2.0" + checksum: 10c0/810614ddb030e271cd591935dcd5956b2410dd079d64ff92a1844d6b7588bf992b3e1b69b0f4d34a3e06e0bd73046ac646b5264c1987b20d0601f81ef35d731d + languageName: node + linkType: hard + "strip-ansi-cjs@npm:strip-ansi@^6.0.1, strip-ansi@npm:^6.0.0, strip-ansi@npm:^6.0.1": version: 6.0.1 resolution: "strip-ansi@npm:6.0.1" @@ -6179,6 +6430,13 @@ __metadata: languageName: node linkType: hard +"totalist@npm:^3.0.0": + version: 3.0.1 + resolution: "totalist@npm:3.0.1" + checksum: 10c0/4bb1fadb69c3edbef91c73ebef9d25b33bbf69afe1e37ce544d5f7d13854cda15e47132f3e0dc4cafe300ddb8578c77c50a65004d8b6e97e77934a69aa924863 + languageName: node + linkType: hard + "tr46@npm:^1.0.1": version: 1.0.1 resolution: "tr46@npm:1.0.1" @@ -6496,6 +6754,13 @@ __metadata: languageName: node linkType: hard +"url-join@npm:^5.0.0": + version: 5.0.0 + resolution: "url-join@npm:5.0.0" + checksum: 10c0/ed2b166b4b5a98adcf6828a48b6bd6df1dac4c8a464a73cf4d8e2457ed410dd8da6be0d24855b86026cd7f5c5a3657c1b7b2c7a7c5b8870af17635a41387b04c + languageName: node + linkType: hard + "use-sync-external-store@npm:^1.4.0": version: 1.5.0 resolution: "use-sync-external-store@npm:1.5.0"