diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..8acb8d3 --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "webavatar-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 5173 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fcaaa53 --- /dev/null +++ b/.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= diff --git a/.gitignore b/.gitignore index 41f1bdc..5e5ef89 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ node_modules .wrangler /.svelte-kit /build +/build-server # OS .DS_Store diff --git a/package-lock.json b/package-lock.json index b5270d1..1e8a06a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,17 @@ "": { "name": "webavatar-base", "version": "0.0.1", + "dependencies": { + "ws": "^8.21.3" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^24", + "@types/ws": "^8.18.1", "@vitest/browser-playwright": "^4.1.8", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -282,6 +286,26 @@ "@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": { "version": "0.146.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.146.0.tgz", @@ -595,6 +619,537 @@ "dev": true, "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -612,14 +1167,21 @@ "acorn": "^8.9.0" } }, - "node_modules/@sveltejs/adapter-auto": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", - "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", "dev": true, "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": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^2.4.0" } }, "node_modules/@sveltejs/kit": { @@ -773,6 +1335,13 @@ "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": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -780,6 +1349,16 @@ "dev": true, "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": { "version": "8.68.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.68.0.tgz", @@ -1299,6 +1878,13 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1396,6 +1982,16 @@ "dev": true, "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": { "version": "2.3.2", "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_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": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -1810,6 +2416,19 @@ "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": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1830,6 +2449,22 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -1853,6 +2488,13 @@ "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": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2401,6 +3043,13 @@ "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": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2671,6 +3320,28 @@ "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": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.5.tgz", @@ -2705,6 +3376,52 @@ "@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": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2807,6 +3524,19 @@ "dev": true, "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": { "version": "5.56.10", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz", @@ -3348,7 +4078,6 @@ "version": "8.21.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", - "dev": true, "license": "MIT", "engines": { "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index f8e6ea6..8ded50f 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,9 @@ "type": "module", "scripts": { "dev": "vite dev", - "build": "vite build", + "build": "vite build && tsc -p tsconfig.server.json", "preview": "vite preview", + "start": "node server.js", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", @@ -19,10 +20,11 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@playwright/test": "^1.60.0", - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^24", + "@types/ws": "^8.18.1", "@vitest/browser-playwright": "^4.1.8", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -38,5 +40,8 @@ "vite": "^8.0.16", "vitest": "^4.1.8", "vitest-browser-svelte": "^2.1.1" + }, + "dependencies": { + "ws": "^8.21.3" } } diff --git a/server.js b/server.js new file mode 100644 index 0000000..b3d389c --- /dev/null +++ b/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}`); +}); diff --git a/src/lib/client/core/client.ts b/src/lib/client/core/client.ts new file mode 100644 index 0000000..368724c --- /dev/null +++ b/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(); + + connect(token: string, options: RealtimeClientOptions = {}): Promise { + 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((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); + } +} diff --git a/src/lib/core/protocol.ts b/src/lib/core/protocol.ts new file mode 100644 index 0000000..48d5fa0 --- /dev/null +++ b/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 }; diff --git a/src/lib/server/core/defaults.ts b/src/lib/server/core/defaults.ts new file mode 100644 index 0000000..82a0c71 --- /dev/null +++ b/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.' +}; diff --git a/src/lib/server/core/dev-plugin.ts b/src/lib/server/core/dev-plugin.ts new file mode 100644 index 0000000..7a6f4d8 --- /dev/null +++ b/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 }); + }); + } + }; +} diff --git a/src/lib/server/core/provider.ts b/src/lib/server/core/provider.ts new file mode 100644 index 0000000..d08c04f --- /dev/null +++ b/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; +} diff --git a/src/lib/server/core/providers/openai.spec.ts b/src/lib/server/core/providers/openai.spec.ts new file mode 100644 index 0000000..636c31f --- /dev/null +++ b/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(); + }); +}); diff --git a/src/lib/server/core/providers/openai.ts b/src/lib/server/core/providers/openai.ts new file mode 100644 index 0000000..e3b02ae --- /dev/null +++ b/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 { + 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; + 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> { + 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): 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; + } +} diff --git a/src/lib/server/core/session.spec.ts b/src/lib/server/core/session.spec.ts new file mode 100644 index 0000000..a636f64 --- /dev/null +++ b/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; + private release!: () => void; + + constructor(opts: { deferred?: boolean } = {}) { + this.gate = opts.deferred + ? new Promise((resolve) => (this.release = resolve)) + : Promise.resolve(); + } + + async createSession(): Promise { + 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)); + }); +}); diff --git a/src/lib/server/core/session.ts b/src/lib/server/core/session.ts new file mode 100644 index 0000000..0b43319 --- /dev/null +++ b/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 { + 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 { + 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); +} diff --git a/src/lib/server/core/tokens.spec.ts b/src/lib/server/core/tokens.spec.ts new file mode 100644 index 0000000..6755938 --- /dev/null +++ b/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(); + }); +}); diff --git a/src/lib/server/core/tokens.ts b/src/lib/server/core/tokens.ts new file mode 100644 index 0000000..61a3b24 --- /dev/null +++ b/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); +} diff --git a/src/lib/server/core/ws-handler.ts b/src/lib/server/core/ws-handler.ts new file mode 100644 index 0000000..2abac01 --- /dev/null +++ b/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); + }); +} diff --git a/src/routes/api/realtime/session/+server.ts b/src/routes/api/realtime/session/+server.ts new file mode 100644 index 0000000..66e87ab --- /dev/null +++ b/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 }); +}; diff --git a/src/routes/demo/realtime/+page.svelte b/src/routes/demo/realtime/+page.svelte new file mode 100644 index 0000000..0c7a362 --- /dev/null +++ b/src/routes/demo/realtime/+page.svelte @@ -0,0 +1,117 @@ + + +

Realtime session demo

+

Status: {status}{statusMessage ? ` — ${statusMessage}` : ''}

+ +{#if status === 'idle' || status === 'error' || status === 'closed'} + +{:else if status === 'ready'} + +{/if} + +
+ {#each transcript as entry, i (i)} +

{entry.role}: {entry.text}

+ {/each} +
+ +
{ + e.preventDefault(); + sendMessage(); + }} +> + + +
+ + diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..4c80172 --- /dev/null +++ b/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"] +} diff --git a/vite.config.ts b/vite.config.ts index 7fd6945..f46ce21 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from 'vitest/config'; 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 { realtimeDevWsPlugin } from './src/lib/server/core/dev-plugin.js'; export default defineConfig({ plugins: [ @@ -12,11 +13,14 @@ export default defineConfig({ filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. + // adapter-node: the realtime WS relay needs a persistent Node process + // (see src/lib/server/core/ws-handler.ts) — no serverless adapter can + // hold an upgraded WebSocket connection the way this can. 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: { expect: { requireAssertions: true },