Browse Source

Add realtime session management core (OpenAI Realtime API via WS relay)

Provider-agnostic session layer for the base project: a shared client/server
wire protocol, a RealtimeProvider adapter interface, and an OpenAI Realtime
implementation of it, wired through a WebSocket relay (browser <-> our
SvelteKit server <-> OpenAI) so the API key never reaches the browser and
future providers/pipelines can be added without touching client code.

Switches the adapter to adapter-node and adds a custom server.js + dev-mode
Vite plugin, since SvelteKit has no native WebSocket support yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main
Jonathan Hinkel 2 months ago
parent
commit
27904bf647
  1. 11
      .claude/launch.json
  2. 6
      .env.example
  3. 1
      .gitignore
  4. 759
      package-lock.json
  5. 9
      package.json
  6. 30
      server.js
  7. 69
      src/lib/client/core/client.ts
  8. 46
      src/lib/core/protocol.ts
  9. 12
      src/lib/server/core/defaults.ts
  10. 42
      src/lib/server/core/dev-plugin.ts
  11. 36
      src/lib/server/core/provider.ts
  12. 91
      src/lib/server/core/providers/openai.spec.ts
  13. 174
      src/lib/server/core/providers/openai.ts
  14. 205
      src/lib/server/core/session.spec.ts
  15. 161
      src/lib/server/core/session.ts
  16. 48
      src/lib/server/core/tokens.spec.ts
  17. 66
      src/lib/server/core/tokens.ts
  18. 47
      src/lib/server/core/ws-handler.ts
  19. 8
      src/routes/api/realtime/session/+server.ts
  20. 117
      src/routes/demo/realtime/+page.svelte
  21. 17
      tsconfig.server.json
  22. 14
      vite.config.ts

11
.claude/launch.json

@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "webavatar-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 5173
}
]
}

6
.env.example

@ -0,0 +1,6 @@
# OpenAI API key used server-side only (never sent to the browser).
OPENAI_API_KEY=
# Secret used to sign short-lived browser session tokens for the realtime WS handshake.
# Generate one with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
REALTIME_TOKEN_SECRET=

1
.gitignore

@ -7,6 +7,7 @@ node_modules
.wrangler .wrangler
/.svelte-kit /.svelte-kit
/build /build
/build-server
# OS # OS
.DS_Store .DS_Store

759
package-lock.json

@ -7,13 +7,17 @@
"": { "": {
"name": "webavatar-base", "name": "webavatar-base",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"ws": "^8.21.3"
},
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^24", "@types/node": "^24",
"@types/ws": "^8.18.1",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.8",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -282,6 +286,26 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
"integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^22.20 || ^24.12 || >=25"
}
},
"node_modules/@oxc-project/types": { "node_modules/@oxc-project/types": {
"version": "0.146.0", "version": "0.146.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz",
@ -595,6 +619,537 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-commonjs": {
"version": "29.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz",
"integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
"is-reference": "1.2.1",
"magic-string": "^0.30.3",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=16.0.0 || 14 >= 14.17"
},
"peerDependencies": {
"rollup": "^2.68.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs/node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz",
"integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-replace": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz",
"integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"magic-string": "^0.30.3"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz",
"integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.0.tgz",
"integrity": "sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.0.tgz",
"integrity": "sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.0.tgz",
"integrity": "sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.0.tgz",
"integrity": "sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.0.tgz",
"integrity": "sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.0.tgz",
"integrity": "sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.0.tgz",
"integrity": "sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.0.tgz",
"integrity": "sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==",
"cpu": [
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.0.tgz",
"integrity": "sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.0.tgz",
"integrity": "sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.0.tgz",
"integrity": "sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.0.tgz",
"integrity": "sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==",
"cpu": [
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.0.tgz",
"integrity": "sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.0.tgz",
"integrity": "sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.0.tgz",
"integrity": "sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.0.tgz",
"integrity": "sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==",
"cpu": [
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.0.tgz",
"integrity": "sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.0.tgz",
"integrity": "sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.0.tgz",
"integrity": "sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.0.tgz",
"integrity": "sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.0.tgz",
"integrity": "sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.0.tgz",
"integrity": "sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.0.tgz",
"integrity": "sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.0.tgz",
"integrity": "sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.0.tgz",
"integrity": "sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@ -612,14 +1167,21 @@
"acorn": "^8.9.0" "acorn": "^8.9.0"
} }
}, },
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-node": {
"version": "7.0.1", "version": "5.5.7",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz",
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"@rollup/plugin-commonjs": "^29.0.0",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-replace": "^6.0.3",
"rollup": "^4.59.0"
},
"peerDependencies": { "peerDependencies": {
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.4.0"
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
@ -773,6 +1335,13 @@
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@ -780,6 +1349,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.68.0", "version": "8.68.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz",
@ -1299,6 +1878,13 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@ -1396,6 +1982,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.3.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
@ -1784,6 +2380,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@ -1810,6 +2416,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/hasown": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -1830,6 +2449,22 @@
"node": ">=0.8.19" "node": ">=0.8.19"
} }
}, },
"node_modules/is-core-module": {
"version": "2.16.2",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz",
"integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -1853,6 +2488,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@ -2401,6 +3043,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@ -2671,6 +3320,28 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve": {
"version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
"integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"is-core-module": "^2.16.1",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/rolldown": { "node_modules/rolldown": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz",
@ -2705,6 +3376,52 @@
"@rolldown/binding-win32-x64-msvc": "1.2.5" "@rolldown/binding-win32-x64-msvc": "1.2.5"
} }
}, },
"node_modules/rollup": {
"version": "4.63.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.0.tgz",
"integrity": "sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.9"
},
"bin": {
"rollup": "dist/bin/rollup"
},
"engines": {
"node": ">=18.0.0",
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@napi-rs/lzma-linux-x64-gnu": "1.5.1",
"@rollup/rollup-android-arm-eabi": "4.63.0",
"@rollup/rollup-android-arm64": "4.63.0",
"@rollup/rollup-darwin-arm64": "4.63.0",
"@rollup/rollup-darwin-x64": "4.63.0",
"@rollup/rollup-freebsd-arm64": "4.63.0",
"@rollup/rollup-freebsd-x64": "4.63.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.63.0",
"@rollup/rollup-linux-arm-musleabihf": "4.63.0",
"@rollup/rollup-linux-arm64-gnu": "4.63.0",
"@rollup/rollup-linux-arm64-musl": "4.63.0",
"@rollup/rollup-linux-loong64-gnu": "4.63.0",
"@rollup/rollup-linux-loong64-musl": "4.63.0",
"@rollup/rollup-linux-ppc64-gnu": "4.63.0",
"@rollup/rollup-linux-ppc64-musl": "4.63.0",
"@rollup/rollup-linux-riscv64-gnu": "4.63.0",
"@rollup/rollup-linux-riscv64-musl": "4.63.0",
"@rollup/rollup-linux-s390x-gnu": "4.63.0",
"@rollup/rollup-linux-x64-gnu": "4.63.0",
"@rollup/rollup-linux-x64-musl": "4.63.0",
"@rollup/rollup-openbsd-x64": "4.63.0",
"@rollup/rollup-openharmony-arm64": "4.63.0",
"@rollup/rollup-win32-arm64-msvc": "4.63.0",
"@rollup/rollup-win32-ia32-msvc": "4.63.0",
"@rollup/rollup-win32-x64-gnu": "4.63.0",
"@rollup/rollup-win32-x64-msvc": "4.63.0",
"fsevents": "~2.3.2"
}
},
"node_modules/sade": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@ -2807,6 +3524,19 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.56.10", "version": "5.56.10",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz",
@ -3348,7 +4078,6 @@
"version": "8.21.3", "version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=10.0.0" "node": ">=10.0.0"
@ -3366,22 +4095,6 @@
} }
} }
}, },
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"extraneous": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

9
package.json

@ -5,8 +5,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build && tsc -p tsconfig.server.json",
"preview": "vite preview", "preview": "vite preview",
"start": "node server.js",
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
@ -19,10 +20,11 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^24", "@types/node": "^24",
"@types/ws": "^8.18.1",
"@vitest/browser-playwright": "^4.1.8", "@vitest/browser-playwright": "^4.1.8",
"eslint": "^10.4.1", "eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
@ -38,5 +40,8 @@
"vite": "^8.0.16", "vite": "^8.0.16",
"vitest": "^4.1.8", "vitest": "^4.1.8",
"vitest-browser-svelte": "^2.1.1" "vitest-browser-svelte": "^2.1.1"
},
"dependencies": {
"ws": "^8.21.3"
} }
} }

30
server.js

@ -0,0 +1,30 @@
// Custom production server: wraps adapter-node's generated request handler in
// a plain http.Server so we can also intercept WebSocket upgrade requests,
// which SvelteKit itself has no hook for (see src/lib/server/core/ws-handler.ts).
//
// `.env` is not auto-loaded here (SvelteKit's own adapter-node doesn't in
// production either) — run with `node --env-file=.env server.js` locally, or
// provide OPENAI_API_KEY / REALTIME_TOKEN_SECRET via your deployment's real
// secret mechanism.
import { createServer } from 'node:http';
import { handler } from './build/handler.js';
import { DEFAULT_SESSION_DEFAULTS } from './build-server/server/core/defaults.js';
import { OpenAiRealtimeProvider } from './build-server/server/core/providers/openai.js';
import { handleRealtimeUpgrade } from './build-server/server/core/ws-handler.js';
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('OPENAI_API_KEY is not set. See .env.example.');
}
const provider = new OpenAiRealtimeProvider({ apiKey });
const server = createServer(handler);
server.on('upgrade', (req, socket, head) => {
handleRealtimeUpgrade(req, socket, head, { provider, defaults: DEFAULT_SESSION_DEFAULTS });
});
const port = process.env.PORT ? Number(process.env.PORT) : 3000;
server.listen(port, () => {
console.log(`listening on port ${port}`);
});

69
src/lib/client/core/client.ts

@ -0,0 +1,69 @@
import { REALTIME_WS_PATH, type ClientEvent, type ServerEvent } from '../../core/protocol.js';
export interface RealtimeClientOptions {
/** Origin to connect to. Defaults to the current page's origin. */
baseUrl?: string;
}
type ServerEventHandler = (event: ServerEvent) => void;
/**
* Thin typed WebSocket wrapper around the `$lib/core/protocol` wire contract
* — transport only, no audio capture/playback and no session-start policy.
* Callers fetch their own token (see `POST /api/realtime/session`) and
* decide when/what to send.
*/
export class RealtimeClient {
private socket: WebSocket | null = null;
private readonly handlers = new Set<ServerEventHandler>();
connect(token: string, options: RealtimeClientOptions = {}): Promise<void> {
const base = options.baseUrl ?? location.origin;
const wsUrl = new URL(REALTIME_WS_PATH, base);
wsUrl.protocol = wsUrl.protocol === 'https:' ? 'wss:' : 'ws:';
wsUrl.searchParams.set('token', token);
return new Promise<void>((resolve, reject) => {
const socket = new WebSocket(wsUrl);
socket.addEventListener('open', () => resolve(), { once: true });
socket.addEventListener(
'error',
() => reject(new Error('Failed to connect to the realtime WS endpoint.')),
{ once: true }
);
socket.addEventListener('message', (event) => this.handleMessage(event));
socket.addEventListener('close', () => {
this.socket = null;
});
this.socket = socket;
});
}
send(event: ClientEvent): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('RealtimeClient is not connected.');
}
this.socket.send(JSON.stringify(event));
}
/** Returns an unsubscribe function. */
on(handler: ServerEventHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
close(): void {
this.socket?.close();
this.socket = null;
}
private handleMessage(event: MessageEvent): void {
let parsed: ServerEvent;
try {
parsed = JSON.parse(event.data);
} catch {
return;
}
for (const handler of this.handlers) handler(parsed);
}
}

46
src/lib/core/protocol.ts

@ -0,0 +1,46 @@
/**
* Wire contract between a browser client and our realtime WS relay.
*
* This vocabulary is intentionally our own, not a passthrough of any specific
* provider's event schema (e.g. OpenAI Realtime's `input_audio_buffer.append`,
* `response.create`, etc). Provider adapters under `$lib/server/core/providers`
* translate to/from this shape, which is what keeps the client and the
* session orchestrator provider-agnostic.
*/
export const REALTIME_WS_PATH = '/api/realtime/ws';
/**
* Client-controllable subset of session config. Deliberately small: things
* like `instructions`, `tools`, and `model` are server-owned (see
* `$lib/server/core/session`) since this relay is shared infrastructure that
* other people's browsers connect to.
*/
export interface SessionStartConfig {
voice?: string;
}
export type ClientEvent =
| { type: 'session.start'; config?: SessionStartConfig }
| { type: 'input_audio.chunk'; audio: string } // base64-encoded PCM16
| { type: 'input_audio.commit' }
| { type: 'input_text.send'; text: string }
| { type: 'response.cancel' }
| { type: 'session.end' };
export type RealtimeErrorCode =
| 'auth_expired'
| 'invalid_client_event'
| 'upstream_unavailable'
| 'upstream_error'
| 'internal_error';
export type ServerEvent =
| { type: 'session.ready'; sessionId: string }
| { type: 'output_audio.chunk'; audio: string } // base64-encoded PCM16
| { type: 'output_audio.done' }
| { type: 'transcript.delta'; role: 'user' | 'assistant'; text: string }
| { type: 'transcript.done'; role: 'user' | 'assistant' }
| { type: 'response.done' }
| { type: 'error'; code: RealtimeErrorCode; message: string }
| { type: 'session.closed'; reason: string };

12
src/lib/server/core/defaults.ts

@ -0,0 +1,12 @@
import type { SessionDefaults } from './session.js';
/**
* Server-owned session policy shared by the dev-mode Vite plugin and the
* production custom server, so both environments start sessions identically.
* A real client project will likely replace this with its own
* persona/instructions, possibly per-route rather than a single constant.
*/
export const DEFAULT_SESSION_DEFAULTS: SessionDefaults = {
model: 'gpt-realtime',
instructions: 'You are a helpful voice assistant for a webavatar demo. Keep responses concise.'
};

42
src/lib/server/core/dev-plugin.ts

@ -0,0 +1,42 @@
import { loadEnv, type Plugin } from 'vite';
import { DEFAULT_SESSION_DEFAULTS } from './defaults.js';
import { OpenAiRealtimeProvider } from './providers/openai.js';
import { handleRealtimeUpgrade } from './ws-handler.js';
/**
* Wires the same upgrade handling used in production (see repo-root
* server.js) into Vite's dev server, so `npm run dev` supports the realtime
* WS endpoint without a production build.
*
* Loads .env itself: this hook runs at dev-server startup, outside
* SvelteKit's own request/module graph, so `$env/...` isn't available here —
* see the note in ./tokens.ts for why this whole module tree reads
* `process.env` directly instead.
*/
export function realtimeDevWsPlugin(): Plugin {
return {
name: 'webavatar-realtime-ws-dev',
configureServer(server) {
// vitest's browser-mode project boots its own Vite dev server for
// component tests; this plugin has nothing to do there.
if (process.env.VITEST) return;
const loaded = loadEnv('development', process.cwd(), '');
for (const [key, value] of Object.entries(loaded)) {
if (process.env[key] === undefined) process.env[key] = value;
}
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.warn(
'[realtime] OPENAI_API_KEY is not set (see .env.example) — realtime sessions will fail to start.'
);
}
const provider = new OpenAiRealtimeProvider({ apiKey: apiKey ?? '' });
server.httpServer?.on('upgrade', (req, socket, head) => {
handleRealtimeUpgrade(req, socket, head, { provider, defaults: DEFAULT_SESSION_DEFAULTS });
});
}
};
}

36
src/lib/server/core/provider.ts

@ -0,0 +1,36 @@
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
/**
* Fully-resolved, server-trusted session config. Never expose this shape to
* the browser directly — `instructions` and `tools` in particular are
* server-owned policy, not client input (see `resolveSessionConfig` in
* `./session`).
*/
export interface ResolvedSessionConfig {
model: string;
instructions: string;
voice?: string;
}
/**
* A single live conversation with a backend model/pipeline. Speaks only the
* normalized `ClientEvent`/`ServerEvent` vocabulary from `$lib/core/protocol`
* — never a provider's own wire format. That constraint is what lets
* `RealtimeSession` (./session.ts) stay identical across providers.
*/
export interface ProviderSession {
send(event: ClientEvent): void;
/** Only one handler is supported; a session has exactly one consumer. */
onEvent(handler: (event: ServerEvent) => void): void;
close(reason?: string): void;
}
/**
* A backend capable of running realtime sessions — today, a single upstream
* WebSocket to OpenAI Realtime. A future pipeline (STT -> LLM -> TTS) or a
* different model provider implements this same interface; nothing else in
* `$lib/server/core` needs to change.
*/
export interface RealtimeProvider {
createSession(config: ResolvedSessionConfig): Promise<ProviderSession>;
}

91
src/lib/server/core/providers/openai.spec.ts

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { fromOpenAiEvent, toOpenAiEvents } from './openai.js';
describe('toOpenAiEvents', () => {
it('translates an audio chunk to input_audio_buffer.append', () => {
expect(toOpenAiEvents({ type: 'input_audio.chunk', audio: 'abc123' })).toEqual([
{ type: 'input_audio_buffer.append', audio: 'abc123' }
]);
});
it('translates an audio commit to input_audio_buffer.commit', () => {
expect(toOpenAiEvents({ type: 'input_audio.commit' })).toEqual([
{ type: 'input_audio_buffer.commit' }
]);
});
it('translates text input into a conversation item plus a response trigger', () => {
expect(toOpenAiEvents({ type: 'input_text.send', text: 'hello' })).toEqual([
{
type: 'conversation.item.create',
item: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'hello' }] }
},
{ type: 'response.create' }
]);
});
it('translates response.cancel', () => {
expect(toOpenAiEvents({ type: 'response.cancel' })).toEqual([{ type: 'response.cancel' }]);
});
it('produces no wire events for session.start / session.end (handled by the orchestrator)', () => {
expect(toOpenAiEvents({ type: 'session.start' })).toEqual([]);
expect(toOpenAiEvents({ type: 'session.end' })).toEqual([]);
});
});
describe('fromOpenAiEvent', () => {
it('translates response.output_audio.delta', () => {
expect(fromOpenAiEvent({ type: 'response.output_audio.delta', delta: 'xyz' })).toEqual({
type: 'output_audio.chunk',
audio: 'xyz'
});
});
it('translates response.output_audio.done', () => {
expect(fromOpenAiEvent({ type: 'response.output_audio.done' })).toEqual({
type: 'output_audio.done'
});
});
it('translates response.output_audio_transcript.delta to an assistant transcript delta', () => {
expect(
fromOpenAiEvent({ type: 'response.output_audio_transcript.delta', delta: 'hi there' })
).toEqual({
type: 'transcript.delta',
role: 'assistant',
text: 'hi there'
});
});
it('translates a completed user transcription to a user transcript delta', () => {
expect(
fromOpenAiEvent({
type: 'conversation.item.input_audio_transcription.completed',
transcript: 'what is the weather'
})
).toEqual({ type: 'transcript.delta', role: 'user', text: 'what is the weather' });
});
it('translates response.done', () => {
expect(fromOpenAiEvent({ type: 'response.done' })).toEqual({ type: 'response.done' });
});
it('translates an upstream error, falling back to a default message', () => {
expect(fromOpenAiEvent({ type: 'error', error: { message: 'boom' } })).toEqual({
type: 'error',
code: 'upstream_error',
message: 'boom'
});
expect(fromOpenAiEvent({ type: 'error' })).toEqual({
type: 'error',
code: 'upstream_error',
message: 'Unknown upstream error'
});
});
it('returns null for events outside our normalized vocabulary', () => {
expect(fromOpenAiEvent({ type: 'session.created' })).toBeNull();
expect(fromOpenAiEvent({ type: 'rate_limits.updated' })).toBeNull();
});
});

174
src/lib/server/core/providers/openai.ts

@ -0,0 +1,174 @@
import WebSocket from 'ws';
import type { ClientEvent, ServerEvent } from '../../../core/protocol.js';
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from '../provider.js';
const REALTIME_URL = 'wss://api.openai.com/v1/realtime';
export interface OpenAiRealtimeProviderOptions {
apiKey: string;
}
/**
* `RealtimeProvider` backed by a single upstream WebSocket connection to
* OpenAI's Realtime API (server-to-server: the real API key lives only here,
* never handed to a browser). Translates between our normalized
* `ClientEvent`/`ServerEvent` vocabulary and OpenAI's wire event schema.
*
* Always requests `output_modalities: ['audio']` — OpenAI streams an audio
* transcript (`response.audio_transcript.delta`) alongside the audio itself,
* which we surface as `transcript.delta`, so text-only consumers can just
* ignore the `output_audio.*` events rather than needing a separate
* text-only session mode.
*/
export class OpenAiRealtimeProvider implements RealtimeProvider {
constructor(private readonly options: OpenAiRealtimeProviderOptions) {}
createSession(config: ResolvedSessionConfig): Promise<ProviderSession> {
return new Promise((resolve, reject) => {
const url = `${REALTIME_URL}?model=${encodeURIComponent(config.model)}`;
const ws = new WebSocket(url, {
headers: { Authorization: `Bearer ${this.options.apiKey}` }
});
let settled = false;
ws.once('open', () => {
ws.send(
JSON.stringify({
type: 'session.update',
session: {
type: 'realtime',
model: config.model,
instructions: config.instructions,
voice: config.voice,
output_modalities: ['audio']
}
})
);
settled = true;
resolve(new OpenAiProviderSession(ws));
});
ws.once('error', (err) => {
if (!settled) {
settled = true;
reject(err instanceof Error ? err : new Error(String(err)));
}
});
});
}
}
class OpenAiProviderSession implements ProviderSession {
private handler: ((event: ServerEvent) => void) | null = null;
private closingLocally = false;
constructor(private readonly ws: WebSocket) {
this.ws.on('message', (data, isBinary) => this.handleUpstreamMessage(data, isBinary));
this.ws.on('close', () => this.handleUpstreamClose());
this.ws.on('error', (err) =>
this.emit({ type: 'error', code: 'upstream_error', message: err.message })
);
}
send(event: ClientEvent): void {
const wireEvents = toOpenAiEvents(event);
for (const wireEvent of wireEvents) {
this.ws.send(JSON.stringify(wireEvent));
}
}
onEvent(handler: (event: ServerEvent) => void): void {
this.handler = handler;
}
close(): void {
this.closingLocally = true;
if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) {
this.ws.close();
}
}
private handleUpstreamMessage(data: WebSocket.RawData, isBinary: boolean): void {
if (isBinary) return; // OpenAI Realtime sends JSON text frames only
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(data.toString());
} catch {
return;
}
const event = fromOpenAiEvent(parsed);
if (event) this.emit(event);
}
private handleUpstreamClose(): void {
if (this.closingLocally) return;
this.emit({ type: 'session.closed', reason: 'upstream_disconnected' });
}
private emit(event: ServerEvent): void {
this.handler?.(event);
}
}
// Exported (alongside fromOpenAiEvent below) for direct unit testing of the
// translation logic, without spinning up a real upstream WebSocket.
export function toOpenAiEvents(event: ClientEvent): Array<Record<string, unknown>> {
switch (event.type) {
case 'input_audio.chunk':
return [{ type: 'input_audio_buffer.append', audio: event.audio }];
case 'input_audio.commit':
return [{ type: 'input_audio_buffer.commit' }];
case 'input_text.send':
return [
{
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: event.text }]
}
},
{ type: 'response.create' }
];
case 'response.cancel':
return [{ type: 'response.cancel' }];
// 'session.start' and 'session.end' are handled by the orchestrator
// (RealtimeSession) before reaching a provider — see ../session.ts.
default:
return [];
}
}
export function fromOpenAiEvent(raw: Record<string, unknown>): ServerEvent | null {
const type = raw.type;
switch (type) {
// Verified against a live session (Aug 2026): output content events carry
// an `output_` prefix not documented consistently anywhere we could find
// — e.g. `response.output_audio.delta`, not `response.audio.delta`.
case 'response.output_audio.delta':
return { type: 'output_audio.chunk', audio: raw.delta as string };
case 'response.output_audio.done':
return { type: 'output_audio.done' };
case 'response.output_audio_transcript.delta':
return { type: 'transcript.delta', role: 'assistant', text: raw.delta as string };
case 'response.output_audio_transcript.done':
return { type: 'transcript.done', role: 'assistant' };
case 'conversation.item.input_audio_transcription.completed':
return { type: 'transcript.delta', role: 'user', text: raw.transcript as string };
case 'response.done':
return { type: 'response.done' };
case 'error': {
const err = raw.error as { message?: string } | undefined;
return {
type: 'error',
code: 'upstream_error',
message: err?.message ?? 'Unknown upstream error'
};
}
default:
// session.created/updated, conversation.*, input_audio_buffer.speech_*,
// rate_limits.updated, etc. — not part of our normalized vocabulary.
return null;
}
}

205
src/lib/server/core/session.spec.ts

@ -0,0 +1,205 @@
import { EventEmitter } from 'node:events';
import { describe, expect, it, vi } from 'vitest';
import type WebSocket from 'ws';
import type { ClientEvent, ServerEvent } from '../../core/protocol.js';
import type { ProviderSession, RealtimeProvider } from './provider.js';
import { createRealtimeSession, resolveSessionConfig } from './session.js';
const DEFAULTS = { model: 'test-model', instructions: 'be helpful' };
/** Minimal stand-in for a `ws` WebSocket — only what RealtimeSession touches. */
class FakeBrowserSocket extends EventEmitter {
readyState = 1; // WebSocket.OPEN
sentRaw: string[] = [];
closeCalls = 0;
send(data: string) {
this.sentRaw.push(data);
}
close() {
this.closeCalls++;
this.readyState = 3; // WebSocket.CLOSED
}
get sent(): ServerEvent[] {
return this.sentRaw.map((raw) => JSON.parse(raw));
}
receive(event: ClientEvent) {
this.emit('message', Buffer.from(JSON.stringify(event)), false);
}
}
class FakeProviderSession implements ProviderSession {
sent: ClientEvent[] = [];
closed = false;
closeReason: string | undefined;
private handler: ((event: ServerEvent) => void) | null = null;
send(event: ClientEvent): void {
this.sent.push(event);
}
onEvent(handler: (event: ServerEvent) => void): void {
this.handler = handler;
}
close(reason?: string): void {
this.closed = true;
this.closeReason = reason;
}
emitToOrchestrator(event: ServerEvent): void {
this.handler?.(event);
}
}
class FakeProvider implements RealtimeProvider {
sessions: FakeProviderSession[] = [];
private readonly gate: Promise<void>;
private release!: () => void;
constructor(opts: { deferred?: boolean } = {}) {
this.gate = opts.deferred
? new Promise((resolve) => (this.release = resolve))
: Promise.resolve();
}
async createSession(): Promise<ProviderSession> {
await this.gate;
const session = new FakeProviderSession();
this.sessions.push(session);
return session;
}
releaseDeferred(): void {
this.release();
}
}
function makeSocket() {
const socket = new FakeBrowserSocket();
return { socket, asWs: socket as unknown as WebSocket };
}
describe('resolveSessionConfig', () => {
it('always uses server-owned model/instructions, merging only the client-visible voice', () => {
expect(resolveSessionConfig(DEFAULTS, { voice: 'coral' })).toEqual({
model: 'test-model',
instructions: 'be helpful',
voice: 'coral'
});
});
it('is safe to call with no client config at all', () => {
expect(resolveSessionConfig(DEFAULTS)).toEqual({
model: 'test-model',
instructions: 'be helpful',
voice: undefined
});
});
});
describe('RealtimeSession', () => {
it('starts a provider session on session.start and replies with session.ready', async () => {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'session.start' });
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
await vi.waitFor(() =>
expect(socket.sent).toContainEqual(expect.objectContaining({ type: 'session.ready' }))
);
});
it('rejects content events sent before session.start', async () => {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'input_text.send', text: 'too early' });
await vi.waitFor(() =>
expect(socket.sent).toContainEqual(
expect.objectContaining({ type: 'error', code: 'invalid_client_event' })
)
);
expect(provider.sessions).toHaveLength(0);
});
it('forwards events to the provider session once active', async () => {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'session.start' });
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
socket.receive({ type: 'input_text.send', text: 'hello' });
await vi.waitFor(() =>
expect(provider.sessions[0].sent).toContainEqual({ type: 'input_text.send', text: 'hello' })
);
});
it('relays provider server events back to the browser socket', async () => {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'session.start' });
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
provider.sessions[0].emitToOrchestrator({
type: 'transcript.delta',
role: 'assistant',
text: 'hi'
});
await vi.waitFor(() =>
expect(socket.sent).toContainEqual({
type: 'transcript.delta',
role: 'assistant',
text: 'hi'
})
);
});
it('closes the provider session when the browser disconnects', async () => {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'session.start' });
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
socket.emit('close');
await vi.waitFor(() => expect(provider.sessions[0].closed).toBe(true));
expect(provider.sessions[0].closeReason).toBe('client_disconnected');
});
it('closes the provider session on session.end and closes the browser socket', async () => {
const provider = new FakeProvider();
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'session.start' });
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
socket.receive({ type: 'session.end' });
await vi.waitFor(() => expect(provider.sessions[0].closed).toBe(true));
expect(socket.closeCalls).toBe(1);
});
it('does not leak an upstream session if the browser disconnects mid-handshake', async () => {
const provider = new FakeProvider({ deferred: true });
const { socket, asWs } = makeSocket();
createRealtimeSession(asWs, provider, DEFAULTS);
socket.receive({ type: 'session.start' });
socket.emit('close'); // disconnect while the provider handshake is still pending
provider.releaseDeferred();
await vi.waitFor(() => expect(provider.sessions).toHaveLength(1));
await vi.waitFor(() => expect(provider.sessions[0].closed).toBe(true));
});
});

161
src/lib/server/core/session.ts

@ -0,0 +1,161 @@
import { randomUUID } from 'node:crypto';
import WebSocket from 'ws';
import type { ClientEvent, ServerEvent, SessionStartConfig } from '../../core/protocol.js';
import type { ProviderSession, RealtimeProvider, ResolvedSessionConfig } from './provider.js';
export type RealtimeSessionState = 'idle' | 'connecting' | 'active' | 'closing' | 'closed';
/** Server-owned policy for a realtime session. Never client-controlled. */
export interface SessionDefaults {
model: string;
instructions: string;
}
/**
* Merges the browser's (deliberately tiny) client-visible config into the
* server-owned defaults. `instructions`/`model` always come from `defaults`
* — this is the trust boundary that keeps a shared relay safe to expose to
* untrusted browsers.
*/
export function resolveSessionConfig(
defaults: SessionDefaults,
clientConfig?: SessionStartConfig
): ResolvedSessionConfig {
return {
model: defaults.model,
instructions: defaults.instructions,
voice: clientConfig?.voice
};
}
/**
* Owns the lifecycle of one browser WS connection: turns its first
* `session.start` into a `ProviderSession` via the injected `RealtimeProvider`,
* pipes events both directions afterward, and guarantees that either side
* closing/erroring tears down the other.
*/
export class RealtimeSession {
private state: RealtimeSessionState = 'idle';
private providerSession: ProviderSession | null = null;
constructor(
private readonly browserSocket: WebSocket,
private readonly provider: RealtimeProvider,
private readonly defaults: SessionDefaults
) {
this.browserSocket.on('message', (data, isBinary) => {
void this.handleBrowserMessage(data, isBinary);
});
this.browserSocket.on('close', () => this.close('client_disconnected'));
this.browserSocket.on('error', () => this.close('client_disconnected'));
}
getState(): RealtimeSessionState {
return this.state;
}
private async handleBrowserMessage(data: WebSocket.RawData, isBinary: boolean): Promise<void> {
if (isBinary) {
this.sendToBrowser({
type: 'error',
code: 'invalid_client_event',
message: 'Binary frames are not supported yet.'
});
return;
}
let event: ClientEvent;
try {
event = JSON.parse(data.toString());
} catch {
this.sendToBrowser({
type: 'error',
code: 'invalid_client_event',
message: 'Malformed JSON.'
});
return;
}
if (event.type === 'session.start') {
await this.start(event.config);
return;
}
if (event.type === 'session.end') {
this.close('client_requested');
return;
}
if (this.state !== 'active' || !this.providerSession) {
this.sendToBrowser({
type: 'error',
code: 'invalid_client_event',
message: `Cannot handle '${event.type}' before session.start.`
});
return;
}
this.providerSession.send(event);
}
private async start(clientConfig?: SessionStartConfig): Promise<void> {
if (this.state !== 'idle') {
this.sendToBrowser({
type: 'error',
code: 'invalid_client_event',
message: 'Session already started.'
});
return;
}
this.state = 'connecting';
const config = resolveSessionConfig(this.defaults, clientConfig);
let providerSession: ProviderSession;
try {
providerSession = await this.provider.createSession(config);
} catch (err) {
this.state = 'closed';
this.sendToBrowser({
type: 'error',
code: 'upstream_unavailable',
message: err instanceof Error ? err.message : 'Failed to start session.'
});
this.browserSocket.close();
return;
}
// The browser may have disconnected (session.end / close) while we were
// awaiting the provider handshake — don't leak the upstream connection.
const stateAfterConnect: RealtimeSessionState = this.state;
if (stateAfterConnect !== 'connecting') {
providerSession.close('client_requested');
return;
}
this.providerSession = providerSession;
this.providerSession.onEvent((event) => this.sendToBrowser(event));
this.state = 'active';
this.sendToBrowser({ type: 'session.ready', sessionId: randomUUID() });
}
private close(reason: string): void {
if (this.state === 'closing' || this.state === 'closed') return;
this.state = 'closing';
this.providerSession?.close(reason);
this.providerSession = null;
if (this.browserSocket.readyState === WebSocket.OPEN) {
this.browserSocket.close();
}
this.state = 'closed';
}
private sendToBrowser(event: ServerEvent): void {
if (this.browserSocket.readyState !== WebSocket.OPEN) return;
this.browserSocket.send(JSON.stringify(event));
}
}
export function createRealtimeSession(
browserSocket: WebSocket,
provider: RealtimeProvider,
defaults: SessionDefaults
): RealtimeSession {
return new RealtimeSession(browserSocket, provider, defaults);
}

48
src/lib/server/core/tokens.spec.ts

@ -0,0 +1,48 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mintSessionToken, verifySessionToken } from './tokens.js';
describe('tokens', () => {
const originalSecret = process.env.REALTIME_TOKEN_SECRET;
beforeEach(() => {
process.env.REALTIME_TOKEN_SECRET = 'test-secret';
});
afterEach(() => {
process.env.REALTIME_TOKEN_SECRET = originalSecret;
});
it('mints a token that verifies as valid', () => {
const { token } = mintSessionToken();
expect(verifySessionToken(token)).toEqual({ valid: true });
});
it('rejects a malformed token', () => {
expect(verifySessionToken('not-a-token')).toEqual({ valid: false, reason: 'malformed' });
});
it('rejects a tampered signature', () => {
const { token } = mintSessionToken();
const [payload] = token.split('.');
expect(verifySessionToken(`${payload}.deadbeef`)).toEqual({
valid: false,
reason: 'invalid_signature'
});
});
it('rejects a token signed with a different secret', () => {
const { token } = mintSessionToken();
process.env.REALTIME_TOKEN_SECRET = 'a-different-secret';
expect(verifySessionToken(token)).toEqual({ valid: false, reason: 'invalid_signature' });
});
it('rejects an expired token', () => {
const { token } = mintSessionToken(-1000);
expect(verifySessionToken(token)).toEqual({ valid: false, reason: 'expired' });
});
it('throws when the secret is not configured', () => {
delete process.env.REALTIME_TOKEN_SECRET;
expect(() => mintSessionToken()).toThrow();
});
});

66
src/lib/server/core/tokens.ts

@ -0,0 +1,66 @@
import { createHmac, timingSafeEqual } from 'node:crypto';
// Short-lived: this token only needs to survive the moment between minting
// and completing the WS upgrade handshake, not the whole conversation.
const DEFAULT_TTL_MS = 60_000;
// Read from process.env (not SvelteKit's $env/static/private) so this module
// stays plain, portable TypeScript: it's compiled standalone for the custom
// Node server (see ../../../../server.js and tsconfig.server.json), not just
// bundled through Vite. Runtime env access is also simply more correct for a
// secret that shouldn't be inlined at build time.
function getSecret(): string {
const secret = process.env.REALTIME_TOKEN_SECRET;
if (!secret) {
throw new Error('REALTIME_TOKEN_SECRET is not set. See .env.example.');
}
return secret;
}
export interface MintedToken {
token: string;
expiresAt: number; // unix ms
}
export type TokenVerification =
{ valid: true } | { valid: false; reason: 'expired' | 'invalid_signature' | 'malformed' };
/**
* Generic, HMAC-signed, short-lived bearer token — not tied to any specific
* provider. Intended as a reusable "prove you got this from our own server a
* moment ago" primitive for gating the realtime WS upgrade (see
* `../ws-handler`), reusable by future server-only features too.
*/
export function mintSessionToken(ttlMs = DEFAULT_TTL_MS): MintedToken {
const expiresAt = Date.now() + ttlMs;
const signature = sign(String(expiresAt));
return { token: `${expiresAt}.${signature}`, expiresAt };
}
export function verifySessionToken(token: string): TokenVerification {
const parts = token.split('.');
if (parts.length !== 2) return { valid: false, reason: 'malformed' };
const [payload, signature] = parts;
if (!timingSafeCompare(signature, sign(payload))) {
return { valid: false, reason: 'invalid_signature' };
}
const expiresAt = Number(payload);
if (!Number.isFinite(expiresAt) || Date.now() > expiresAt) {
return { valid: false, reason: 'expired' };
}
return { valid: true };
}
function sign(payload: string): string {
return createHmac('sha256', getSecret()).update(payload).digest('hex');
}
function timingSafeCompare(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}

47
src/lib/server/core/ws-handler.ts

@ -0,0 +1,47 @@
import type { IncomingMessage } from 'node:http';
import type { Duplex } from 'node:stream';
import { WebSocketServer } from 'ws';
import { REALTIME_WS_PATH } from '../../core/protocol.js';
import type { RealtimeProvider } from './provider.js';
import { createRealtimeSession, type SessionDefaults } from './session.js';
import { verifySessionToken } from './tokens.js';
export interface RealtimeUpgradeDeps {
provider: RealtimeProvider;
defaults: SessionDefaults;
}
// `noServer: true` + `handleUpgrade` below is what lets this share an
// `http.Server` with SvelteKit's own request handling (and, in dev, Vite's
// HMR websocket) instead of needing its own listening port.
const wss = new WebSocketServer({ noServer: true });
/**
* Raw `http.Server` `'upgrade'` handler, shared verbatim between the
* production custom server (../../../../server.js) and the dev-mode Vite
* plugin (./dev-plugin.ts) so the two environments behave identically.
*
* Must be a no-op (never touch `socket`) for any request whose path isn't
* ours: the same underlying HTTP server also carries Vite's own HMR upgrade
* in dev, and destroying/responding to that would silently break HMR.
*/
export function handleRealtimeUpgrade(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
deps: RealtimeUpgradeDeps
): void {
const url = new URL(req.url ?? '', 'http://internal');
if (url.pathname !== REALTIME_WS_PATH) return;
const verification = verifySessionToken(url.searchParams.get('token') ?? '');
if (!verification.valid) {
socket.write('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (browserSocket) => {
createRealtimeSession(browserSocket, deps.provider, deps.defaults);
});
}

8
src/routes/api/realtime/session/+server.ts

@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
import { mintSessionToken } from '$lib/server/core/tokens.js';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = () => {
const { token, expiresAt } = mintSessionToken();
return json({ token, expiresAt });
};

117
src/routes/demo/realtime/+page.svelte

@ -0,0 +1,117 @@
<script lang="ts">
import { RealtimeClient } from '$lib/client/core/client.js';
import type { ServerEvent } from '$lib/core/protocol.js';
type Status = 'idle' | 'connecting' | 'ready' | 'error' | 'closed';
type TranscriptEntry = { role: 'user' | 'assistant'; text: string };
let status = $state<Status>('idle');
let statusMessage = $state('');
let transcript = $state<TranscriptEntry[]>([]);
let draft = $state('');
let client: RealtimeClient | null = null;
async function connect() {
status = 'connecting';
statusMessage = '';
try {
const res = await fetch('/api/realtime/session', { method: 'POST' });
if (!res.ok) throw new Error(`Failed to mint session token (${res.status})`);
const { token } = await res.json();
const nextClient = new RealtimeClient();
nextClient.on(handleServerEvent);
await nextClient.connect(token);
client = nextClient;
client.send({ type: 'session.start' });
} catch (err) {
status = 'error';
statusMessage = err instanceof Error ? err.message : 'Failed to connect.';
}
}
function handleServerEvent(event: ServerEvent) {
switch (event.type) {
case 'session.ready':
status = 'ready';
break;
case 'transcript.delta':
appendDelta(event.role, event.text);
break;
case 'error':
status = 'error';
statusMessage = event.message;
break;
case 'session.closed':
status = 'closed';
statusMessage = event.reason;
break;
}
}
function appendDelta(role: 'user' | 'assistant', text: string) {
const last = transcript.at(-1);
if (last && last.role === role) {
last.text += text;
} else {
transcript.push({ role, text });
}
}
function sendMessage() {
const text = draft.trim();
if (!text || !client) return;
transcript.push({ role: 'user', text });
client.send({ type: 'input_text.send', text });
draft = '';
}
function disconnect() {
client?.send({ type: 'session.end' });
client?.close();
client = null;
status = 'closed';
}
</script>
<h1>Realtime session demo</h1>
<p>Status: {status}{statusMessage ? ` — ${statusMessage}` : ''}</p>
{#if status === 'idle' || status === 'error' || status === 'closed'}
<button onclick={connect}>Connect</button>
{:else if status === 'ready'}
<button onclick={disconnect}>Disconnect</button>
{/if}
<div class="transcript">
{#each transcript as entry, i (i)}
<p class={entry.role}><strong>{entry.role}:</strong> {entry.text}</p>
{/each}
</div>
<form
onsubmit={(e) => {
e.preventDefault();
sendMessage();
}}
>
<input bind:value={draft} placeholder="Type a message…" disabled={status !== 'ready'} />
<button type="submit" disabled={status !== 'ready'}>Send</button>
</form>
<style>
.transcript {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1rem 0;
min-height: 8rem;
}
.user {
color: #666;
}
.assistant {
font-weight: 500;
}
</style>

17
tsconfig.server.json

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "es2023",
"module": "nodenext",
"moduleResolution": "nodenext",
"outDir": "build-server",
"rootDir": "src/lib",
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"types": ["node"]
},
"include": ["src/lib/core/**/*.ts", "src/lib/server/core/**/*.ts"],
"exclude": ["**/*.spec.ts"]
}

14
vite.config.ts

@ -1,7 +1,8 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright'; import { playwright } from '@vitest/browser-playwright';
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { realtimeDevWsPlugin } from './src/lib/server/core/dev-plugin.js';
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -12,11 +13,14 @@ export default defineConfig({
filename.split(/[/\\]/).includes('node_modules') ? undefined : true filename.split(/[/\\]/).includes('node_modules') ? undefined : true
}, },
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // adapter-node: the realtime WS relay needs a persistent Node process
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // (see src/lib/server/core/ws-handler.ts) — no serverless adapter can
// See https://svelte.dev/docs/kit/adapters for more information about adapters. // hold an upgraded WebSocket connection the way this can.
adapter: adapter() adapter: adapter()
}) }),
// Attaches the same WS upgrade handling used in production (server.js)
// to Vite's dev server, so `npm run dev` supports it without a build.
realtimeDevWsPlugin()
], ],
test: { test: {
expect: { requireAssertions: true }, expect: { requireAssertions: true },

Loading…
Cancel
Save