code-runner
Guides

Integrate in React

Subscribe to live output in the browser with the useCodeRunnerJob hook.

@teovilla/code-runner-react is the browser side of the integration. It wraps pusher-js to subscribe to a job's private-run-<jobId> channel over soketi and reassembles ordered stdout/stderr for you. It carries no secrets and no bearer token — actions like stdin and kill are delegated back to your backend through callbacks.

Install

pnpm add @teovilla/code-runner-react pusher-js
npm install @teovilla/code-runner-react pusher-js
yarn add @teovilla/code-runner-react pusher-js

react >= 18 and pusher-js >= 8 are peer dependencies.

Wrap your app in the provider

CodeRunnerProvider lazily creates a single pusher-js connection and supplies it to every hook below it.

import { CodeRunnerProvider } from '@teovilla/code-runner-react';

export function App() {
  return (
    <CodeRunnerProvider
      appKey={import.meta.env.VITE_SOKETI_APP_KEY} // public app key
      host="localhost"                              // soketi host
      port={6001}
      useTLS={false}
      authEndpoint="/code-runner/auth"              // YOUR backend's channel-auth route
    >
      <YourRoutes />
    </CodeRunnerProvider>
  );
}
PropTypeNotes
appKeystringThe public soketi app key. Safe in the browser.
hoststringsoketi hostname.
portnumber?Defaults to 6001.
useTLSboolean?true for wss://. Defaults to false.
clusterstring?Leave unset for self-hosted soketi.
authEndpointstringYour backend route that signs private-channel auth.
authHeadersRecord<string,string>?Extra headers (session cookie, CSRF) for the auth request.

authEndpoint points at your backend, not at code-runner. That route uses the Node SDK's createChannelAuthorizer to sign the subscription with the app secret. The browser never sees the secret.

Subscribe to a job

import { useCodeRunnerJob } from '@teovilla/code-runner-react';

function JobView({ jobId }: { jobId: string }) {
  const { stage, stdout, stderr, compileOutput, result, status, sendStdin, kill } =
    useCodeRunnerJob({
      jobId,
      // The start-handshake: subscribe FIRST, then start once soketi confirms it.
      onSubscribed: () => fetch(`/api/jobs/${jobId}/start`, { method: 'POST' }),
      // sendStdin / kill don't talk to code-runner directly — they call YOUR backend,
      // which holds the bearer token and forwards to the code-runner API.
      onStdin: (chunk) =>
        fetch(`/api/jobs/${jobId}/stdin`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ chunk }),
        }),
      onKill: () => fetch(`/api/jobs/${jobId}/kill`, { method: 'POST' }),
    });

  return (
    <div>
      <p>stage: {stage ?? '—'} · status: {status}</p>
      {/* Compiled languages stream a live build log here, separate from the run. */}
      {compileOutput && <pre>{compileOutput}</pre>}
      <pre>{stdout}</pre>
      <pre style={{ color: 'crimson' }}>{stderr}</pre>
      <button onClick={() => sendStdin('World\n')}>Send stdin</button>
      <button onClick={() => kill()}>Kill</button>
      {result && <p>exit {result.exitCode} · {result.durationMs}ms</p>}
    </div>
  );
}

Hook arguments

FieldTypeNotes
jobIdstringThe job to subscribe to.
channelstring?Override; defaults to private-run-${jobId}.
onStdin(chunk: string) => void | Promise<void>Called by sendStdin. Route to your backend.
onKill() => void | Promise<void>Called by kill. Route to your backend.
onSubscribed() => void | Promise<void>Fires once soketi confirms the subscription. The correct place to trigger the job's start (the start-handshake).

Hook return value

FieldTypeNotes
stageStagePhase | null"queued" | "compiling" | "running".
stdoutstringReassembled, ordered stdout (program run output only).
stderrstringReassembled, ordered stderr (program run output only).
compileOutputstringLive build log of the compile stage (compiled languages only), reassembled in emission order. Separate from stdout/stderr so you can render a dedicated build panel. Empty for interpreted languages, before the compiler emits anything, or on a clean compile with no warnings/errors.
resultResultEvent | nullTerminal result once the job ends.
artifactsArtifact[]Captured workspace artifacts (if any).
status"idle" | "running" | "done"Coarse lifecycle state.
sendStdin(chunk: string) => void | Promise<void>Invokes onStdin.
kill() => void | Promise<void>Invokes onKill.

The hook handles chunk ordering by seq internally, so stdout/stderr (and compileOutput) are always correctly ordered even if WebSocket frames arrive out of order.

Start-handshake

A job runs the instant it is started — and can finish in single-digit milliseconds. If you call start before this client has finished subscribing, the output is emitted to nobody and you see nothing. So subscribe first, then start: pass onSubscribed and trigger the job's start (via your backend) only once soketi confirms the subscription (pusher:subscription_succeeded).

useCodeRunnerJob({
  jobId,
  onSubscribed: () => fetch(`/api/jobs/${jobId}/start`, { method: 'POST' }),
});

The compile-stage build log (compileOutput) arrives on its own compile_output event, so a clean compile that prints nothing simply leaves compileOutput empty — there is no run output to confuse it with.

Putting it together

Read Interactive Sessions for the complete backend + frontend flow, including who calls execute, who subscribes, and who calls start.

On this page