Skip to content

feat(rivetkit): move HTTP listener to Rust, auto-listen on prod start()#5132

Open
abcxff wants to merge 1 commit into
mainfrom
06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_
Open

feat(rivetkit): move HTTP listener to Rust, auto-listen on prod start()#5132
abcxff wants to merge 1 commit into
mainfrom
06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_

Conversation

@abcxff
Copy link
Copy Markdown
Contributor

@abcxff abcxff commented Jun 3, 2026

Description

Please include a summary of the changes and the related issue. Please also include relevant motivation and context.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • This change requires a documentation update

How Has This Been Tested?

Please describe the tests that you ran to verify your changes.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes

Copy link
Copy Markdown
Contributor Author

abcxff commented Jun 3, 2026

This stack of pull requests is managed by Graphite. Learn more about stacking.

@railway-app
Copy link
Copy Markdown

railway-app Bot commented Jun 3, 2026

🚅 Deployed to the rivet-pr-5132 environment in rivet-frontend

Service Status Web Updated (UTC)
website 😴 Sleeping (View Logs) Web Jun 3, 2026 at 11:20 pm
kitchen-sink 😴 Sleeping (View Logs) Web Jun 3, 2026 at 11:19 pm
frontend-cloud 😴 Sleeping (View Logs) Web Jun 3, 2026 at 11:18 pm
frontend-inspector ❌ Build Failed (View Logs) Web Jun 3, 2026 at 11:09 pm
mcp-hub ✅ Success (View Logs) Web Jun 3, 2026 at 11:08 pm
ladle ✅ Success (View Logs) Web Jun 3, 2026 at 6:58 pm

@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from 8945f57 to df0149e Compare June 3, 2026 18:43
@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from df0149e to f4e0a85 Compare June 3, 2026 18:55
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Jun 3, 2026

PR Review: feat(rivetkit): move HTTP listener to Rust, auto-listen on prod start()

This is a solid architectural improvement. Moving the HTTP listener to native Rust eliminates the multi-runtime (Node/Bun/Deno) abstraction in serve.ts and unifies serving under axum. The public_dir static-file fallback and PORT env-var integration are well-motivated. A few issues to address before landing:

Medium: 413 error body reports size=0 when Content-Length is absent

In serverless_http.rs forward_request, the .unwrap_or(0) fallback means that when the client omits Content-Length (common with chunked-encoded uploads), the response reports size=0, limit=N which is misleading since the body was clearly larger than N. Since to_bytes fails at exactly body_limit bytes, .unwrap_or(body_limit) is the more accurate fallback.

Low: Silent drops in into_axum_response should log

Both the invalid-header drop and the invalid-status-code fallback to 500 are silent. Both should emit tracing::warn! to make serverless-handler bugs observable at the axum layer.

Low: start() auto-promote has a silent-failure path on non-Node runtimes

In registry/index.ts, if listen() throws (e.g. the WASM runtime explicit error) and process is not available, the error is logged but execution continues silently. The user intended start() to boot an envoy, not fail silently. Consider propagating the error or documenting that production start() is unsupported outside Node.

Low: Test polling loop should use vi.waitFor

waitForResponse in listener.test.ts is a hand-rolled while (Date.now() < deadline) poll. The project convention, enforced by pnpm run check:wait-for-comments, is to use vi.waitFor with an explanatory comment on the immediately preceding line. A custom loop bypasses that check.

Observation: no test coverage for RIVETKIT_AUTO_LISTEN or publicDir

The two headline features have no direct test coverage. A unit test for shouldAutoListen() based on the env var and NODE_ENV would be small and prevent regressions.

Nits

  • shutdown_fut in serverless_http.rs::serve has an unnecessary extra clone() and block; let shutdown_fut = async move { shutdown.cancelled().await }; is sufficient since shutdown moves into the closure.
  • forward_request calls .to_ascii_lowercase() on each header name, but axum normalizes HeaderName to lowercase already -- redundant.

@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from f4e0a85 to d07e88c Compare June 3, 2026 19:03
@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from d07e88c to 106d943 Compare June 3, 2026 19:24
@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from 106d943 to 992bb34 Compare June 3, 2026 19:45
@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from 992bb34 to cc44f96 Compare June 3, 2026 22:11
@abcxff abcxff requested a review from NathanFlurry June 3, 2026 22:12
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Jun 3, 2026

Code Review

This PR moves the HTTP listener from TypeScript (Hono + @hono/node-server) to Rust (axum), with auto-listen promotion in start() for production. Good direction overall — the native axum server is more reliable and removes cross-platform runtime detection. A few issues worth addressing:


Bug: shutdown() calls shutdownRegistry twice on the same handle

In listen(), both private fields are assigned the same promise:

// registry/index.ts
const configuredRegistryPromise = buildConfiguredRegistry(config);
this.#runtimeServeConfiguredPromise = configuredRegistryPromise;
this.#runtimeServerlessPromise = configuredRegistryPromise;  // same object

Then shutdown() drains both independently:

if (this.#runtimeServeConfiguredPromise) drains.push(drain(this.#runtimeServeConfiguredPromise));
if (this.#runtimeServerlessPromise)      drains.push(drain(this.#runtimeServerlessPromise));

This calls runtime.shutdownRegistry(registry) twice on the same handle. Mode A (serve()) and Mode B (listen()) are documented as mutually exclusive per instance, so there should not be two drains on the same registry. Either set only one field in listen(), or deduplicate in shutdown().


Potentially breaking: start() auto-listen activates on any NODE_ENV=production

#shouldAutoListen(): boolean {
    const override = getRivetkitAutoListen();
    if (override !== undefined) return override;
    return getNodeEnv() === "production";
}

Many production Dockerfiles set NODE_ENV=production as a convention. In Rivet-managed deployments the actor process is engine-spawned and expected to start an envoy WS connection, not bind an HTTP listener. If those containers have NODE_ENV=production, start() would bind an HTTP port instead of connecting to the envoy, silently changing mode.

This heuristic is fragile. Safer alternatives: detect via an engine-set env var like RIVETKIT_MODE=standalone, or require an explicit opt-in rather than inferring from NODE_ENV.


Welcome message printed before the server is bound

this.#printWelcome(config, "serverless", { port, host: opts.host, publicDir });
// ...
const { runtime, registry, serveConfig } = await configuredRegistryPromise;
await runtime.serveListener(registry, { port, host: opts.host, publicDir }, serveConfig);

The "Listening http://..." line is logged before serveListener actually binds the port. The Rust side also logs when it is ready (tracing::info!(..., "rivetkit server listening")), so users see two messages and the first one is early. Move #printWelcome after the bind, or remove the "Listening" line from the TypeScript welcome and rely on the Rust log.


start() forces publicDir = "/public" in production unconditionally

const publicDir = getRivetkitPublicDir() ?? "/public";
this.listen({ publicDir }).catch(...);

Even when the user has no /public directory, the axum router mounts a ServeDir there. tower_http::ServeDir falls back gracefully, but this is an unexpected default for users who have not opted into static file serving. If this is intentional, document it explicitly. Otherwise default to undefined and let users or RIVETKIT_PUBLIC_DIR opt in.


Regression: IncomingMessageTooLong no longer reports the actual received size

- "Incoming message too long. Received {size} bytes, limit is {limit} bytes."
+ "Incoming message too long. Exceeded limit of {limit} bytes."

The exact byte count is unavailable once body reading moves to the axum layer — that is a real constraint. But the change silently drops useful debugging information. Worth noting this trade-off in the PR description.


Minor: tests use a hand-rolled polling loop instead of vi.waitFor

while (Date.now() < deadline) {
    try { return await fetch(url); }
    catch (error) { lastError = error; await sleep(100); }
}

Per project conventions, vi.waitFor should be used for async-event coordination, with an immediately-preceding // comment explaining why polling is necessary. Consider converting waitForResponse to use vi.waitFor(() => fetch(url), { timeout: timeoutMs }) and adding that comment at each call site.


Nits

  • shutdown() does not wait for the listener to fully drain. #runtimeServePromise is checked but listen() never sets it, so that branch is a no-op for the listener path. After shutdownRegistry, the axum server may still be draining active connections. Storing the serveListener promise and awaiting it in shutdown() would close this gap.
  • host default is duplicated. The welcome message uses listener.host ?? "0.0.0.0" and axum also defaults to 0.0.0.0. A single shared constant would keep them in sync.

@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from cc44f96 to 8331535 Compare June 3, 2026 22:34
@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from 8331535 to 8086664 Compare June 3, 2026 22:43
@railway-app railway-app Bot temporarily deployed to rivet-frontend / rivet-pr-5132 June 3, 2026 22:43 Destroyed
@abcxff abcxff marked this pull request as ready for review June 3, 2026 22:47
@abcxff abcxff force-pushed the 06-03-feat_rivetkit_move_http_listener_to_rust_auto-listen_on_prod_start_ branch from 8086664 to 21b19a1 Compare June 3, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant