From 726a8e116b2f983299a2242cdb5559daba60f4c0 Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 16 Aug 2026 14:45:02 +0700 Subject: [PATCH] fix(build): make oRPC/tRPC dist runnable under node ESM (deploy crashloop) flake.nix only rewrote @/ aliases but left extensionless relative imports (./router) in compiled dist/. node dist/index.js (how prod runs) cannot resolve extensionless ESM specifiers -> ERR_MODULE_NOT_FOUND -> backend crashlooped (444 restarts, port 4001 dead). Extract the fixer into a shared scripts/fix-imports.mjs that appends .js to extensionless relative imports and rewrites @/ aliases, and wire it into backend + discord-gateway build phases. Verified: fresh tsc + fixer -> node dist/index.js boots; oRPC over /trpc serves both HTTP POST and WebSocket (config/dashboard/voice/moderation/ media/chatbot/analysis) end-to-end against Postgres + Redis. next build passes with the oRPC client + partysocket. --- flake.nix | 54 +---- services/backend/package.json | 2 +- services/backend/pnpm-lock.yaml | 189 ++++++++++++++-- services/backend/scripts/fix-imports.mjs | 49 +++++ services/backend/src/http/app.ts | 45 ++-- services/backend/src/http/server.ts | 4 +- .../src/{trpc/routers.ts => orpc/router.ts} | 205 +++++++++--------- services/backend/src/{trpc => orpc}/ws.ts | 30 +-- services/backend/src/trpc/trpc.ts | 35 --- .../discord-gateway/scripts/fix-imports.mjs | 49 +++++ services/frontend/package.json | 3 +- services/frontend/pnpm-lock.yaml | 124 ++++++++--- services/frontend/src/lib/api/chatbot.ts | 18 +- services/frontend/src/lib/api/config.ts | 4 +- services/frontend/src/lib/api/dashboard.ts | 23 +- services/frontend/src/lib/api/index.ts | 9 +- services/frontend/src/lib/api/media.ts | 12 +- services/frontend/src/lib/api/messages.ts | 18 +- services/frontend/src/lib/api/moderation.ts | 6 +- services/frontend/src/lib/api/recordings.ts | 8 +- services/frontend/src/lib/api/server.ts | 55 +++-- services/frontend/src/lib/api/ui-state.ts | 6 +- services/frontend/src/lib/api/voice.ts | 19 +- services/frontend/src/lib/orpc/client.ts | 36 +++ services/frontend/src/lib/orpc/types.ts | 12 + services/frontend/src/lib/trpc/client.ts | 33 --- services/frontend/src/lib/trpc/types.ts | 15 -- 27 files changed, 646 insertions(+), 417 deletions(-) create mode 100644 services/backend/scripts/fix-imports.mjs rename services/backend/src/{trpc/routers.ts => orpc/router.ts} (72%) rename services/backend/src/{trpc => orpc}/ws.ts (66%) delete mode 100644 services/backend/src/trpc/trpc.ts create mode 100644 services/discord-gateway/scripts/fix-imports.mjs create mode 100644 services/frontend/src/lib/orpc/client.ts create mode 100644 services/frontend/src/lib/orpc/types.ts delete mode 100644 services/frontend/src/lib/trpc/client.ts delete mode 100644 services/frontend/src/lib/trpc/types.ts diff --git a/flake.nix b/flake.nix index 05a8daa..5000a9f 100644 --- a/flake.nix +++ b/flake.nix @@ -101,31 +101,8 @@ buildPhase = pnpmInstall + '' echo "=== Compiling TypeScript ===" npx tsc 2>&1 - echo "=== Fixing @/ path aliases to relative paths ===" - node -e " - const fs = require('fs'); - const path = require('path'); - let count = 0; - function walk(dir) { - if (!fs.existsSync(dir)) return; - for (const e of fs.readdirSync(dir, {withFileTypes: true})) { - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (e.name.endsWith('.js')) { - const c = fs.readFileSync(p, 'utf8'); - const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g; - const n = c.replace(pat, (m, p1) => { - const target = path.join('dist', p1) + '.js'; - const rel = path.relative(path.dirname(p), target); - return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"'; - }); - if (n !== c) { fs.writeFileSync(p, n); count++; } - } - } - } - walk('dist'); - console.log('Fixed ' + count + ' files'); - " + echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ===" + node scripts/fix-imports.mjs echo "=== Build complete ===" '' + pruneProd; @@ -202,31 +179,8 @@ WRAPPER pnpm rebuild @discordjs/opus 2>&1 || true echo "=== Compiling TypeScript ====" npx tsc 2>&1 - echo "=== Fixing @/ path aliases to relative paths ===" - node -e " - const fs = require('fs'); - const path = require('path'); - let count = 0; - function walk(dir) { - if (!fs.existsSync(dir)) return; - for (const e of fs.readdirSync(dir, {withFileTypes: true})) { - const p = path.join(dir, e.name); - if (e.isDirectory()) walk(p); - else if (e.name.endsWith('.js')) { - const c = fs.readFileSync(p, 'utf8'); - const pat = /from\s+['\"]@\/([^'\"]+)['\"]/g; - const n = c.replace(pat, (m, p1) => { - const target = path.join('dist', p1) + '.js'; - const rel = path.relative(path.dirname(p), target); - return 'from \"' + (rel.startsWith('.') ? rel : './' + rel) + '\"'; - }); - if (n !== c) { fs.writeFileSync(p, n); count++; } - } - } - } - walk('dist'); - console.log('Fixed ' + count + ' files'); - " + echo "=== Fixing @/ path aliases + extensionless relative imports for node ESM ===" + node scripts/fix-imports.mjs echo "=== Build complete ===" '' + pruneProd; diff --git a/services/backend/package.json b/services/backend/package.json index f04f270..8b00a28 100644 --- a/services/backend/package.json +++ b/services/backend/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@discordjs/voice": "^0.19.2", - "@trpc/server": "^11.18.0", + "@orpc/server": "1.15.0", "axios": "^1.16.1", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", diff --git a/services/backend/pnpm-lock.yaml b/services/backend/pnpm-lock.yaml index 2586174..bd3033d 100644 --- a/services/backend/pnpm-lock.yaml +++ b/services/backend/pnpm-lock.yaml @@ -11,9 +11,9 @@ importers: '@discordjs/voice': specifier: ^0.19.2 version: 0.19.2(@emnapi/core@2.0.0-alpha.3)(@emnapi/runtime@2.0.0-alpha.3) - '@trpc/server': - specifier: ^11.18.0 - version: 11.18.0(typescript@5.9.3) + '@orpc/server': + specifier: 1.15.0 + version: 1.15.0(@opentelemetry/api@1.9.1)(ws@8.21.1) axios: specifier: ^1.16.1 version: 1.19.0(debug@4.4.3) @@ -318,6 +318,57 @@ packages: resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} engines: {node: '>=8.0.0'} + '@orpc/client@1.15.0': + resolution: {integrity: sha512-Qt0FdPSGySdQwy83iUWOq+Iqhw2gM9qHtyxfBbcw1mwOz1s4O2BwUFA3ymVTLIRRNYRgPrQLaBZhp8CacqeePg==} + + '@orpc/contract@1.15.0': + resolution: {integrity: sha512-zo8+x+5iqnIMrFdcj+ibVgn6dJneWzLNcD3kZQxrJFGFNAFoxvZbwryR5wOdcHkaSCeS3N7Ib2NydU6pihSYmg==} + + '@orpc/interop@1.15.0': + resolution: {integrity: sha512-Ah48o/rc00rN1yP5HcaoVsDCjdUhoh3JP1VibBbvwijhGvdOwRwiw5RWiGu442GSKcrPt9etAGhWXH88q8C3ww==} + + '@orpc/server@1.15.0': + resolution: {integrity: sha512-l9FnL1MjQ8g77mB10/8D61okuKHLJBWhuwmd6OF65POHzBF177kMCcbHvRFEqUuxSbzP2eNiZGf3kXPMHmIKiQ==} + peerDependencies: + crossws: '>=0.3.4' + ws: '>=8.18.1' + peerDependenciesMeta: + crossws: + optional: true + ws: + optional: true + + '@orpc/shared@1.15.0': + resolution: {integrity: sha512-A3/JE7pQYSrrRm6/WYJxV3GBhpMdJRPM3h47slQtWBUZe9Sao5En5WBc4tISGQNP2emcez6qIvDziSgD2T+img==} + peerDependencies: + '@opentelemetry/api': '>=1.9.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@orpc/standard-server-aws-lambda@1.15.0': + resolution: {integrity: sha512-laFW7C4t1gEV0NIt4EjCVCBU3kFPnj2FObJLQkTH+DkEOzxVy6zyJ1coIncDSeOPxzbf7rG8cs0B2vQQyuROag==} + + '@orpc/standard-server-fastify@1.15.0': + resolution: {integrity: sha512-fcOl2004o3RnZxW7rhLuDCrCKSi/H2eWnyljeeoY+xykcygRf0ONCs2K2bSdxPm6cmvQGAXa9m8VBglG9oERVQ==} + peerDependencies: + fastify: '>=5.6.1' + peerDependenciesMeta: + fastify: + optional: true + + '@orpc/standard-server-fetch@1.15.0': + resolution: {integrity: sha512-XYVfgmIt71YrPJSI7RKRiNWjcyktPYHpevkjeohGNBP5aSMAUL95quZArAWr+XOPp4U56XRao54I6wFMZa4How==} + + '@orpc/standard-server-node@1.15.0': + resolution: {integrity: sha512-3ye9SIIhYfJckQwyyGQQRHAEF5vW6QDbax7oi1mOQ9UBHaRDH5kcL28mT0JnETrUvgBoDRx+rfCsQaAOB4n3zw==} + + '@orpc/standard-server-peer@1.15.0': + resolution: {integrity: sha512-fsTN+FrdPkseVx99yRenGJYSp0xOrEk9fODk8oh3zzricNYzlZ8GGgCYtDNgt9vFIR/J05dc+Nk76KhcPBQpLw==} + + '@orpc/standard-server@1.15.0': + resolution: {integrity: sha512-bje/xn6thDqJY/JQ7xoOjmD1KWE4FsyZOPgnPxwqTZLx/r00stRhLuFvk1hDEGj9UlG7NLZEijDVx0wqvcyDzA==} + '@oxc-project/types@0.142.0': resolution: {integrity: sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==} @@ -515,12 +566,6 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} - '@trpc/server@11.18.0': - resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} - hasBin: true - peerDependencies: - typescript: '>=5.7.2' - '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -674,6 +719,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1083,6 +1132,9 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -1203,6 +1255,10 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} + engines: {node: '>=14.18.0'} + range-parser@1.3.0: resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} @@ -1293,6 +1349,10 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tdigest@0.1.2: resolution: {integrity: sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==} @@ -1326,6 +1386,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -1619,6 +1683,97 @@ snapshots: '@opentelemetry/api@1.9.1': {} + '@orpc/client@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-peer': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/contract@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/client': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@standard-schema/spec': 1.1.0 + openapi-types: 12.1.3 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/interop@1.15.0': {} + + '@orpc/server@1.15.0(@opentelemetry/api@1.9.1)(ws@8.21.1)': + dependencies: + '@orpc/client': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/contract': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/interop': 1.15.0 + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-aws-lambda': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-fastify': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-node': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-peer': 1.15.0(@opentelemetry/api@1.9.1) + cookie: 1.1.1 + optionalDependencies: + ws: 8.21.1 + transitivePeerDependencies: + - '@opentelemetry/api' + - fastify + + '@orpc/shared@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + radash: 12.1.1 + type-fest: 5.8.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + + '@orpc/standard-server-aws-lambda@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-node': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-fastify@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-node': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-fetch@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-node@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server-fetch': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-peer@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + '@orpc/standard-server': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server@1.15.0(@opentelemetry/api@1.9.1)': + dependencies: + '@orpc/shared': 1.15.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - '@opentelemetry/api' + '@oxc-project/types@0.142.0': {} '@pinojs/redact@0.4.0': {} @@ -1743,10 +1898,6 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@trpc/server@11.18.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -1928,6 +2079,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.1.1: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -2245,6 +2398,8 @@ snapshots: dependencies: wrappy: 1.0.2 + openapi-types@12.1.3: {} + parseurl@1.3.3: {} path-to-regexp@8.4.2: {} @@ -2349,6 +2504,8 @@ snapshots: quick-format-unescaped@4.0.4: {} + radash@12.1.1: {} + range-parser@1.3.0: {} raw-body@3.0.2: @@ -2474,6 +2631,8 @@ snapshots: std-env@4.2.0: {} + tagged-tag@1.0.0: {} + tdigest@0.1.2: dependencies: bintrees: 1.0.2 @@ -2503,6 +2662,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + type-is@2.1.0: dependencies: content-type: 2.0.0 diff --git a/services/backend/scripts/fix-imports.mjs b/services/backend/scripts/fix-imports.mjs new file mode 100644 index 0000000..3a3858c --- /dev/null +++ b/services/backend/scripts/fix-imports.mjs @@ -0,0 +1,49 @@ +// Rewrite import specifiers in the compiled dist/ so the output runs under +// plain `node dist/index.js` (native ESM, no bundler / no tsx). +// +// Background: tsconfig uses moduleResolution:"bundler", so `tsc` emits BARE +// relative specifiers WITHOUT extensions (e.g. `import "./router"`) and leaves +// the `@/*` path-alias imports untouched. Node's native ESM resolver rejects +// extensionless relative specifiers and knows nothing about the `@/` alias, so +// the emitted dist/ crashes at startup (`ERR_MODULE_NOT_FOUND`). This script +// fixes both: +// 1. `@/foo` -> relative path to dist/foo.js +// 2. `./foo` / `../foo` -> `./foo.js` / `../foo.js` (append .js) +// Already-extensioned relative imports (.js/.json/.node/.mjs/.cjs) and bare +// package specifiers are left untouched (idempotent). +import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; +import { join, relative, dirname } from "node:path"; + +let count = 0; +function walk(dir) { + if (!existsSync(dir)) return; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".js")) { + const c = readFileSync(p, "utf8"); + const pat = /from\s+['"]([^'"]+)['"]/g; + const n = c.replace(pat, (m, spec) => { + if (spec.startsWith("@/")) { + const target = join("dist", spec.slice(2)) + ".js"; + let rel = relative(dirname(p), target); + if (!rel.startsWith(".")) rel = "./" + rel; + return `from "${rel}"`; + } + if ( + (spec.startsWith("./") || spec.startsWith("../")) && + !/\.(js|json|node|mjs|cjs)$/.test(spec) + ) { + return `from "${spec}.js"`; + } + return m; + }); + if (n !== c) { + writeFileSync(p, n); + count++; + } + } + } +} +walk("dist"); +console.log(`Fixed ${count} import specifiers in dist/`); diff --git a/services/backend/src/http/app.ts b/services/backend/src/http/app.ts index 122d859..bc76f80 100644 --- a/services/backend/src/http/app.ts +++ b/services/backend/src/http/app.ts @@ -1,4 +1,5 @@ -import { nodeHTTPRequestHandler } from "@trpc/server/adapters/node-http"; +import { RPCHandler } from "@orpc/server/node"; +import { onError } from "@orpc/server"; import express, { type Express, type NextFunction, @@ -9,13 +10,13 @@ import helmet from "helmet"; import { createChildLogger } from "@/shared/logger/index"; import { createHealthRouter } from "../modules/health/index.js"; import { errorHandler } from "../shared/middlewares/index.js"; -import { appRouter } from "../trpc/routers"; +import { appRouter } from "../orpc/router"; // Auth removed — dashboard is public. // All data APIs (dashboard, messages, moderation, media, voice, recordings, -// analysis, chatbot, config, ui-state) now flow over tRPC, served on TWO +// analysis, chatbot, config, ui-state) now flow over oRPC, served on TWO // transports sharing the /trpc path: -// - WebSocket (browser live RPCs) — see trpc/ws.ts +// - WebSocket (browser live RPCs) — see orpc/ws.ts // - HTTP POST (server-side / RSC fetch) — handled below // Only infra endpoints (health, prometheus metrics) remain plain HTTP. @@ -31,7 +32,7 @@ export function createHttpApp(): Express { }), ); - // Body parsing (still needed for any JSON POST; tRPC is WS-based) + // Body parsing (still needed for any JSON POST; oRPC is WS/HTTP-based) app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -60,29 +61,27 @@ export function createHttpApp(): Express { // Infra-only HTTP endpoints app.use("/api", createHealthRouter()); - // tRPC over HTTP (server-side / RSC fetch). The context has no WebSocket - // here (that's the WS transport's job); procedures don't read ctx.conn, so - // a null conn is safe. - // NOTE: Express 5 (path-to-regexp v8) rejects the `"/trpc/*"` wildcard route, - // and `nodeHTTPRequestHandler` uses `opts.path` as the literal procedure - // path (it does NOT derive it from `req.url`). So we mount a plain - // middleware and compute the procedure path from the URL ourselves. + // oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser + // reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full + // response itself; if no procedure matched we fall through to the 404 below. + const orpcHandler = new RPCHandler(appRouter, { + interceptors: [onError((error) => logger.error({ error }, "oRPC error"))], + }); + app.use((req: Request, res: Response, next: NextFunction) => { if (!req.path.startsWith("/trpc")) { next(); return; } - const procPath = req.url.replace(/^\/trpc\/?/, "").split("?")[0] || "/"; - nodeHTTPRequestHandler({ - router: appRouter, - createContext: () => ({ conn: null }), - req, - res, - path: procPath, - }).catch((err: unknown) => { - logger.error({ err }, "tRPC HTTP handler failed"); - if (!res.headersSent) res.status(500).json({ error: "INTERNAL" }); - }); + orpcHandler + .handle(req, res, { prefix: "/trpc", context: {} }) + .then(({ matched }) => { + if (!matched) next(); + }) + .catch((err: unknown) => { + logger.error({ err }, "oRPC HTTP handler failed"); + if (!res.headersSent) res.status(500).json({ error: "INTERNAL" }); + }); }); // 404 handler diff --git a/services/backend/src/http/server.ts b/services/backend/src/http/server.ts index c54d3fb..aaa2125 100644 --- a/services/backend/src/http/server.ts +++ b/services/backend/src/http/server.ts @@ -2,7 +2,7 @@ import { createServer, type Server } from "node:http"; import { createChildLogger } from "@/shared/logger/index"; import { config } from "../shared/config/index.js"; import { initializeDatabase } from "../shared/database/index.js"; -import { createTRPCWebSocketServer } from "../trpc/ws.js"; +import { createORPCWebSocketServer } from "../orpc/ws.js"; import { startRedisBridge } from "../ws/redis-bridge.js"; import { createWebSocketServer } from "../ws/server.js"; import { createHttpApp } from "./app.js"; @@ -19,7 +19,7 @@ export async function startHttpServer(): Promise { // Attach WebSocket servers to the same HTTP server createWebSocketServer(server); // /ws — voice PCM + gateway events - createTRPCWebSocketServer(server); // /trpc — structured data RPCs + createORPCWebSocketServer(server); // /trpc — structured data RPCs // Start Redis pub/sub bridge to forward discord-gateway events to WS clients await startRedisBridge(); diff --git a/services/backend/src/trpc/routers.ts b/services/backend/src/orpc/router.ts similarity index 72% rename from services/backend/src/trpc/routers.ts rename to services/backend/src/orpc/router.ts index ec7d8fa..9366fc4 100644 --- a/services/backend/src/trpc/routers.ts +++ b/services/backend/src/orpc/router.ts @@ -1,20 +1,12 @@ +import { os } from "@orpc/server"; import { z } from "zod"; import { analysisService } from "../modules/analysis/analysis.service"; import { chatRequestSchema } from "../modules/chatbot/chatbot.schema"; import { chatbotService } from "../modules/chatbot/chatbot.service"; // ── Service imports ────────────────────────────────────────────── import { dashboardService } from "../modules/dashboard/dashboard.service"; -import { - mediaLoopSchema, - mediaQueueSchema, -} from "../modules/media/media.schema"; -import { - getStatus, - queue, - setLoop, - skip, - stop, -} from "../modules/media/media.service"; +import { mediaLoopSchema, mediaQueueSchema } from "../modules/media/media.schema"; +import { getStatus, queue, setLoop, skip, stop } from "../modules/media/media.service"; import { messageQuerySchema } from "../modules/messages/messages.schema"; import { messagesService } from "../modules/messages/messages.service"; import { moderationService } from "../modules/moderation/moderation.service"; @@ -30,17 +22,14 @@ import { } from "../modules/voice/voice.service"; import { config } from "../shared/config/index"; import { publishCommandNoReply } from "../shared/redis/index"; -import { logger, publicProcedure, router } from "./trpc"; // ── Dashboard ──────────────────────────────────────────────────── -const dashboardRouter = router({ - stats: publicProcedure.query(() => dashboardService.getStats()), - activity: publicProcedure - .input( - z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }), - ) - .query(({ input }) => dashboardService.getActivity(input.days)), - users: publicProcedure +const dashboardRouter = { + stats: os.handler(() => dashboardService.getStats()), + activity: os + .input(z.object({ days: z.coerce.number().int().min(1).max(90).default(14) })) + .handler(({ input }) => dashboardService.getActivity(input.days)), + users: os .input( z.object({ limit: z.coerce.number().int().positive().default(20), @@ -48,17 +37,17 @@ const dashboardRouter = router({ search: z.string().optional(), }), ) - .query(({ input }) => + .handler(({ input }) => dashboardService.listUsers({ limit: input.limit, cursor: input.cursor, search: input.search, }), ), - userDetail: publicProcedure + userDetail: os .input(z.object({ userId: z.string() })) - .query(({ input }) => dashboardService.getUserDetail(input.userId)), - channels: publicProcedure + .handler(({ input }) => dashboardService.getUserDetail(input.userId)), + channels: os .input( z.object({ limit: z.coerce.number().int().positive().default(20), @@ -66,82 +55,82 @@ const dashboardRouter = router({ guildId: z.string().optional(), }), ) - .query(({ input }) => + .handler(({ input }) => dashboardService.listChannels({ limit: input.limit, search: input.search, guildId: input.guildId, }), ), - channelDetail: publicProcedure + channelDetail: os .input(z.object({ channelId: z.string() })) - .query(({ input }) => dashboardService.getChannelDetail(input.channelId)), - reactions: publicProcedure + .handler(({ input }) => dashboardService.getChannelDetail(input.channelId)), + reactions: os .input(z.object({ limit: z.coerce.number().int().positive().default(20) })) - .query(({ input }) => dashboardService.getTopReactions(input.limit)), - reactors: publicProcedure + .handler(({ input }) => dashboardService.getTopReactions(input.limit)), + reactors: os .input(z.object({ limit: z.coerce.number().int().positive().default(20) })) - .query(({ input }) => dashboardService.getTopReactors(input.limit)), -}); + .handler(({ input }) => dashboardService.getTopReactors(input.limit)), +}; // ── Messages ───────────────────────────────────────────────────── -const messagesRouter = router({ - list: publicProcedure +const messagesRouter = { + list: os .input(messageQuerySchema) - .query(({ input }) => messagesService.listMessages(input)), - byChannel: publicProcedure + .handler(({ input }) => messagesService.listMessages(input)), + byChannel: os .input( z.object({ channelId: z.string(), query: messageQuerySchema, }), ) - .query(({ input }) => + .handler(({ input }) => messagesService.getMessagesByChannel(input.channelId, input.query), ), - detail: publicProcedure + detail: os .input(z.object({ id: z.string() })) - .query(({ input }) => messagesService.getMessageById(input.id)), - images: publicProcedure + .handler(({ input }) => messagesService.getMessageById(input.id)), + images: os .input( z.object({ guildId: z.string(), limit: z.coerce.number().int().positive().default(50), }), ) - .query(({ input }) => + .handler(({ input }) => messagesService.getImageMessages(input.guildId, input.limit), ), - attachmentsByChannel: publicProcedure + attachmentsByChannel: os .input( z.object({ channelId: z.string(), query: messageQuerySchema, }), ) - .query(({ input }) => + .handler(({ input }) => messagesService.getAttachmentsByChannel(input.channelId, input.query), ), - review: publicProcedure + review: os .input( z.object({ limit: z.coerce.number().int().positive().default(20), channelId: z.string().optional(), }), ) - .query(async ({ input }) => { + .handler(async ({ input }) => { const rows = await messagesService.getReviewMessages( input.channelId, input.limit, ); return { results: rows, limit: input.limit, cursor: null }; }), -}); +}; // ── Moderation ─────────────────────────────────────────────────── -const moderationRouter = router({ - stats: publicProcedure.query(() => moderationService.getStats()), - actions: publicProcedure +const moderationRouter = { + stats: os.handler(() => moderationService.getStats()), + actions: os .input( z.object({ limit: z.coerce.number().int().positive().default(50), @@ -150,7 +139,7 @@ const moderationRouter = router({ cursor: z.coerce.number().int().optional(), }), ) - .query(({ input }) => + .handler(({ input }) => moderationService.listActions({ limit: input.limit, status: input.status, @@ -158,60 +147,64 @@ const moderationRouter = router({ cursor: input.cursor, }), ), -}); +}; // ── Media ──────────────────────────────────────────────────────── -const mediaRouter = router({ - status: publicProcedure.query(() => getStatus()), - queue: publicProcedure.input(mediaQueueSchema).mutation(async ({ input }) => { - await queue(input.source, input.mode); - return getStatus(); - }), - skip: publicProcedure.mutation(async () => { +const mediaRouter = { + status: os.handler(() => getStatus()), + queue: os + .input(mediaQueueSchema) + .handler(async ({ input }) => { + await queue(input.source, input.mode); + return getStatus(); + }), + skip: os.handler(async () => { await skip(); return getStatus(); }), - stop: publicProcedure.mutation(async () => { + stop: os.handler(async () => { await stop(); return getStatus(); }), - loop: publicProcedure.input(mediaLoopSchema).mutation(async ({ input }) => { - await setLoop(input.loop); - return getStatus(); - }), -}); + loop: os + .input(mediaLoopSchema) + .handler(async ({ input }) => { + await setLoop(input.loop); + return getStatus(); + }), +}; // ── Voice ───────────────────────────────────────────────────────── -const voiceRouter = router({ - guilds: publicProcedure.query(() => getGuilds()), - textChannels: publicProcedure +const voiceRouter = { + guilds: os.handler(() => getGuilds()), + textChannels: os .input(z.object({ guildId: z.string() })) - .query(({ input }) => getTextChannels(input.guildId)), - voiceChannels: publicProcedure + .handler(({ input }) => getTextChannels(input.guildId)), + voiceChannels: os .input(z.object({ guildId: z.string() })) - .query(({ input }) => getVoiceChannels(input.guildId)), - status: publicProcedure.query(() => getVoiceStatus()), - connect: publicProcedure + .handler(({ input }) => getVoiceChannels(input.guildId)), + status: os.handler(() => getVoiceStatus()), + connect: os .input(z.object({ guildId: z.string(), channelId: z.string() })) - .mutation(async ({ input }) => { + .handler(async ({ input }) => { await connectVoice(input.guildId, input.channelId); return getVoiceStatus(); }), - disconnect: publicProcedure.mutation(async () => { + disconnect: os.handler(async () => { await disconnectVoice(); return getVoiceStatus(); }), - command: publicProcedure + command: os .input(z.object({ command: z.string().min(1) })) - .mutation(async ({ input }) => { + .handler(async ({ input }) => { await publishCommandNoReply(input.command); return { success: true, command: input.command }; }), -}); +}; // ── Recordings ─────────────────────────────────────────────────── -const recordingsRouter = router({ - list: publicProcedure +const recordingsRouter = { + list: os .input( z.object({ limit: z.coerce.number().int().positive().default(50), @@ -220,24 +213,24 @@ const recordingsRouter = router({ cursor: z.string().optional(), }), ) - .query(({ input }) => + .handler(({ input }) => recordingsService.getRecent(input.limit, { channelId: input.channelId, userId: input.userId, cursor: input.cursor, }), ), - delete: publicProcedure + delete: os .input(z.object({ id: z.string() })) - .mutation(async ({ input }) => { + .handler(async ({ input }) => { await recordingsService.deleteById(input.id); return { ok: true }; }), -}); +}; // ── Analysis (search) ────────────────────────────────────────────── -const analysisRouter = router({ - search: publicProcedure +const analysisRouter = { + search: os .input( z.object({ q: z.string().default(""), @@ -245,18 +238,18 @@ const analysisRouter = router({ limit: z.coerce.number().int().positive().default(20), }), ) - .query(({ input }) => + .handler(({ input }) => analysisService.search({ q: input.q, channelId: input.channelId, limit: input.limit, }), ), -}); +}; // ── Chatbot ─────────────────────────────────────────────────────── -const chatbotRouter = router({ - chat: publicProcedure +const chatbotRouter = { + chat: os .input( chatRequestSchema.extend({ // Per-device actor id; the old REST layer used an X-User-Id header. @@ -264,7 +257,7 @@ const chatbotRouter = router({ userId: z.string().optional(), }), ) - .mutation(async ({ input }) => { + .handler(async ({ input }) => { const userId = input.userId ?? "anonymous"; const response = await chatbotService.processMessage( input.message, @@ -280,30 +273,30 @@ const chatbotRouter = router({ }); return { response, timestamp: new Date().toISOString() }; }), - history: publicProcedure + history: os .input( z.object({ limit: z.coerce.number().int().positive().max(100).default(50), userId: z.string().optional(), }), ) - .query(async ({ input }) => { + .handler(async ({ input }) => { const userId = input.userId ?? "anonymous"; const history = await chatbotService.getChatHistory(userId, input.limit); return { history, total: history.length }; }), - clearHistory: publicProcedure + clearHistory: os .input(z.object({ userId: z.string().optional() })) - .mutation(async ({ input }) => { + .handler(async ({ input }) => { const userId = input.userId ?? "anonymous"; await chatbotService.clearChatHistory(userId); return { ok: true }; }), -}); +}; // ── Config (public dashboard config snapshot) ────────────────────── -const configRouter = router({ - get: publicProcedure.query(() => ({ +const configRouter = { + get: os.handler(() => ({ monitorGuildId: config.MONITOR_GUILD_ID || null, webserverPort: config.WEBSERVER_PORT, nodeEnv: config.NODE_ENV, @@ -318,18 +311,18 @@ const configRouter = router({ voiceChannelId: config.VOICE_CHANNEL_ID || null, logLevel: config.LOG_LEVEL, })), -}); +}; // ── UI State ────────────────────────────────────────────────────── -const uiStateRouter = router({ - get: publicProcedure.query(() => uiStateService.getState()), - update: publicProcedure +const uiStateRouter = { + get: os.handler(() => uiStateService.getState()), + update: os .input(z.record(z.string(), z.unknown())) - .mutation(({ input }) => uiStateService.updateState(input)), -}); + .handler(({ input }) => uiStateService.updateState(input)), +}; // ── Root router ─────────────────────────────────────────────────── -export const appRouter = router({ +export const appRouter = { dashboard: dashboardRouter, messages: messagesRouter, moderation: moderationRouter, @@ -340,8 +333,6 @@ export const appRouter = router({ chatbot: chatbotRouter, config: configRouter, uiState: uiStateRouter, -}); +}; export type AppRouter = typeof appRouter; - -logger.info("tRPC appRouter constructed"); diff --git a/services/backend/src/trpc/ws.ts b/services/backend/src/orpc/ws.ts similarity index 66% rename from services/backend/src/trpc/ws.ts rename to services/backend/src/orpc/ws.ts index 8ab0b79..2967bc4 100644 --- a/services/backend/src/trpc/ws.ts +++ b/services/backend/src/orpc/ws.ts @@ -1,14 +1,15 @@ import type { IncomingMessage, Server } from "node:http"; import type { Duplex } from "node:stream"; -import { applyWSSHandler } from "@trpc/server/adapters/ws"; +import { RPCHandler } from "@orpc/server/ws"; +import { onError } from "@orpc/server"; import { WebSocketServer } from "ws"; import { createChildLogger } from "@/shared/logger/index"; -import { appRouter } from "./routers"; +import { appRouter } from "./router"; -const logger = createChildLogger("trpc.ws"); +const logger = createChildLogger("orpc.ws"); /** - * Attach the tRPC WebSocket handler to the shared HTTP server, on a path + * Attach the oRPC WebSocket handler to the shared HTTP server, on a path * SEPARATE from the voice/binary WebSocket (`/ws`). All structured data RPCs * (dashboard, messages, moderation, media, voice control, recordings, * analysis, chatbot, config, ui-state) flow over this `/trpc` socket; the @@ -21,27 +22,20 @@ const logger = createChildLogger("trpc.ws"); * other server's path. Routing the upgrade ourselves by URL keeps `/trpc` * and `/ws` fully isolated. */ -export function createTRPCWebSocketServer(server: Server): WebSocketServer { - const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false }); - - applyWSSHandler({ - wss, - prefix: "/trpc", - router: appRouter, - createContext: (opts) => ({ conn: opts.res }), - keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 10_000 }, - onError: (err) => { - logger.error({ err }, "tRPC WS error"); - }, +export function createORPCWebSocketServer(server: Server): WebSocketServer { + const handler = new RPCHandler(appRouter, { + interceptors: [onError((error) => logger.error({ error }, "oRPC WS error"))], }); + const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false }); + server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => { if (!req.url?.startsWith("/trpc")) return; // let the /ws server handle it wss.handleUpgrade(req, socket, head, (ws) => { - wss.emit("connection", ws, req); + handler.upgrade(ws, { context: {} }); }); }); - logger.info({ path: "/trpc" }, "tRPC WebSocket server attached"); + logger.info({ path: "/trpc" }, "oRPC WebSocket server attached"); return wss; } diff --git a/services/backend/src/trpc/trpc.ts b/services/backend/src/trpc/trpc.ts deleted file mode 100644 index ab2092d..0000000 --- a/services/backend/src/trpc/trpc.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { initTRPC } from "@trpc/server"; -import type { WebSocket } from "ws"; -import { createChildLogger } from "@/shared/logger/index"; - -const logger = createChildLogger("trpc"); - -/** - * tRPC context. The WebSocket transport enriches each request with the raw - * socket so procedures can, if needed, inspect connection metadata. The - * dashboard is public (no auth), mirroring the previous REST layer. - */ -export interface TRPCContext { - conn: WebSocket | null; -} - -const t = initTRPC.context().create({ - errorFormatter({ shape, error }) { - return { - ...shape, - data: { - ...shape.data, - // Surface a stable code + message for client-side handling. - code: error.code, - stack: undefined, - }, - }; - }, -}); - -export const router = t.router; -export const publicProcedure = t.procedure; - -// Re-export so routers can import z from one place if desired. -export { z } from "zod"; -export { logger }; diff --git a/services/discord-gateway/scripts/fix-imports.mjs b/services/discord-gateway/scripts/fix-imports.mjs new file mode 100644 index 0000000..3a3858c --- /dev/null +++ b/services/discord-gateway/scripts/fix-imports.mjs @@ -0,0 +1,49 @@ +// Rewrite import specifiers in the compiled dist/ so the output runs under +// plain `node dist/index.js` (native ESM, no bundler / no tsx). +// +// Background: tsconfig uses moduleResolution:"bundler", so `tsc` emits BARE +// relative specifiers WITHOUT extensions (e.g. `import "./router"`) and leaves +// the `@/*` path-alias imports untouched. Node's native ESM resolver rejects +// extensionless relative specifiers and knows nothing about the `@/` alias, so +// the emitted dist/ crashes at startup (`ERR_MODULE_NOT_FOUND`). This script +// fixes both: +// 1. `@/foo` -> relative path to dist/foo.js +// 2. `./foo` / `../foo` -> `./foo.js` / `../foo.js` (append .js) +// Already-extensioned relative imports (.js/.json/.node/.mjs/.cjs) and bare +// package specifiers are left untouched (idempotent). +import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs"; +import { join, relative, dirname } from "node:path"; + +let count = 0; +function walk(dir) { + if (!existsSync(dir)) return; + for (const e of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, e.name); + if (e.isDirectory()) walk(p); + else if (e.name.endsWith(".js")) { + const c = readFileSync(p, "utf8"); + const pat = /from\s+['"]([^'"]+)['"]/g; + const n = c.replace(pat, (m, spec) => { + if (spec.startsWith("@/")) { + const target = join("dist", spec.slice(2)) + ".js"; + let rel = relative(dirname(p), target); + if (!rel.startsWith(".")) rel = "./" + rel; + return `from "${rel}"`; + } + if ( + (spec.startsWith("./") || spec.startsWith("../")) && + !/\.(js|json|node|mjs|cjs)$/.test(spec) + ) { + return `from "${spec}.js"`; + } + return m; + }); + if (n !== c) { + writeFileSync(p, n); + count++; + } + } + } +} +walk("dist"); +console.log(`Fixed ${count} import specifiers in dist/`); diff --git a/services/frontend/package.json b/services/frontend/package.json index a50901c..cb2d595 100644 --- a/services/frontend/package.json +++ b/services/frontend/package.json @@ -10,12 +10,13 @@ "format": "biome format --write" }, "dependencies": { - "@trpc/client": "^11.18.0", + "@orpc/client": "1.15.0", "clsx": "^2.1.1", "lucide-react": "^1.27.0", "motion": "^12.0.0", "next": "16.2.12", "next-themes": "^0.4.6", + "partysocket": "1.3.0", "react": "19.2.4", "react-dom": "19.2.4", "swr": "^2.4.2", diff --git a/services/frontend/pnpm-lock.yaml b/services/frontend/pnpm-lock.yaml index 89f83fd..f13c6ae 100644 --- a/services/frontend/pnpm-lock.yaml +++ b/services/frontend/pnpm-lock.yaml @@ -8,9 +8,9 @@ importers: .: dependencies: - '@trpc/client': - specifier: ^11.18.0 - version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3) + '@orpc/client': + specifier: 1.15.0 + version: 1.15.0 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -26,6 +26,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + partysocket: + specifier: 1.3.0 + version: 1.3.0(react@19.2.4) react: specifier: 19.2.4 version: 19.2.4 @@ -433,6 +436,26 @@ packages: cpu: [x64] os: [win32] + '@orpc/client@1.15.0': + resolution: {integrity: sha512-Qt0FdPSGySdQwy83iUWOq+Iqhw2gM9qHtyxfBbcw1mwOz1s4O2BwUFA3ymVTLIRRNYRgPrQLaBZhp8CacqeePg==} + + '@orpc/shared@1.15.0': + resolution: {integrity: sha512-A3/JE7pQYSrrRm6/WYJxV3GBhpMdJRPM3h47slQtWBUZe9Sao5En5WBc4tISGQNP2emcez6qIvDziSgD2T+img==} + peerDependencies: + '@opentelemetry/api': '>=1.9.0' + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + + '@orpc/standard-server-fetch@1.15.0': + resolution: {integrity: sha512-XYVfgmIt71YrPJSI7RKRiNWjcyktPYHpevkjeohGNBP5aSMAUL95quZArAWr+XOPp4U56XRao54I6wFMZa4How==} + + '@orpc/standard-server-peer@1.15.0': + resolution: {integrity: sha512-fsTN+FrdPkseVx99yRenGJYSp0xOrEk9fODk8oh3zzricNYzlZ8GGgCYtDNgt9vFIR/J05dc+Nk76KhcPBQpLw==} + + '@orpc/standard-server@1.15.0': + resolution: {integrity: sha512-bje/xn6thDqJY/JQ7xoOjmD1KWE4FsyZOPgnPxwqTZLx/r00stRhLuFvk1hDEGj9UlG7NLZEijDVx0wqvcyDzA==} + '@puppeteer/browsers@3.2.0': resolution: {integrity: sha512-LlBrE8oqGfU7b1Nk2d5Q1SbuPhZxTj0cJEMDPEws28OjNMELlflekmPPuf4FnK03x0ZRjKaYwJElUcKK4kyqJA==} engines: {node: '>=22.12.0'} @@ -541,19 +564,6 @@ packages: '@tailwindcss/postcss@4.3.3': resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==} - '@trpc/client@11.18.0': - resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==} - hasBin: true - peerDependencies: - '@trpc/server': 11.18.0 - typescript: '>=5.7.2' - - '@trpc/server@11.18.0': - resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==} - hasBin: true - peerDependencies: - typescript: '>=5.7.2' - '@tweenjs/tween.js@23.1.3': resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} @@ -661,6 +671,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + event-target-polyfill@0.0.4: + resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==} + fflate@0.8.3: resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} @@ -864,6 +877,14 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + partysocket@1.3.0: + resolution: {integrity: sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==} + peerDependencies: + react: '>=17' + peerDependenciesMeta: + react: + optional: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -879,6 +900,10 @@ packages: resolution: {integrity: sha512-wgBBj7dU5ceGyoT2PCrJpkYOhxPY8mDOmcSKZP92Cj5GgqQ3kv/UxzawnOLxiYuupe4bDf/yiQm3bzs/1nI0rQ==} engines: {node: '>=22.12.0'} + radash@12.1.1: + resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==} + engines: {node: '>=14.18.0'} + react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -938,6 +963,10 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -954,6 +983,10 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + type-fest@5.8.0: + resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==} + engines: {node: '>=20'} + typed-query-selector@2.12.2: resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==} @@ -1314,6 +1347,40 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.12': optional: true + '@orpc/client@1.15.0': + dependencies: + '@orpc/shared': 1.15.0 + '@orpc/standard-server': 1.15.0 + '@orpc/standard-server-fetch': 1.15.0 + '@orpc/standard-server-peer': 1.15.0 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/shared@1.15.0': + dependencies: + radash: 12.1.1 + type-fest: 5.8.0 + + '@orpc/standard-server-fetch@1.15.0': + dependencies: + '@orpc/shared': 1.15.0 + '@orpc/standard-server': 1.15.0 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server-peer@1.15.0': + dependencies: + '@orpc/shared': 1.15.0 + '@orpc/standard-server': 1.15.0 + transitivePeerDependencies: + - '@opentelemetry/api' + + '@orpc/standard-server@1.15.0': + dependencies: + '@orpc/shared': 1.15.0 + transitivePeerDependencies: + - '@opentelemetry/api' + '@puppeteer/browsers@3.2.0': dependencies: modern-tar: 0.8.4 @@ -1392,15 +1459,6 @@ snapshots: postcss: 8.5.25 tailwindcss: 4.3.3 - '@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)': - dependencies: - '@trpc/server': 11.18.0(typescript@5.9.3) - typescript: 5.9.3 - - '@trpc/server@11.18.0(typescript@5.9.3)': - dependencies: - typescript: 5.9.3 - '@tweenjs/tween.js@23.1.3': {} '@types/node@20.19.43': @@ -1496,6 +1554,8 @@ snapshots: escalade@3.2.0: {} + event-target-polyfill@0.0.4: {} + fflate@0.8.3: {} framer-motion@12.43.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): @@ -1647,6 +1707,12 @@ snapshots: node-releases@2.0.51: optional: true + partysocket@1.3.0(react@19.2.4): + dependencies: + event-target-polyfill: 0.0.4 + optionalDependencies: + react: 19.2.4 + picocolors@1.1.1: {} postcss@8.4.31: @@ -1675,6 +1741,8 @@ snapshots: - utf-8-validate - yauzl + radash@12.1.1: {} + react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -1752,6 +1820,8 @@ snapshots: react: 19.2.4 use-sync-external-store: 1.6.0(react@19.2.4) + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -1762,6 +1832,10 @@ snapshots: tslib@2.8.1: {} + type-fest@5.8.0: + dependencies: + tagged-tag: 1.0.0 + typed-query-selector@2.12.2: {} typescript@5.9.3: {} diff --git a/services/frontend/src/lib/api/chatbot.ts b/services/frontend/src/lib/api/chatbot.ts index 35e8b8c..13f24b5 100644 --- a/services/frontend/src/lib/api/chatbot.ts +++ b/services/frontend/src/lib/api/chatbot.ts @@ -1,22 +1,22 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types"; export const chatbotApi = { send: (message: string, guildId?: string, userId?: string) => - trpc.chatbot.chat.mutate({ + orpc.chatbot.chat({ message, context: guildId ? { guildId } : undefined, userId, }) as unknown as Promise, getHistory: (userId?: string) => - trpc.chatbot.history.query({ - limit: 50, - userId, - }) as unknown as Promise<{ history: ChatbotHistoryRow[]; total: number }>, + orpc.chatbot.history({ limit: 50, userId }) as unknown as Promise<{ + history: ChatbotHistoryRow[]; + total: number; + }>, clearHistory: (userId?: string) => - trpc.chatbot.clearHistory.mutate({ - userId, - }) as unknown as Promise<{ ok: boolean }>, + orpc.chatbot.clearHistory({ userId }) as unknown as Promise<{ + ok: boolean; + }>, }; diff --git a/services/frontend/src/lib/api/config.ts b/services/frontend/src/lib/api/config.ts index e25ae86..35c1baa 100644 --- a/services/frontend/src/lib/api/config.ts +++ b/services/frontend/src/lib/api/config.ts @@ -1,6 +1,6 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { AppConfig } from "@/lib/types"; export const configApi = { - get: () => trpc.config.get.query() as unknown as Promise, + get: () => orpc.config.get() as unknown as Promise, }; diff --git a/services/frontend/src/lib/api/dashboard.ts b/services/frontend/src/lib/api/dashboard.ts index 480fe99..2137013 100644 --- a/services/frontend/src/lib/api/dashboard.ts +++ b/services/frontend/src/lib/api/dashboard.ts @@ -1,4 +1,4 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { DashboardActivity, DashboardChannelDetail, @@ -11,45 +11,40 @@ import type { } from "@/lib/types"; export const dashboardApi = { - getStats: () => - trpc.dashboard.stats.query() as unknown as Promise, + getStats: () => orpc.dashboard.stats() as unknown as Promise, getActivity: (days = 14) => - trpc.dashboard.activity.query({ - days, - }) as unknown as Promise, + orpc.dashboard.activity({ days }) as unknown as Promise, listUsers: (limit?: number, cursor?: string, search?: string) => - trpc.dashboard.users.query({ + orpc.dashboard.users({ limit, cursor, search, }) as unknown as Promise, getUserDetail: (userId: string) => - trpc.dashboard.userDetail.query({ + orpc.dashboard.userDetail({ userId, }) as unknown as Promise, listChannels: (limit?: number, search?: string, guildId?: string) => - trpc.dashboard.channels.query({ + orpc.dashboard.channels({ limit, search, guildId, }) as unknown as Promise, getChannelDetail: (channelId: string) => - trpc.dashboard.channelDetail.query({ + orpc.dashboard.channelDetail({ channelId, }) as unknown as Promise, getTopReactions: (limit = 20) => - trpc.dashboard.reactions.query({ limit }) as unknown as Promise< + orpc.dashboard.reactions({ limit }) as unknown as Promise< TopReactedMessage[] >, getTopReactors: (limit = 20) => - trpc.dashboard.reactors.query({ limit }) as unknown as Promise< - TopReactor[] - >, + orpc.dashboard.reactors({ limit }) as unknown as Promise, }; diff --git a/services/frontend/src/lib/api/index.ts b/services/frontend/src/lib/api/index.ts index ae8b1e5..08f64e4 100644 --- a/services/frontend/src/lib/api/index.ts +++ b/services/frontend/src/lib/api/index.ts @@ -1,7 +1,8 @@ -// tRPC client is browser-only (wsLink). Re-export it for convenience / for any -// code that wants to call tRPC directly instead of going through the api/* -// wrappers. Server-side RSC data lives in ./server (httpLink). -export { trpc } from "../trpc/client"; +// oRPC client is browser-only (websocket RPCLink + partysocket). Re-export it +// for convenience / for any code that wants to call oRPC directly instead of +// going through the api/* wrappers. Server-side RSC data lives in ./server +// (fetch RPCLink). +export { orpc } from "../orpc/client"; export { chatbotApi } from "./chatbot"; export { configApi } from "./config"; export { dashboardApi } from "./dashboard"; diff --git a/services/frontend/src/lib/api/media.ts b/services/frontend/src/lib/api/media.ts index e3e3fec..4976bb9 100644 --- a/services/frontend/src/lib/api/media.ts +++ b/services/frontend/src/lib/api/media.ts @@ -1,12 +1,12 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { MediaState } from "@/lib/types"; export const mediaApi = { - getStatus: () => trpc.media.status.query() as unknown as Promise, + getStatus: () => orpc.media.status() as unknown as Promise, queue: (source: string, mode: string) => - trpc.media.queue.mutate({ source, mode }) as unknown as Promise, - skip: () => trpc.media.skip.mutate() as unknown as Promise, - stop: () => trpc.media.stop.mutate() as unknown as Promise, + orpc.media.queue({ source, mode }) as unknown as Promise, + skip: () => orpc.media.skip() as unknown as Promise, + stop: () => orpc.media.stop() as unknown as Promise, loop: (loop: boolean) => - trpc.media.loop.mutate({ loop }) as unknown as Promise, + orpc.media.loop({ loop }) as unknown as Promise, }; diff --git a/services/frontend/src/lib/api/messages.ts b/services/frontend/src/lib/api/messages.ts index 4f71a0e..454f0c1 100644 --- a/services/frontend/src/lib/api/messages.ts +++ b/services/frontend/src/lib/api/messages.ts @@ -1,4 +1,4 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { AttachmentRecord, MessageRecord } from "@/lib/types"; export const messagesApi = { @@ -8,7 +8,7 @@ export const messagesApi = { channelId?: string, cursor?: string, ) => - trpc.messages.list.query({ + orpc.messages.list({ guildId, limit, channelId, @@ -19,7 +19,7 @@ export const messagesApi = { }>, getByChannel: (channelId: string, limit?: number, cursor?: string) => - trpc.messages.byChannel.query({ + orpc.messages.byChannel({ channelId, query: { channelId, limit, cursor }, }) as unknown as Promise<{ @@ -28,10 +28,10 @@ export const messagesApi = { }>, getDetail: (id: string) => - trpc.messages.detail.query({ id }) as unknown as Promise, + orpc.messages.detail({ id }) as unknown as Promise, getImages: (guildId: string, limit?: number) => - trpc.messages.images.query({ guildId, limit }) as unknown as Promise<{ + orpc.messages.images({ guildId, limit }) as unknown as Promise<{ data: MessageRecord[]; nextCursor: string | null; }>, @@ -42,7 +42,7 @@ export const messagesApi = { cursor?: string, messageId?: string, ) => - trpc.messages.attachmentsByChannel.query({ + orpc.messages.attachmentsByChannel({ channelId, query: { channelId, limit, cursor, messageId }, }) as unknown as Promise<{ @@ -51,15 +51,15 @@ export const messagesApi = { }>, getReview: (limit?: number, channelId?: string) => - trpc.messages.review.query({ limit, channelId }) as unknown as Promise<{ + orpc.messages.review({ limit, channelId }) as unknown as Promise<{ results: MessageRecord[]; limit: number; cursor: null; }>, - // Analysis search (formerly /api/analysis/search → tRPC analysis.search) + // Analysis search (formerly /api/analysis/search → oRPC analysis.search) search: (q: string, limit?: number) => - trpc.analysis.search.query({ q, limit }) as unknown as Promise<{ + orpc.analysis.search({ q, limit }) as unknown as Promise<{ results: MessageRecord[]; }>, }; diff --git a/services/frontend/src/lib/api/moderation.ts b/services/frontend/src/lib/api/moderation.ts index cab2037..43e6343 100644 --- a/services/frontend/src/lib/api/moderation.ts +++ b/services/frontend/src/lib/api/moderation.ts @@ -1,9 +1,9 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { ModerationStats, PaginatedModerationActions } from "@/lib/types"; export const moderationApi = { getStats: () => - trpc.moderation.stats.query() as unknown as Promise, + orpc.moderation.stats() as unknown as Promise, listActions: ( limit?: number, @@ -11,7 +11,7 @@ export const moderationApi = { actionType?: string, cursor?: string, ) => - trpc.moderation.actions.query({ + orpc.moderation.actions({ limit, status, actionType, diff --git a/services/frontend/src/lib/api/recordings.ts b/services/frontend/src/lib/api/recordings.ts index 69dfa59..647f97e 100644 --- a/services/frontend/src/lib/api/recordings.ts +++ b/services/frontend/src/lib/api/recordings.ts @@ -1,4 +1,4 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { PaginatedRecordings } from "@/lib/types"; export const recordingsApi = { @@ -8,7 +8,7 @@ export const recordingsApi = { userId?: string, cursor?: string, ) => - trpc.recordings.list.query({ + orpc.recordings.list({ limit, channelId, userId, @@ -16,7 +16,5 @@ export const recordingsApi = { }) as unknown as Promise, delete: (id: string) => - trpc.recordings.delete.mutate({ id }) as unknown as Promise<{ - ok: boolean; - }>, + orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>, }; diff --git a/services/frontend/src/lib/api/server.ts b/services/frontend/src/lib/api/server.ts index 79d7083..104ab7a 100644 --- a/services/frontend/src/lib/api/server.ts +++ b/services/frontend/src/lib/api/server.ts @@ -1,18 +1,19 @@ /** * Server-only data layer (React Server Components / route handlers). * - * The dashboard is fully tRPC-native: this module talks to the backend's - * tRPC HTTP endpoint (/trpc) via an httpLink client — the same appRouter the - * browser reaches over WebSocket. No legacy REST `/api/*` is used. + * The dashboard is fully oRPC-native: this module talks to the backend's + * oRPC HTTP endpoint (/trpc) via a fetch RPCLink client — the same appRouter + * the browser reaches over WebSocket. No legacy REST `/api/*` is used. * - * The client is loosely typed (see ./types → TRPCClient); results are asserted + * The client is loosely typed (see ./types → ORPCClient); results are asserted * to the frontend's local types at each call site. * * Never import this module from a client component. Browser code uses - * `@/lib/trpc/client` (wsLink) via the `@/lib/api/*` wrappers. + * `@/lib/orpc/client` (websocket RPCLink) via the `@/lib/api/*` wrappers. */ -import { createTRPCClient, httpBatchLink } from "@trpc/client"; +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/fetch"; import type { AppConfig, DashboardActivity, @@ -24,24 +25,22 @@ import type { PaginatedRecordings, VoiceStatus, } from "@/lib/types"; -import type { TRPCClient } from "../trpc/types"; +import type { ORPCClient } from "../orpc/types"; const BACKEND_URL = process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001"; -let _client: TRPCClient | null = null; -function serverTrpc(): TRPCClient { +let _client: ORPCClient | null = null; +function serverOrpc(): ORPCClient { if (!_client) { - _client = createTRPCClient({ - links: [ - httpBatchLink({ - url: `${BACKEND_URL}/trpc`, - fetch(url, init) { - return fetch(url, { ...init, cache: "no-store" }); - }, - }), - ], - }) as unknown as TRPCClient; + const link = new RPCLink({ + url: `${BACKEND_URL}/trpc`, + // Always bypass Next.js fetch cache for live dashboard data. + fetch(url, init) { + return fetch(url, { ...init, cache: "no-store" }); + }, + }); + _client = createORPCClient(link) as unknown as ORPCClient; } return _client; } @@ -57,30 +56,30 @@ export class ApiServerError extends Error { // ---- Dashboard ---- export async function getDashboardStats(): Promise { - return serverTrpc().dashboard.stats.query() as unknown as Promise; + return serverOrpc().dashboard.stats() as unknown as Promise; } export async function getActivity(days = 14): Promise { - return serverTrpc().dashboard.activity.query({ + return serverOrpc().dashboard.activity({ days, }) as unknown as Promise; } // ---- Media ---- export async function getMediaStatus(): Promise { - return serverTrpc().media.status.query() as unknown as Promise; + return serverOrpc().media.status() as unknown as Promise; } // ---- Config ---- export async function getConfig(): Promise { - return serverTrpc().config.get.query() as unknown as Promise; + return serverOrpc().config.get() as unknown as Promise; } // ---- Moderation ---- export async function getModerationStats(): Promise { - return serverTrpc().moderation.stats.query() as unknown as Promise; + return serverOrpc().moderation.stats() as unknown as Promise; } export async function getModerationActions(limit = 100) { - const res = (await serverTrpc().moderation.actions.query({ + const res = (await serverOrpc().moderation.actions({ limit, })) as unknown as PaginatedModerationActions; return res.data; @@ -88,15 +87,15 @@ export async function getModerationActions(limit = 100) { // ---- Voice ---- export async function getGuilds(): Promise { - return serverTrpc().voice.guilds.query() as unknown as Promise; + return serverOrpc().voice.guilds() as unknown as Promise; } export async function getVoiceStatus(): Promise { - return serverTrpc().voice.status.query() as unknown as Promise; + return serverOrpc().voice.status() as unknown as Promise; } // ---- Recordings ---- export async function getRecordings(limit = 50): Promise { - return serverTrpc().recordings.list.query({ + return serverOrpc().recordings.list({ limit, }) as unknown as Promise; } diff --git a/services/frontend/src/lib/api/ui-state.ts b/services/frontend/src/lib/api/ui-state.ts index f74718d..367bb72 100644 --- a/services/frontend/src/lib/api/ui-state.ts +++ b/services/frontend/src/lib/api/ui-state.ts @@ -1,9 +1,9 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { UiState } from "@/lib/types"; export const uiStateApi = { - get: () => trpc.uiState.get.query() as unknown as Promise, + get: () => orpc.uiState.get() as unknown as Promise, save: (state: UiState) => - trpc.uiState.update.mutate(state) as unknown as Promise<{ ok: boolean }>, + orpc.uiState.update(state) as unknown as Promise<{ ok: boolean }>, }; diff --git a/services/frontend/src/lib/api/voice.ts b/services/frontend/src/lib/api/voice.ts index 718faaa..f7bf801 100644 --- a/services/frontend/src/lib/api/voice.ts +++ b/services/frontend/src/lib/api/voice.ts @@ -1,25 +1,22 @@ -import { trpc } from "@/lib/trpc/client"; +import { orpc } from "@/lib/orpc/client"; import type { Channel, Guild, VoiceStatus } from "@/lib/types"; export const voiceApi = { // Guilds - getGuilds: () => trpc.voice.guilds.query() as unknown as Promise, + getGuilds: () => orpc.voice.guilds() as unknown as Promise, getTextChannels: (guildId: string) => - trpc.voice.textChannels.query({ guildId }) as unknown as Promise, + orpc.voice.textChannels({ guildId }) as unknown as Promise, getVoiceChannels: (guildId: string) => - trpc.voice.voiceChannels.query({ - guildId, - }) as unknown as Promise, + orpc.voice.voiceChannels({ guildId }) as unknown as Promise, // Voice connection - getStatus: () => trpc.voice.status.query() as unknown as Promise, + getStatus: () => orpc.voice.status() as unknown as Promise, connect: (guildId: string, channelId: string) => - trpc.voice.connect.mutate({ + orpc.voice.connect({ guildId, channelId, }) as unknown as Promise, - disconnect: () => - trpc.voice.disconnect.mutate() as unknown as Promise, + disconnect: () => orpc.voice.disconnect() as unknown as Promise, sendCommand: (command: string) => - trpc.voice.command.mutate({ command }) as unknown as Promise, + orpc.voice.command({ command }) as unknown as Promise, }; diff --git a/services/frontend/src/lib/orpc/client.ts b/services/frontend/src/lib/orpc/client.ts new file mode 100644 index 0000000..5d7d5de --- /dev/null +++ b/services/frontend/src/lib/orpc/client.ts @@ -0,0 +1,36 @@ +"use client"; + +import { createORPCClient } from "@orpc/client"; +import { RPCLink } from "@orpc/client/websocket"; +import PartySocket from "partysocket"; +import type { ORPCClient } from "./types"; + +/** + * oRPC client over WebSocket (browser only). + * + * oRPC's own WebSocket RPCLink takes a caller-provided socket and does NOT + * auto-reconnect, so we back it with partysocket — a reconnecting WebSocket + * whose `host` + `basePath: "trpc"` resolves to exactly `/trpc`, matching the + * backend's oRPC WS mount. partysocket keeps a stable socket identity across + * reconnects, which is what oRPC's RPCLink expects. + * + * The client is loosely typed (see ./types): we do NOT import the backend + * router's type into the FE (fragile coupling). api/* wrappers assert leaf + * results to the frontend's local types. + */ +const orpc: ORPCClient = (() => { + if (typeof window === "undefined") { + // SSR fallback — api/* callers are client components, never run server-side. + return undefined as unknown as ORPCClient; + } + + const socket = new PartySocket({ + host: window.location.host, + basePath: "trpc", + }); + + const link = new RPCLink({ websocket: socket }); + return createORPCClient(link) as unknown as ORPCClient; +})(); + +export { orpc }; diff --git a/services/frontend/src/lib/orpc/types.ts b/services/frontend/src/lib/orpc/types.ts new file mode 100644 index 0000000..df6f084 --- /dev/null +++ b/services/frontend/src/lib/orpc/types.ts @@ -0,0 +1,12 @@ +/** + * Loosely-typed oRPC client shape shared by the browser (websocket RPCLink) and + * server-side (fetch RPCLink) clients. The real router lives on the backend; we + * do NOT import its type here (coupling the FE typecheck to the BE source tree + * and its `@/` alias layout is fragile). Instead we describe the minimal shape + * the api/* wrappers rely on: any path segment yields an object, and leaves are + * callable functions returning a Promise. Leaf results are asserted to the + * frontend's local types inside the api/* wrappers. + */ +export type ORPCClient = { + [k: string]: ORPCClient; +} & ((input?: unknown) => Promise); diff --git a/services/frontend/src/lib/trpc/client.ts b/services/frontend/src/lib/trpc/client.ts deleted file mode 100644 index bf63583..0000000 --- a/services/frontend/src/lib/trpc/client.ts +++ /dev/null @@ -1,33 +0,0 @@ -"use client"; - -import { createTRPCClient, createWSClient, wsLink } from "@trpc/client"; -import type { TRPCClient } from "./types"; - -/** - * WebSocket URL for the tRPC data RPC endpoint (/trpc), served by the backend - * on the same host as the page (nginx proxies it). Upgrades http→ws and - * derives wss:// when the page is served over https. - */ -function resolveWsUrl(): string { - if (typeof window === "undefined") return "ws://localhost/trpc"; - const proto = window.location.protocol === "https:" ? "wss" : "ws"; - return `${proto}://${window.location.host}/trpc`; -} - -/** - * tRPC client over WebSocket (browser only). `createTRPCClient` (no router - * generic) is an untyped client; we assert it into the loose `TRPCClient` - * shape so `.dashboard.stats.query(...)` etc. typecheck. See ./types for why - * the client shape is intentionally untyped. - */ -const wsClient = - typeof window === "undefined" - ? null - : createWSClient({ url: resolveWsUrl() }); - -export const trpc: TRPCClient = wsClient - ? (createTRPCClient({ - links: [wsLink({ client: wsClient })], - }) as unknown as TRPCClient) - : // SSR fallback — api/* callers are client components, never run server-side. - (undefined as unknown as TRPCClient); diff --git a/services/frontend/src/lib/trpc/types.ts b/services/frontend/src/lib/trpc/types.ts deleted file mode 100644 index be757ae..0000000 --- a/services/frontend/src/lib/trpc/types.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Loosely-typed tRPC client shape shared by the browser (wsLink) and - * server-side (httpLink) clients. The real router lives on the backend; we do - * NOT import its type here (coupling the FE typecheck to the BE source tree - * and its `@/` alias layout is fragile). Instead we describe the minimal shape - * the api/* wrappers rely on: any path segment yields an object with `query` - * and `mutate`, and arbitrary further nesting is allowed. Leaf results are - * asserted to the frontend's local types inside the api/* wrappers. - */ -export type TRPCClient = { - [k: string]: TRPCClient; -} & { - query(input?: unknown): Promise; - mutate(input?: unknown): Promise; -};