From 459266be698a45bb7575b76b410a88672bf33a06 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 07:27:17 +0000 Subject: [PATCH 01/18] refactor: Environment variables --- src/env/variables/get.ts | 11 ++++++----- src/env/variables/keys.ts | 2 ++ 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/env/variables/get.ts b/src/env/variables/get.ts index 34230af..caed616 100644 --- a/src/env/variables/get.ts +++ b/src/env/variables/get.ts @@ -11,10 +11,11 @@ export const getEnvironmentVariables = memoize(async () => { await loadDotEnv(); const { optional } = new EnvironmentVariableValidators(process.env); - const KEYS = ENVIRONMENT_VARIABLE_KEYS; - return { - CLOUDFLARE_CALLS_API_TOKEN: optional(KEYS.CLOUDFLARE_CALLS_API_TOKEN), - CLOUDFLARE_ACCOUNT_ID: optional(KEYS.CLOUDFLARE_ACCOUNT_ID), - } as const; + return Object // + .values(ENVIRONMENT_VARIABLE_KEYS) + .reduce( + (acc, key) => Object.assign(acc, { [key]: optional(key) }), + {} as { [_ in keyof typeof ENVIRONMENT_VARIABLE_KEYS]: undefined | string }, + ); }); export type EnvironmentVariables = Awaited>; diff --git a/src/env/variables/keys.ts b/src/env/variables/keys.ts index 7d2e607..ca9cb58 100644 --- a/src/env/variables/keys.ts +++ b/src/env/variables/keys.ts @@ -1,4 +1,6 @@ export const ENVIRONMENT_VARIABLE_KEYS = { + /** For `create-cloudflare-sfu-app.ts` */ CLOUDFLARE_CALLS_API_TOKEN: "CLOUDFLARE_CALLS_API_TOKEN", + /** For `create-cloudflare-sfu-app.ts` */ CLOUDFLARE_ACCOUNT_ID: "CLOUDFLARE_ACCOUNT_ID", } as const; From 278d929771222ebf3251968dd90aa03807bcde72 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 03:03:50 +0000 Subject: [PATCH 02/18] deps(vscode): Bump the version of extensions --- .devcontainer/devcontainer.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 53d2552..283ab19 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,20 +19,20 @@ "extensions": [ "EditorConfig.EditorConfig@0.18.1", "GitHub.vscode-github-actions@0.31.0", - "ms-vscode-remote.remote-containers@0.444.0", - "oxc.oxc-vscode@1.47.0", - "timonwong.shellcheck@0.38.6", - "TypeScriptTeam.native-preview@0.20260207.1", - "vitest.explorer@1.42.1" + "ms-vscode-remote.remote-containers@0.449.0", + "oxc.oxc-vscode@1.50.0", + "timonwong.shellcheck@0.39.2", + "TypeScriptTeam.native-preview@0.20260313.1", + "vitest.explorer@1.48.1" ], "settings": { "editor.defaultFormatter": "oxc.oxc-vscode", + "js/ts.experimental.useTsgo": true, "json.schemaDownload.trustedDomains": { "https://docs.renovatebot.com/": true }, "oxc.configPath": ".oxfmtrc.json", - "oxc.typeAware": true, - "typescript.experimental.useTsgo": true + "oxc.typeAware": true } } } From ea19894de5cf9330c70ece06b0bfe94079ab9b93 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Sun, 22 Feb 2026 06:56:21 +0000 Subject: [PATCH 03/18] fix: Relative `.env` file path reference --- src/env/path.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/env/path.ts b/src/env/path.ts index 30e0441..40590b0 100644 --- a/src/env/path.ts +++ b/src/env/path.ts @@ -1,8 +1,10 @@ import path from "node:path"; const WORKSPACE_PATH = path.resolve(import.meta.dirname, "../.."); +/** Resolve a path relative to the workspace root */ +const ws = (...paths: readonly string[]) => path.resolve(WORKSPACE_PATH, ...paths); export const PATHS = { workspace: WORKSPACE_PATH, /** `.env` */ - dotEnv: path.relative(WORKSPACE_PATH, ".env"), + dotEnv: ws(".env"), } as const; From fb00875bad68f9abe133f7c40e6d7db6c44ec4bd Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Thu, 12 Mar 2026 16:27:46 +0000 Subject: [PATCH 04/18] feat(util): `peek()` --- src/util/debug.ts | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/util/debug.ts diff --git a/src/util/debug.ts b/src/util/debug.ts new file mode 100644 index 0000000..8c49b9f --- /dev/null +++ b/src/util/debug.ts @@ -0,0 +1,4 @@ +export const peek = (value: T): T => { + console.log(value); + return value; +}; From 313ffce222472eac2388368bb304963d262792e8 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 06:33:18 +0000 Subject: [PATCH 05/18] feat(webrtcserverprovider): Interface --- .../web-rtc-server-provider/interface.ts | 69 +++++++++++++++++++ tsconfig.lib.json | 3 +- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 src/server/web-rtc-server-provider/interface.ts diff --git a/src/server/web-rtc-server-provider/interface.ts b/src/server/web-rtc-server-provider/interface.ts new file mode 100644 index 0000000..0c902cc --- /dev/null +++ b/src/server/web-rtc-server-provider/interface.ts @@ -0,0 +1,69 @@ +export interface WebRtcServerProvider { + createSession: () => Promise<{ + sessionId: string; + }>; + + createTrack: { + (opts: { + kind: "send"; + sessionId: string; + sessionDescription: { + sdp: string; + }; + tracks: readonly { + trackName: string; + mid: string; + }[]; + }): Promise<{ + sessionDescription: RTCSessionDescriptionInit; + }>; + + (opts: { + kind: "receive"; + sessionId: string; + tracks: readonly { + sessionId: string; + trackName: string; + }[]; + }): Promise< + { + tracks: { + mid: string; + }[]; + } & ( + | { + requiresImmediateRenegotiation: false; + } + | { + requiresImmediateRenegotiation: true; + sessionDescription: RTCSessionDescriptionInit; + } + ) + >; + }; + + renegotiateSession: (opts: { + sessionId: string; + sessionDescription: { + sdp: string; + }; + }) => Promise; + + closeTrack: (opts: { + kind: "send" | "receive"; + sessionId: string; + sessionDescription: { + sdp: string; + }; + tracks: readonly { + mid: string; + }[]; + }) => Promise; +} + +export class WebRtcServerProviderError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = "WebRtcServerProviderError"; + } +} diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 2b433a2..edfc601 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -7,7 +7,8 @@ "module": "nodenext", "moduleResolution": "nodenext", "lib": [ - "esnext" // ES2025 + "esnext", // ES2025 + "DOM" ], "types": ["node"] } From c11e04b5cb00283122d218c73cad52dddde82c1e Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Tue, 10 Mar 2026 16:02:13 +0000 Subject: [PATCH 06/18] deps: Add `openapi-typescript` --- package.json | 3 +- pnpm-lock.yaml | 307 ++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 268 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index 49a187e..daf4cf1 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ }, "devDependencies": { "@types/node": "24 - 24.13", - "@typescript/native-preview": "7.0.0-dev.20260512.1", + "@typescript/native-preview": "7.0.0-dev.20260315.1", + "openapi-typescript": "^7.13.0", "oxfmt": "^0.49.0", "oxlint": "^1.42.0", "oxlint-tsgolint": "^0.22.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b30e1d..053ede0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,11 @@ importers: specifier: 24 - 24.13 version: 24.10.12 '@typescript/native-preview': - specifier: 7.0.0-dev.20260512.1 - version: 7.0.0-dev.20260512.1 + specifier: 7.0.0-dev.20260315.1 + version: 7.0.0-dev.20260315.1 + openapi-typescript: + specifier: ^7.13.0 + version: 7.13.0(typescript@5.9.3) oxfmt: specifier: ^0.49.0 version: 0.49.0 @@ -33,6 +36,14 @@ importers: packages: + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} @@ -466,6 +477,16 @@ packages: cpu: [x64] os: [win32] + '@redocly/ajv@8.17.4': + resolution: {integrity: sha512-BieiCML/IgP6x99HZByJSt7fJE4ipgzO7KAFss92Bs+PEI35BhY7vGIysFXLT+YmS7nHtQjZjhOQyPPEf7xGHA==} + + '@redocly/config@0.22.2': + resolution: {integrity: sha512-roRDai8/zr2S9YfmzUfNhKjOF0NdcOIqF7bhf4MVC5UxpjIysDjyudvlAiVbpPHp3eDRWbdzUgtkK1a7YiDNyQ==} + + '@redocly/openapi-core@1.34.7': + resolution: {integrity: sha512-gn2P0OER6qxF/+f4GqNv9XsnU5+6oszD/0SunulOvPYJDhrNkNVrVZV5waX25uqw5UDn2+roViWlRDHKFfHH0g==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rollup/rollup-android-arm-eabi@4.60.3': resolution: {integrity: sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==} cpu: [arm] @@ -628,51 +649,43 @@ packages: '@types/node@24.10.12': resolution: {integrity: sha512-68e+T28EbdmLSTkPgs3+UacC6rzmqrcWFPQs1C8mwJhI/r5Uxr0yEuQotczNRROd1gq30NGxee+fo0rSIxpyAw==} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-l9AJi/TIVMPx5R1c7fxZCSA7eUaHeA0C9Mxdxx/oQJo1K/GtbI3mzYe/SiKNltko1KSdKUmWVhPwxTOS289REg==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-UhRPJWUZMHO1Xuurjr28gR98+nwD+QBJiUWzTLLrvhkGEOA8IG9Q8hqzJ07AQb/V251F/MsEjOSEnHmgGNT6Dg==} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-oABZLQrfB8JN2Ct2CiLK5PyE28Em3sIJlZsAMD45/A2ymtIaa5826dwv8vapE5Wjp54ao0LXxCSuKFm1A8zzCQ==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-7a2P4KyJQF83Zj+3Vi/VCV9I/0lC+2QRgD4JEIh1H0FliRDZILUIbiAqWaksHHl4pBMtlRZr+1hjad5vPUsQmA==} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-xvbwzpTe+5N6bnBI/t9n4zsGzXxz3V6rVbvDUoJmRLfav5fz+ck0QDkGQGUPrQEEIp0KEzQvx7c+AEZnzdvTQA==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-DwKY8zVqsQx0+golSKopbzZVIVfnsUVVt7s6Wg5kiAYrFy32TNKqANfbiWLBe5PGSpYu9Mx1XWLpsvi6/58BWw==} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-0Hs1Gqa/t9cthoPdqHud1pFGUr9DgJivBTjwquTUh8jt/6PI2bQxoMNZLiN/bhqeDFDTzdxoMBfCaytsTMcXqw==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-n2MhBeDAmdbp6DOtz+I2JeGpNzepzvXxPEDpRE+syAT5mTXstwTk+9w4rF2SLt1YgLuPmon7iZhkUyPTd0dD7A==} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-qr5h6FPo74bN/U+EwRuayBhUbxaji8xzFbIbhMOA2oYSc/qozp5ia2g1+9xGw67MXxPPw/IPT+UGvrNK7K1NeQ==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-5kRxtdfqF9X1q/vIg7myq7D0MmF3GywZXS3mmZq6TQEFiu5IClHPaQCuCQqYdtHmHtZllHZz0VaEtnvV9Vamrw==} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-meNWxhNEfaqos2U0JXvfxWvy4JWrKE9fZepCndDZi+t04X+AIiLYp5s6crWnKP67nzVAzMNgTAc8mu8CnGM+/A==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-u0ixXTG/97k2eVRQfwafGckHuK60st5ADYn23KQvZJxeUZ47XWIjzCL1JLcoiRexEAwsEerireyy32l4LxA9BQ==} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-Hp6vBnxJSKEEAVWgIoWMmfqkZXCdkhm6XTivrwgRzBwWfiTVe2ZyZ7byWegIKeNnBbffg/K2KvoM8JAHl059GQ==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-iSnVmAZgogCup/5SOF2h+Hwdywa4cGNIw9z8jpTVJof56w5GR9Hx3vN9UszqszjGxPNobYERcOk8QhGZO8uf5g==} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260512.1': - resolution: {integrity: sha512-KIzYPGuxZnyiiYkYrozDT94Af2nwbdLXoY1cgGY66RRa9HSEw13RH9WHg8wA8fZhT4wYzF5uF7WY3hz0QhaxGg==} - engines: {node: '>=16.20.0'} + '@typescript/native-preview@7.0.0-dev.20260315.1': + resolution: {integrity: sha512-t+st0mCz4HpvODTTlj2XxIQtiNT7L7lxP91790MOfA0xTRgwu7ERYV7WB1SbXRyrFDIwuO1bZqT0E0P4qcL4RQ==} hasBin: true '@vitest/expect@4.1.6': @@ -708,10 +721,21 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + agentkeepalive@4.6.0: resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} engines: {node: '>= 8.0.0'} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -719,6 +743,12 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.0.2: + resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -727,9 +757,15 @@ packages: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + cloudflare@6.1.0: resolution: {integrity: sha512-4hVKiowaEjRoYOFRj8/2bSaj4sQl4GUO/XnzV5OEnjvaYz+MWQOqs3KX6nlbix98JF2iSS/MZrIjbz9C+hkEaQ==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -737,6 +773,15 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -780,6 +825,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -832,9 +883,31 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + humanize-ms@1.2.1: resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -850,6 +923,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + minimatch@5.1.7: + resolution: {integrity: sha512-FjiwU9HaHW6YB3H4a1sFudnv93lvydNjz2lmyUXR6IwKhGI+bgL3SOZrBGn6kvvX2pJvhEkGSGjyTHN47O4rqA==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -875,6 +952,12 @@ packages: obug@2.1.1: resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + oxfmt@0.49.0: resolution: {integrity: sha512-IAHFMdlJSWe+oAr65dx22UvjCtV9DBMisAuLnKpDqMQrctzCkGnj3QRwNHm0d+uwSWPalsDF8ZYLz9rh6nH2IQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -899,6 +982,10 @@ packages: oxlint-tsgolint: optional: true + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -909,10 +996,18 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + postcss@8.5.14: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rollup@4.60.3: resolution: {integrity: sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -931,6 +1026,10 @@ packages: std-env@4.1.0: resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -953,6 +1052,15 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} @@ -1055,8 +1163,23 @@ packages: engines: {node: '>=8'} hasBin: true + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + snapshots: + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.28.5': {} + '@esbuild/aix-ppc64@0.27.7': optional: true @@ -1269,6 +1392,29 @@ snapshots: '@oxlint/binding-win32-x64-msvc@1.64.0': optional: true + '@redocly/ajv@8.17.4': + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + '@redocly/config@0.22.2': {} + + '@redocly/openapi-core@1.34.7(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.17.4 + '@redocly/config': 0.22.2 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.1.1 + minimatch: 5.1.7 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rollup/rollup-android-arm-eabi@4.60.3': optional: true @@ -1370,36 +1516,36 @@ snapshots: dependencies: undici-types: 7.16.0 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260512.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260512.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260512.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260512.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260512.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260512.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260512.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260315.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260512.1': + '@typescript/native-preview@7.0.0-dev.20260315.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260512.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260512.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260512.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260315.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260315.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260315.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260315.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260315.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260315.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260315.1 '@vitest/expect@4.1.6': dependencies: @@ -1446,14 +1592,26 @@ snapshots: dependencies: event-target-shim: 5.0.1 + agent-base@7.1.4: {} + agentkeepalive@4.6.0: dependencies: humanize-ms: 1.2.1 + ansi-colors@4.1.3: {} + + argparse@2.0.1: {} + assertion-error@2.0.1: {} asynckit@0.4.0: {} + balanced-match@1.0.2: {} + + brace-expansion@2.0.2: + dependencies: + balanced-match: 1.0.2 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -1461,6 +1619,8 @@ snapshots: chai@6.2.2: {} + change-case@5.4.4: {} + cloudflare@6.1.0: dependencies: '@types/node': 18.19.130 @@ -1473,12 +1633,20 @@ snapshots: transitivePeerDependencies: - encoding + colorette@1.4.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 convert-source-map@2.0.0: {} + debug@4.4.3(supports-color@10.2.2): + dependencies: + ms: 2.1.3 + optionalDependencies: + supports-color: 10.2.2 + delayed-stream@1.0.0: {} dunder-proto@1.0.1: @@ -1541,6 +1709,10 @@ snapshots: expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 @@ -1595,10 +1767,29 @@ snapshots: dependencies: function-bind: 1.1.2 + https-proxy-agent@7.0.6(supports-color@10.2.2): + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@10.2.2) + transitivePeerDependencies: + - supports-color + humanize-ms@1.2.1: dependencies: ms: 2.1.3 + index-to-position@1.2.0: {} + + js-levenshtein@1.1.6: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1611,6 +1802,10 @@ snapshots: dependencies: mime-db: 1.52.0 + minimatch@5.1.7: + dependencies: + brace-expansion: 2.0.2 + ms@2.1.3: {} nanoid@3.3.12: {} @@ -1623,6 +1818,16 @@ snapshots: obug@2.1.1: {} + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.7(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + oxfmt@0.49.0: dependencies: tinypool: 2.1.0 @@ -1679,18 +1884,28 @@ snapshots: '@oxlint/binding-win32-x64-msvc': 1.64.0 oxlint-tsgolint: 0.22.1 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.0 + index-to-position: 1.2.0 + type-fest: 4.41.0 + pathe@2.0.3: {} picocolors@1.1.1: {} picomatch@4.0.4: {} + pluralize@8.0.0: {} + postcss@8.5.14: dependencies: nanoid: 3.3.12 picocolors: 1.1.1 source-map-js: 1.2.1 + require-from-string@2.0.2: {} + rollup@4.60.3: dependencies: '@types/estree': 1.0.8 @@ -1730,6 +1945,8 @@ snapshots: std-env@4.1.0: {} + supports-color@10.2.2: {} + tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -1745,6 +1962,10 @@ snapshots: tr46@0.0.3: {} + type-fest@4.41.0: {} + + typescript@5.9.3: {} + undici-types@5.26.5: {} undici-types@7.16.0: {} @@ -1801,3 +2022,7 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} From 48b53a82ff9bbb8a9e6c7a89b4c8eadeb04f79a6 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Sun, 22 Feb 2026 06:54:56 +0000 Subject: [PATCH 07/18] env: Configure Cloudflare Realtime API code generation --- .oxfmtrc.json | 3 + .oxlintrc.json | 2 +- .vscode/tasks.json | 16 +- package.json | 4 +- .../provider/cf-realtime/api-client/schema.ts | 859 ++++++++++++++++++ 5 files changed, 881 insertions(+), 3 deletions(-) create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 4ae71f1..556d84b 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,5 +1,8 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", + "ignorePatterns": [ + "src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts" + ], "useTabs": true, "sortImports": { "groups": [ diff --git a/.oxlintrc.json b/.oxlintrc.json index de8d8d5..f8a9f59 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -27,5 +27,5 @@ "builtin": true }, "globals": {}, - "ignorePatterns": [] + "ignorePatterns": ["src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts"] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 673560c..d230786 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -43,9 +43,23 @@ "script": "test", "problemMatcher": [] }, + { + "label": "codegen:realtime-api", + "detail": "Generate types for the Realtime API schema", + "type": "npm", + "script": "codegen:realtime-api", + "problemMatcher": [] + }, + { + "label": "codegen:realtime-api:check", + "detail": "Check if generated types match existing types for the Realtime API schema", + "type": "npm", + "script": "codegen:realtime-api:check", + "problemMatcher": [] + }, { "label": "check", - "detail": "Run all checks: format, lint, typecheck, and tests without fixing", + "detail": "Run all checks: format, lint, typecheck, tests, and ensure generated codes are up to date without fixing", "type": "npm", "script": "check", "problemMatcher": [] diff --git a/package.json b/package.json index daf4cf1..a54e4ba 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,9 @@ "typecheck": "tsgo --build", "test": "vitest watch", "test:run": "vitest run", - "check": "pnpm format:dry && pnpm lint && pnpm typecheck && pnpm test:run" + "codegen:realtime-api": "openapi-typescript https://developers.cloudflare.com/realtime/static/calls-api-2024-05-21.yaml --output ./src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts", + "codegen:realtime-api:check": "pnpm codegen:realtime-api --check", + "check": "pnpm format:dry && pnpm lint && pnpm typecheck && pnpm test:run && pnpm codegen:realtime-api:check" }, "dependencies": { "cloudflare": "^6.0.0" diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts new file mode 100644 index 0000000..a504f02 --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts @@ -0,0 +1,859 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/apps/{appId}/sessions/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create a new PeerConnection */ + post: { + parameters: { + query?: { + /** @description Session is intended to connect to an ICE-lite peer like a third party SFU/server */ + thirdparty?: boolean; + /** @description Associate session to an user-provided correlation id */ + correlationId?: string; + }; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Created */ + 201: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NewSessionResponse"] & unknown; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/adapters/websocket/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create WebSocket adapter(s) for ingest or stream */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["NewAdapterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["NewAdapterResponse"]; + }; + }; + /** @description Service unavailable. No adapter could be created. */ + 503: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "tracks": [ + * { + * "trackName": "mic-track", + * "errorCode": "adapter_unavailable", + * "errorDescription": "Failed to create adapter" + * } + * ] + * } + */ + "application/json": components["schemas"]["NewAdapterResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/adapters/websocket/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Close WebSocket adapter(s) */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "tracks": [ + * { + * "adapterId": "4e66a9d5a35e4a0899f6f8d0b63a35c1" + * } + * ] + * } + */ + "application/json": components["schemas"]["CloseAdapterRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "tracks": [ + * { + * "adapterId": "4e66a9d5a35e4a0899f6f8d0b63a35c1", + * "bytesProcessed": 83492 + * } + * ] + * } + */ + "application/json": components["schemas"]["CloseAdapterResponse"]; + }; + }; + /** @description Service unavailable. No adapter could be closed. */ + 503: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "tracks": [ + * { + * "adapterId": "4e66a9d5a35e4a0899f6f8d0b63a35c1", + * "errorCode": "adapter_not_found", + * "errorDescription": "Adapter not found" + * } + * ] + * } + */ + "application/json": components["schemas"]["CloseAdapterResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/tracks/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Solve the given track object(s) and add the track(s) to the WebRTC session */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + /** @description Current PeerConnection session ID */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TracksRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TracksResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/renegotiate": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** When a previous response has requiresImmediateRenegotiation, you must renegotiate */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + /** + * @example { + * "sessionDescription": { + * "sdp": "v=0\no=- 0 0 IN IP4 127.0.0.1\ns=-\nc=IN IP4 127.0.0.1\nt=0 0\nm=audio 4000 RTP/AVP 111\na=rtpmap:111 OPUS/48000/2\nm=video 4002 RTP/AVP 96\na=rtpmap:96 VP8/90000\n...\ntype: answer\n" + * } + * } + */ + "application/json": components["schemas"]["RenegotiateRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** @example {} */ + "application/json": components["schemas"]["RenegotiateResponse"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/tracks/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Close a local or remote track */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CloseTracksRequest"] & unknown; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "sessionDescription": { + * "sdp": "v=0\no=- 0 0 IN IP4 127.0.0.1\ns=-\nc=IN IP4 127.0.0.1\nt=0 0\nm=audio 4000 RTP/AVP 111\na=rtpmap:111 OPUS/48000/2\nm=video 4002 RTP/AVP 96\na=rtpmap:96 VP8/90000\n...\n", + * "type": "answer" + * }, + * "requiresImmediateRenegotiation": false, + * "tracks": [ + * { + * "mid": "7" + * } + * ] + * } + */ + "application/json": components["schemas"]["CloseTracksResponse"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/tracks/update": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Change tracks by reusing existing transceivers */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + /** @description Current PeerConnection session ID */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["UpdateTracksRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UpdateTracksResponse"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/datachannels/establish": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Pull a server events channel to establish a data channel transport. It only allows to pull the server-events channel */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + /** @description Current PeerConnection session ID */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["EstablishDataChannelsTransportRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EstablishDataChannelsTransportResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/datachannels/new": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Solve the given data channel object(s) and add the data channel(s) to the WebRTC session. It requires to have a data channels transport established */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + /** @description Current PeerConnection session ID */ + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["DataChannelsRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DataChannelsResponse"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}/datachannels/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Close local or remote data channel(s) */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + sessionId: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["CloseDataChannelsRequest"] & unknown; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "datachannels": [ + * { + * "location": "remote", + * "sessionId": "2a45361d5fd7cc14eface0587c276c94", + * "dataChannelName": "1a037563-c35c-4bf6-a9ee-2b474cbb9a51", + * "id": 2 + * } + * ] + * } + */ + "application/json": components["schemas"]["CloseDataChannelsResponse"]; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/apps/{appId}/sessions/{sessionId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return the list of tracks associated to the session */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description WebRTC application ID */ + appId: string; + sessionId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + vary?: string; + [name: string]: unknown; + }; + content: { + /** + * @example { + * "tracks": [ + * { + * "location": "local", + * "mid": "2", + * "trackName": "1a037563-c35c-4bf6-a9ee-2b474cbb9a51", + * "status": "active" + * }, + * { + * "location": "remote", + * "mid": "7", + * "sessionId": "2a45361d5fd7cc14eface0587c276c94", + * "trackName": "2e037563-a35d-4bf6-a9ee-2d474cbb9a58", + * "status": "active" + * } + * ] + * } + */ + "application/json": components["schemas"]["GetSessionStateResponse"]; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + SessionDescription: { + sdp?: string; + /** @enum {string} */ + type?: "answer" | "offer"; + }; + TrackObject: { + /** + * @description If you want to share a track, it should be local. If you want to play a track shared by a remote agent, it should be remote + * @enum {string} + */ + location?: "local" | "remote"; + /** @description mid associated to track's transceiver. It also can be prefixed with \# to reference an existing transceiver by its trackName */ + mid?: string; + /** @description Session ID of the track owner. It should be set for remote tracks only */ + sessionId?: string; + /** @description Given name for the track */ + trackName?: string; + /** @description Make the associated transceiver bidirectional. This option works only when the SFU generates the offer */ + bidirectionalMediaStream?: boolean; + /** @description Give a hint to the SFU about the transceiver kind. This is required when the SFU generates the offer */ + kind?: string; + /** @description Simulcast configuration for the track */ + simulcast?: { + /** @description Preferred RID for simulcast streams */ + preferredRid?: string; + /** + * @description Controls what happens if there is not enough network resources available to send the preferredRid. 'none' means keep sending even if not enough bandwidth, 'asciibetical' uses a-z order to determine priority where a is most desirable and z is least desirable. + * @default none + * @enum {string} + */ + priorityOrdering: "none" | "asciibetical"; + /** + * @description Controls what happens when the rid currently being used or preferredRid is no longer being sent by the publisher. 'none' means do nothing, 'asciibetical' uses the next available layer after sorting the layers a-z. + * @default none + * @enum {string} + */ + ridNotAvailable: "none" | "asciibetical"; + }; + }; + CloseTrackObject: { + /** @description mid associated to the track's transceiver to close */ + mid?: string; + }; + TracksRequest: { + sessionDescription?: components["schemas"]["SessionDescription"]; + tracks?: components["schemas"]["TrackObject"][]; + /** @description Assign a random track name to any new track in the offered SDP */ + autoDiscover?: boolean; + }; + TracksResponse: { + errorCode?: string; + errorDescription?: string; + requiresImmediateRenegotiation?: boolean; + sessionDescription?: components["schemas"]["SessionDescription"]; + tracks?: (components["schemas"]["TrackObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + }; + NewSessionRequest: { + sessionDescription?: components["schemas"]["SessionDescription"]; + }; + NewSessionResponse: { + errorCode?: string; + errorDescription?: string; + sessionDescription?: { + sdp?: string; + /** @enum {string} */ + type?: "answer" | "offer"; + }; + sessionId: string; + }; + CloseTracksRequest: { + sessionDescription?: components["schemas"]["SessionDescription"]; + tracks?: components["schemas"]["CloseTrackObject"][]; + /** @description True if you want to stop just the data flow for the tracks, no WebRTC renegotiation */ + force?: boolean; + }; + CloseTracksResponse: { + errorCode?: string; + errorDescription?: string; + sessionDescription?: components["schemas"]["SessionDescription"]; + tracks?: (components["schemas"]["CloseTrackObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + requiresImmediateRenegotiation?: boolean; + }; + GetSessionStateResponse: { + errorCode?: string; + errorDescription?: string; + tracks?: (components["schemas"]["TrackObject"] & { + /** @enum {string} */ + status?: "active" | "inactive" | "waiting"; + })[]; + }; + RenegotiateRequest: { + sessionDescription?: components["schemas"]["SessionDescription"]; + }; + RenegotiateResponse: { + errorCode?: string; + errorDescription?: string; + sessionDescription?: components["schemas"]["SessionDescription"]; + }; + ChangeTracksRequest: { + /** @description Map of track IDs to track objects for changing tracks */ + tracks?: { + [key: string]: components["schemas"]["TrackObject"]; + }; + sessionDescription?: components["schemas"]["SessionDescription"]; + }; + UpdateTracksRequest: { + /** @description Array of track objects for updating tracks */ + tracks?: components["schemas"]["TrackObject"][]; + sessionDescription?: components["schemas"]["SessionDescription"]; + }; + UpdateTracksResponse: { + errorCode?: string; + errorDescription?: string; + requiresImmediateRenegotiation?: boolean; + /** @description Array of track objects with results */ + tracks?: (components["schemas"]["TrackObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + }; + AdapterObject: { + /** + * @description Use local to ingest media from an external endpoint. Use remote to stream media from an existing WebRTC track. + * @enum {string} + */ + location?: "local" | "remote"; + /** @description Session ID of the track owner. Required for remote adapters. */ + sessionId?: string; + /** @description Track name to ingest into or stream from. */ + trackName?: string; + /** @description WebSocket endpoint URL. */ + endpoint?: string; + /** + * @description Codec for outgoing media on remote adapters. Use pcm for audio or jpeg for video. + * @enum {string} + */ + outputCodec?: "pcm" | "jpeg"; + /** + * @description Codec for incoming media on local adapters. + * @enum {string} + */ + inputCodec?: "pcm"; + /** + * @description Adapter mode. Use buffer for local adapters and stream for remote adapters. + * @enum {string} + */ + mode?: "stream" | "buffer"; + /** @description Unique identifier of the adapter instance. */ + adapterId?: string; + }; + AdapterStatObject: { + /** @description Unique identifier of the adapter instance. */ + adapterId?: string; + /** @description Number of bytes processed before the adapter closed. */ + bytesProcessed?: number; + }; + NewAdapterRequest: { + tracks?: components["schemas"]["AdapterObject"][]; + }; + NewAdapterResponse: { + errorCode?: string; + errorDescription?: string; + tracks?: (components["schemas"]["AdapterObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + }; + CloseAdapterRequest: { + tracks?: { + /** @description Adapter identifier to close. */ + adapterId?: string; + }[]; + }; + CloseAdapterResponse: { + errorCode?: string; + errorDescription?: string; + tracks?: (components["schemas"]["AdapterStatObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + }; + DataChannelObject: { + /** + * @description Choose local to broadcast into the given dataChannelName. Choose remote to consume a broadcast from someone else + * @enum {string} + */ + location?: "local" | "remote"; + /** @description Session ID of the data channel owner. It should be set for remote data channels only */ + sessionId?: string; + /** @description Given name for the data channel */ + dataChannelName?: string; + /** @description Data channel id */ + id?: number; + }; + DataChannelsRequest: { + dataChannels?: components["schemas"]["DataChannelObject"][]; + }; + DataChannelsResponse: { + errorCode?: string; + errorDescription?: string; + dataChannels?: (components["schemas"]["DataChannelObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + }; + EstablishDataChannelsTransportRequest: { + sessionDescription?: components["schemas"]["SessionDescription"]; + dataChannel?: components["schemas"]["DataChannelObject"]; + }; + EstablishDataChannelsTransportResponse: { + errorCode?: string; + errorDescription?: string; + sessionDescription?: components["schemas"]["SessionDescription"]; + requiresImmediateRenegotiation?: boolean; + dataChannel?: components["schemas"]["DataChannelObject"]; + }; + CloseDataChannelsRequest: { + dataChannels?: components["schemas"]["DataChannelObject"][]; + }; + CloseDataChannelsResponse: { + errorCode?: string; + errorDescription?: string; + dataChannels?: (components["schemas"]["DataChannelObject"] & { + errorCode?: string; + errorDescription?: string; + })[]; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; From a60a6cae30d2fc6a53ddac83dc339043ef7cb576 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 16 Feb 2026 18:09:38 +0000 Subject: [PATCH 08/18] feat: `queryOf()` --- src/util/url.spec.ts | 67 ++++++++++++++++++++++++++++++++++++++++++++ src/util/url.ts | 21 ++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/util/url.spec.ts create mode 100644 src/util/url.ts diff --git a/src/util/url.spec.ts b/src/util/url.spec.ts new file mode 100644 index 0000000..fe9508c --- /dev/null +++ b/src/util/url.spec.ts @@ -0,0 +1,67 @@ +import { suite, test } from "vitest"; + +import { queryStringFrom } from "./url.ts"; + +suite.concurrent("queryStringFrom()", () => { + test("object with string values", ({ expect }) => { + const result = queryStringFrom({ + numeric: "123", + alphabet: "honi", + symbol: "!@#$%^&*()_+-=[]{}|;':\",./<>?", + japanese: "ほに", + emoji: "🥴", + empty: "", + undefined: "undefined", + null: "null", + false: "false", + zero: "0", + }); + expect(result).toBe( + "numeric=123" + + "&alphabet=honi" + + "&symbol=!%40%23%24%25%5E%26*()_%2B-%3D%5B%5D%7B%7D%7C%3B'%3A%22%2C.%2F%3C%3E%3F" + + "&japanese=%E3%81%BB%E3%81%AB" + + "&emoji=%F0%9F%A5%B4" + + "&empty=" + + "&undefined=undefined" + + "&null=null" + + "&false=false" + + "&zero=0", + ); + }); + + test("object with boolean values", ({ expect }) => { + const result = queryStringFrom({ + false: false, + true: true, + }); + expect(result).toBe("false=false&true=true"); + }); + + test("empty object", ({ expect }) => { + const result = queryStringFrom({}); + expect(result).toBe(""); + }); + + test("object with single key-value pair", ({ expect }) => { + const result = queryStringFrom({ key: "value" }); + expect(result).toBe("key=value"); + }); + + test("object with undefined value", ({ expect }) => { + const result = queryStringFrom({ a: "value", b: undefined, c: 123 }); + expect(result).toBe("a=value&c=123"); + }); + + test("object with single undefined value", ({ expect }) => { + const result = queryStringFrom({ a: undefined }); + expect(result).toBe(""); + }); + + test("object with special characters", ({ expect }) => { + const result = queryStringFrom({ + "key with spaces": "value with spaces", + }); + expect(result).toBe("key%20with%20spaces=value%20with%20spaces"); + }); +}); diff --git a/src/util/url.ts b/src/util/url.ts new file mode 100644 index 0000000..5da5231 --- /dev/null +++ b/src/util/url.ts @@ -0,0 +1,21 @@ +/** + * Build URL query string from the given object + * @param object An object to be converted to URL query string.\ + * If the value is `undefined`, the key will be skipped.\ + * Otherwise, the key and value will be encoded with {@link globalThis.encodeURIComponent}. + * @returns URL query string (without `?`) of the given object + */ +export const queryStringFrom = (object: Record) => + Object.entries(object) + .values() + .filter((entry): entry is [(typeof entry)[0], Exclude<(typeof entry)[1], undefined>] => { + const value = entry[1]; + return value !== undefined; + }) + // Encode each key and value, and concatenate them with "&" + .reduce( + (acc, [key, value]) => `${acc}&${encodeURIComponent(key)}=${encodeURIComponent(value)}`, + "", + ) + // Remove the first "&" + .slice(1); From 70eaa83021f1de88f2c3448b38aeff109640c938 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Sat, 21 Feb 2026 14:13:18 +0000 Subject: [PATCH 09/18] feat(cfrealtime): Common --- .../provider/cf-realtime/api-client/common.ts | 61 +++++++++++++++++++ .../provider/cf-realtime/util/type.ts | 10 +++ 2 files changed, 71 insertions(+) create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/api-client/common.ts create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/util/type.ts diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/common.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/common.ts new file mode 100644 index 0000000..fd7786c --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/common.ts @@ -0,0 +1,61 @@ +export interface CommonOpts { + /** + * @default globalThis.fetch + */ + fetch?: typeof globalThis.fetch; + /** + * @default `https://rtc.live.cloudflare.com/v1` + */ + baseUrl?: string; +} + +export class CfRealtimeApiError extends Error { + /** + * @throws {@link CfRealtimeApiError} Always + */ + static #rethrowWithWrapping = (thrown: unknown): never => { + throw thrown instanceof CfRealtimeApiError + ? thrown + : thrown instanceof Error + ? new CfRealtimeApiError(thrown.message, { cause: thrown }) + : new CfRealtimeApiError("Thrown unknown object", { cause: thrown }); + }; + + /** + * @throws {@link CfRealtimeApiError} When the given {@link process} function throws any objetct + */ + static wrapThrown(process: () => R): R; + static wrapThrown(process: () => Promise): Promise; + static wrapThrown(process: () => R | Promise): R | Promise { + try { + const return_ = process(); + return return_ instanceof Promise + ? return_.catch(CfRealtimeApiError.#rethrowWithWrapping) + : return_; + } catch (thrown) { + return CfRealtimeApiError.#rethrowWithWrapping(thrown); + } + } + + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = "CfRealtimeError"; + } +} + +/** + * Handle {@link Response} + * @throws {@link CfRealtimeApiError} When the response is not ok + */ +export const handleResponse = async (response: Response): Promise => { + if (!response.ok) { + console.error("Fetched error response"); + console.error("Status code:", response.status); + const body = await response.text(); + console.error("Body:", body); + + throw new CfRealtimeApiError(`Fetched error response: ${response.status}`); + } + + return response.json() as Promise; +}; diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/util/type.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/util/type.ts new file mode 100644 index 0000000..272f210 --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/util/type.ts @@ -0,0 +1,10 @@ +type Primitive = undefined | null | boolean | number | string | symbol | bigint | Function; +export type DeepReadonly = T extends Primitive + ? T + : T extends Array + ? ReadonlyArray + : T extends Set + ? ReadonlySet + : T extends Map + ? ReadonlyMap + : { readonly [K in keyof T]: DeepReadonly }; From 02096a63df514e265e29961d0360ba095b8b39c6 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Sun, 22 Feb 2026 08:02:59 +0000 Subject: [PATCH 10/18] feat(cfrealtime): API Clients --- .../api-client/client/close-track.ts | 47 +++++++++++++++++++ .../api-client/client/create-session.ts | 45 ++++++++++++++++++ .../api-client/client/create-track.ts | 47 +++++++++++++++++++ .../api-client/client/renegotiate-session.ts | 47 +++++++++++++++++++ 4 files changed, 186 insertions(+) create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/close-track.ts create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-session.ts create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-track.ts create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/renegotiate-session.ts diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/close-track.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/close-track.ts new file mode 100644 index 0000000..c78b989 --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/close-track.ts @@ -0,0 +1,47 @@ +import type { DeepReadonly } from "../../util/type.ts"; +import type { CommonOpts } from "../common.ts"; +import type { paths } from "../schema.ts"; + +import { handleResponse, CfRealtimeApiError } from "../common.ts"; + +type Schema = paths["/apps/{appId}/sessions/{sessionId}/tracks/close"]["put"]; + +export namespace CloseTrack { + export type Request = DeepReadonly< + Omit & { + header: { + apiToken: string; + }; + } & { + body?: NonNullable["content"]["application/json"]; + } + >; + export type Response = Schema["responses"]["200"]["content"]["application/json"]; + export type Opts = CommonOpts; +} + +/** + * PUT `/apps/:appId/sessions/:sessionId/tracks/close` + * @throws {@link CfRealtimeApiError} + */ +export const closeTrack = async ( + { path: { appId, sessionId }, header: { apiToken }, body }: CloseTrack.Request, + { + fetch = globalThis.fetch, + baseUrl = "https://rtc.live.cloudflare.com/v1", + }: CloseTrack.Opts = {}, +) => + CfRealtimeApiError.wrapThrown(async () => { + const response = await fetch( + new URL(`/apps/${appId}/sessions/${sessionId}/tracks/close`, baseUrl), + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + body: body ? JSON.stringify(body) : null, + }, + ); + return handleResponse(response); + }); diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-session.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-session.ts new file mode 100644 index 0000000..a8ba00e --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-session.ts @@ -0,0 +1,45 @@ +import type { DeepReadonly } from "../../util/type.ts"; +import type { CommonOpts } from "../common.ts"; +import type { paths } from "../schema.ts"; + +import { queryStringFrom } from "../../../../../../util/url.ts"; +import { handleResponse, CfRealtimeApiError } from "../common.ts"; + +type Schema = paths["/apps/{appId}/sessions/new"]["post"]; + +export namespace CreateSession { + export type Request = DeepReadonly< + Omit & { + header: { + apiToken: string; + }; + } + >; + export type Response = Schema["responses"]["201"]["content"]["application/json"]; + export type Opts = CommonOpts; +} + +/** + * POST `/apps/:appId/sessions/new` + * @throws {@link CfRealtimeApiError} + */ +export const createSession = async ( + { path: { appId }, query, header: { apiToken } }: CreateSession.Request, + { + fetch = globalThis.fetch, + baseUrl = "https://rtc.live.cloudflare.com/v1", + }: CreateSession.Opts = {}, +): Promise => + CfRealtimeApiError.wrapThrown(async () => { + const response = await fetch( + new URL(`/apps/${appId}/sessions/new${query ? `?${queryStringFrom(query)}` : ""}`, baseUrl), + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + }, + ); + return handleResponse(response); + }); diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-track.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-track.ts new file mode 100644 index 0000000..bcf4c23 --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/create-track.ts @@ -0,0 +1,47 @@ +import type { DeepReadonly } from "../../util/type.ts"; +import type { CommonOpts } from "../common.ts"; +import type { paths } from "../schema.ts"; + +import { handleResponse, CfRealtimeApiError } from "../common.ts"; + +type Schema = paths["/apps/{appId}/sessions/{sessionId}/tracks/new"]["post"]; + +export namespace CreateTrack { + export type Request = DeepReadonly< + Omit & { + header: { + apiToken: string; + }; + } & { + body?: NonNullable["content"]["application/json"]; + } + >; + export type Response = Schema["responses"]["200"]["content"]["application/json"]; + export type Opts = CommonOpts; +} + +/** + * POST `/apps/:appId/sessions/:sessionId/tracks/new` + * @throws {@link CfRealtimeApiError} + */ +export const createTrack = async ( + { path: { appId, sessionId }, header: { apiToken }, body }: CreateTrack.Request, + { + fetch = globalThis.fetch, + baseUrl = "https://rtc.live.cloudflare.com/v1", + }: CreateTrack.Opts = {}, +) => + CfRealtimeApiError.wrapThrown(async () => { + const response = await fetch( + new URL(`/apps/${appId}/sessions/${sessionId}/tracks/new`, baseUrl), + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + body: body ? JSON.stringify(body) : null, + }, + ); + return handleResponse(response); + }); diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/renegotiate-session.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/renegotiate-session.ts new file mode 100644 index 0000000..e6f68fc --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/api-client/client/renegotiate-session.ts @@ -0,0 +1,47 @@ +import type { DeepReadonly } from "../../util/type.ts"; +import type { CommonOpts } from "../common.ts"; +import type { paths } from "../schema.ts"; + +import { handleResponse, CfRealtimeApiError } from "../common.ts"; + +type Schema = paths["/apps/{appId}/sessions/{sessionId}/renegotiate"]["put"]; + +export namespace RenegotiateSession { + export type Request = DeepReadonly< + Omit & { + header: { + apiToken: string; + }; + } & { + body?: NonNullable["content"]["application/json"]; + } + >; + export type Response = Schema["responses"]["200"]["content"]["application/json"]; + export type Opts = CommonOpts; +} + +/** + * PUT `/apps/:appId/sessions/:sessionId/renegotiate` + * @throws {@link CfRealtimeApiError} + */ +export const renegotiateSession = async ( + { path: { appId, sessionId }, header: { apiToken }, body }: RenegotiateSession.Request, + { + fetch = globalThis.fetch, + baseUrl = "https://rtc.live.cloudflare.com/v1", + }: RenegotiateSession.Opts = {}, +) => + CfRealtimeApiError.wrapThrown(async () => { + const response = await fetch( + new URL(`/apps/${appId}/sessions/${sessionId}/renegotiate`, baseUrl), + { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiToken}`, + }, + body: body ? JSON.stringify(body) : null, + }, + ); + return handleResponse(response); + }); From 21095fbebc06f47ee49ba47198b45d84ce376a2d Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 06:33:56 +0000 Subject: [PATCH 11/18] feat(cfrealtime): Implement `WebRtcServerProvider` --- .../provider/cf-realtime/error.ts | 35 +++ .../provider/cf-realtime/provider.ts | 201 ++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/error.ts create mode 100644 src/server/web-rtc-server-provider/provider/cf-realtime/provider.ts diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/error.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/error.ts new file mode 100644 index 0000000..3d2a3f4 --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/error.ts @@ -0,0 +1,35 @@ +import { WebRtcServerProviderError } from "../../interface.ts"; + +export class CfRealtimeWebRtcServerProviderError extends WebRtcServerProviderError { + /** + * @throws {@link CfRealtimeApiError} Always + */ + static #rethrowWithWrapping = (thrown: unknown): never => { + throw thrown instanceof CfRealtimeWebRtcServerProviderError + ? thrown + : thrown instanceof Error + ? new CfRealtimeWebRtcServerProviderError(thrown.message, { cause: thrown }) + : new CfRealtimeWebRtcServerProviderError("Thrown unknown object", { cause: thrown }); + }; + + /** + * @throws {@link CfRealtimeWebRtcServerProviderError} When the given {@link process} function throws any objetct + */ + static wrapThrown(process: () => R): R; + static wrapThrown(process: () => Promise): Promise; + static wrapThrown(process: () => R | Promise): R | Promise { + try { + const return_ = process(); + return return_ instanceof Promise + ? return_.catch(CfRealtimeWebRtcServerProviderError.#rethrowWithWrapping) + : return_; + } catch (thrown) { + return CfRealtimeWebRtcServerProviderError.#rethrowWithWrapping(thrown); + } + } + + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = "CfRealtimeWebRtcServerProviderError"; + } +} diff --git a/src/server/web-rtc-server-provider/provider/cf-realtime/provider.ts b/src/server/web-rtc-server-provider/provider/cf-realtime/provider.ts new file mode 100644 index 0000000..ee5fd11 --- /dev/null +++ b/src/server/web-rtc-server-provider/provider/cf-realtime/provider.ts @@ -0,0 +1,201 @@ +import type { WebRtcServerProvider } from "../../interface.ts"; + +import { closeTrack } from "./api-client/client/close-track.ts"; +import { createSession } from "./api-client/client/create-session.ts"; +import { createTrack } from "./api-client/client/create-track.ts"; +import { renegotiateSession } from "./api-client/client/renegotiate-session.ts"; +import { CfRealtimeWebRtcServerProviderError } from "./error.ts"; + +export interface CfRealtimeWebRtcServerProviderConfig { + apiToken: string; + appId: string; +} + +export class CfRealtimeWebRtcServerProvider implements WebRtcServerProvider { + readonly #apiToken: string; + readonly #appId: string; + + constructor({ apiToken, appId }: CfRealtimeWebRtcServerProviderConfig) { + this.#apiToken = apiToken; + this.#appId = appId; + } + + async createSession(): Promise<{ + sessionId: string; + }> { + return CfRealtimeWebRtcServerProviderError.wrapThrown(async () => { + const result = await createSession({ + path: { + appId: this.#appId, + }, + header: { + apiToken: this.#apiToken, + }, + }); + return { + sessionId: result.sessionId, + }; + }); + } + + // #region Overload declarations + createTrack(opts: { + kind: "send"; + sessionId: string; + sessionDescription: { + sdp: string; + }; + tracks: readonly { + mid: string; + trackName: string; + }[]; + }): Promise<{ + sessionDescription: RTCSessionDescriptionInit; + }>; + createTrack(opts: { + kind: "receive"; + sessionId: string; + tracks: readonly { + sessionId: string; + trackName: string; + }[]; + }): Promise< + { + tracks: { + mid: string; + }[]; + } & ( + | { + requiresImmediateRenegotiation: false; + } + | { + requiresImmediateRenegotiation: true; + sessionDescription: RTCSessionDescriptionInit; + } + ) + >; + // #endregion + async createTrack( + opts: + | { + kind: "send"; + sessionId: string; + sessionDescription: { + sdp: string; + }; + tracks: readonly { + trackName: string; + mid: string; + }[]; + } + | { + kind: "receive"; + sessionId: string; + tracks: readonly { + sessionId: string; + trackName: string; + }[]; + }, + ): Promise<{ + tracks: { + mid: string; + }[]; + requiresImmediateRenegotiation: boolean; + sessionDescription: RTCSessionDescriptionInit; + }> { + return CfRealtimeWebRtcServerProviderError.wrapThrown(async () => { + const result = await createTrack({ + path: { + appId: this.#appId, + sessionId: opts.sessionId, + }, + header: { + apiToken: this.#apiToken, + }, + body: + opts.kind === "send" + ? // send + { + sessionDescription: { + type: "offer", + sdp: opts.sessionDescription.sdp, + }, + tracks: opts.tracks.map((track) => ({ + ...track, + location: "local", + })), + } + : // receive + { + tracks: opts.tracks.map((track) => ({ + ...track, + location: "remote", + })), + }, + }); + + return { + sessionDescription: result.sessionDescription as Required< + NonNullable + >, + tracks: result.tracks as Required[number]>[], + requiresImmediateRenegotiation: result.requiresImmediateRenegotiation!, + }; + }); + } + + async renegotiateSession(opts: { + sessionId: string; + sessionDescription: { + sdp: string; + }; + }): Promise { + return CfRealtimeWebRtcServerProviderError.wrapThrown(async () => { + await renegotiateSession({ + path: { + appId: this.#appId, + sessionId: opts.sessionId, + }, + header: { + apiToken: this.#apiToken, + }, + body: { + sessionDescription: { + type: "answer", + sdp: opts.sessionDescription.sdp, + }, + }, + }); + }); + } + + async closeTrack(opts: { + kind: "send" | "receive"; + sessionId: string; + sessionDescription: { + sdp: string; + }; + tracks: readonly { + mid: string; + }[]; + }): Promise { + return CfRealtimeWebRtcServerProviderError.wrapThrown(async () => { + await closeTrack({ + path: { + appId: this.#appId, + sessionId: opts.sessionId, + }, + header: { + apiToken: this.#apiToken, + }, + body: { + sessionDescription: { + type: opts.kind === "send" ? "offer" : "answer", + sdp: opts.sessionDescription.sdp, + }, + tracks: opts.tracks, + }, + }); + }); + } +} From 9797c961c32f59f7f402b542b41fe27dcf7f0e79 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 10:10:02 +0000 Subject: [PATCH 12/18] feat(storageprovider): Interface --- src/server/storage-provider/interface.ts | 40 ++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/server/storage-provider/interface.ts diff --git a/src/server/storage-provider/interface.ts b/src/server/storage-provider/interface.ts new file mode 100644 index 0000000..90c3fe0 --- /dev/null +++ b/src/server/storage-provider/interface.ts @@ -0,0 +1,40 @@ +import type { ReadonlyRecursive } from "../../util/type.ts"; + +// #region Schema +export type MogomogoSessionId = string; +export type WebRtcServerSessionId = string; +export interface MuttererSession { + webRtcServerSessionId: WebRtcServerSessionId; + tracks: { + trackName: string; + mid: string; + }[]; + createdAt: Date; +} +export interface TapperSession { + webRtcServerSessionId: WebRtcServerSessionId; + createdAt: Date; +} +// #endregion + +export interface StorageProvider { + saveMuttererSession( + mogomogoSessionId: MogomogoSessionId, + relations: ReadonlyRecursive, + ): Promise; + loadMuttererSession(mogomogoSessionId: MogomogoSessionId): Promise; + deleteOutdatedMuttererSessions(olderThan: Date): Promise; + saveTapperSession( + mogomogoSessionId: MogomogoSessionId, + relations: ReadonlyRecursive, + ): Promise; + loadTapperSession(mogomogoSessionId: MogomogoSessionId): Promise; + deleteOutdatedTapperSessions(olderThan: Date): Promise; +} + +export class StorageProviderError extends Error { + constructor(message?: string, options?: ErrorOptions) { + super(message, options); + this.name = "StorageProviderError"; + } +} From 749a9e0944bd465890f097222da3fb2ed9dc174a Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 11:07:03 +0000 Subject: [PATCH 13/18] feat(server): Core --- src/server/core.ts | 193 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 src/server/core.ts diff --git a/src/server/core.ts b/src/server/core.ts new file mode 100644 index 0000000..a430f59 --- /dev/null +++ b/src/server/core.ts @@ -0,0 +1,193 @@ +import type { StorageProvider } from "./storage-provider/interface.ts"; +import type { WebRtcServerProvider } from "./web-rtc-server-provider/interface.ts"; + +export interface MogomogoConfig { + debugMode?: boolean; +} + +export class Mogomogo { + readonly #webRtcServerProvider: WebRtcServerProvider; + readonly #storageProvider: StorageProvider; + readonly #config: Required; + + static readonly #MOGOMOGO_DEFAULT_CONFIG = { + debugMode: false, + } as const satisfies Required; + + constructor({ + webRtcServerProvider, + storageProvider, + config = Mogomogo.#MOGOMOGO_DEFAULT_CONFIG as TConfig, + }: { + webRtcServerProvider: WebRtcServerProvider; + storageProvider: StorageProvider; + config?: TConfig; + }) { + this.#webRtcServerProvider = webRtcServerProvider; + this.#storageProvider = storageProvider; + this.#config = { + ...Mogomogo.#MOGOMOGO_DEFAULT_CONFIG, + ...config, + } as Required; + } + + /** + * @returns The given data when `debugMode` is `true`, otherwise an empty object + */ + #withDebugInfo(data: TData): TConfig["debugMode"] extends true ? TData : {} { + return this.#config.debugMode + ? // @ts-expect-error + data + : // @ts-expect-error + {}; + } + + async #createSession() { + const mogomogoSessionId = crypto.randomUUID(); + const { sessionId: webRtcServerSessionId } = await this.#webRtcServerProvider.createSession(); + + return { + mogomogoSessionId, + webRtcServerSessionId, + }; + } + + async startMuttering({ + fromBrowser, + }: { + fromBrowser: { + sessionDescription: { + sdp: string; + }; + tracks: readonly { + trackName: string; + mid: string; + }[]; + }; + }) { + const session = await this.#createSession(); + + const track = await this.#webRtcServerProvider.createTrack({ + kind: "send", + sessionId: session.webRtcServerSessionId, + sessionDescription: fromBrowser.sessionDescription, + tracks: fromBrowser.tracks, + }); + + await this.#storageProvider.saveMuttererSession(session.mogomogoSessionId, { + webRtcServerSessionId: session.webRtcServerSessionId, + tracks: fromBrowser.tracks, + createdAt: new Date(), + }); + + return { + session: { + mogomogoSessionId: session.mogomogoSessionId, + ...this.#withDebugInfo({ + webRtcServerSessionId: session.webRtcServerSessionId, + }), + }, + toBrowser: track, + }; + } + + async startTapping({ muttererMogomogoSessionId }: { muttererMogomogoSessionId: string }) { + const tapperSession = await this.#createSession(); + + const muttererSession = + await this.#storageProvider.loadMuttererSession(muttererMogomogoSessionId); + if (!muttererSession) + throw new Error(`Session with mogomogoSessionId not found: ${muttererMogomogoSessionId}`); + + const track = await this.#webRtcServerProvider.createTrack({ + kind: "receive", + sessionId: tapperSession.webRtcServerSessionId, + tracks: muttererSession.tracks.map(({ trackName }) => ({ + sessionId: muttererSession.webRtcServerSessionId, + trackName, + })), + }); + + await this.#storageProvider.saveTapperSession(tapperSession.mogomogoSessionId, { + webRtcServerSessionId: tapperSession.webRtcServerSessionId, + createdAt: new Date(), + }); + + return { + session: { + mogomogoSessionId: tapperSession.mogomogoSessionId, + ...this.#withDebugInfo({ + webRtcServerSessionId: tapperSession.webRtcServerSessionId, + }), + }, + toBrowser: track, + }; + } + + async renegotiateTapperSession({ + mogomogoSessionId, + sessionDescription, + }: { + mogomogoSessionId: string; + sessionDescription: { + sdp: string; + }; + }) { + const session = await this.#storageProvider.loadTapperSession(mogomogoSessionId); + if (!session) throw new Error(`Session with mogomogoSessionId not found: ${mogomogoSessionId}`); + + await this.#webRtcServerProvider.renegotiateSession({ + sessionId: session.webRtcServerSessionId, + sessionDescription, + }); + } + + async stopMuttering({ + mogomogoSessionId, + sessionDescription, + }: { + mogomogoSessionId: string; + sessionDescription: { + sdp: string; + }; + }) { + const muttererSession = await this.#storageProvider.loadMuttererSession(mogomogoSessionId); + if (!muttererSession) + throw new Error(`Session with mogomogoSessionId not found: ${mogomogoSessionId}`); + + await this.#webRtcServerProvider.closeTrack({ + kind: "send", + sessionId: muttererSession.webRtcServerSessionId, + sessionDescription: sessionDescription, + tracks: muttererSession.tracks, + }); + } + + async stopTapping({ + mogomogoSessionId, + muttererMogomogoSessionId, + sessionDescription, + }: { + mogomogoSessionId: string; + muttererMogomogoSessionId: string; + sessionDescription: { + sdp: string; + }; + }) { + const tapperSession = await this.#storageProvider.loadTapperSession(mogomogoSessionId); + if (!tapperSession) + throw new Error(`Session with mogomogoSessionId not found: ${mogomogoSessionId}`); + + const muttererSession = + await this.#storageProvider.loadMuttererSession(muttererMogomogoSessionId); + if (!muttererSession) + throw new Error(`Session with mogomogoSessionId not found: ${muttererMogomogoSessionId}`); + + await this.#webRtcServerProvider.closeTrack({ + kind: "receive", + sessionId: tapperSession.webRtcServerSessionId, + sessionDescription: sessionDescription, + tracks: muttererSession.tracks, + }); + } +} From 9b54b4f6d188ae1a9f7704260783c2e6c8285ecb Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Tue, 10 Mar 2026 16:02:45 +0000 Subject: [PATCH 14/18] deps: Add Hono for example --- package.json | 2 ++ pnpm-lock.yaml | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/package.json b/package.json index a54e4ba..82aa191 100644 --- a/package.json +++ b/package.json @@ -26,8 +26,10 @@ "cloudflare": "^6.0.0" }, "devDependencies": { + "@hono/node-server": "^1.19.11", "@types/node": "24 - 24.13", "@typescript/native-preview": "7.0.0-dev.20260315.1", + "hono": "^4.12.7", "openapi-typescript": "^7.13.0", "oxfmt": "^0.49.0", "oxlint": "^1.42.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 053ede0..7222751 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,12 +12,18 @@ importers: specifier: ^6.0.0 version: 6.1.0 devDependencies: + '@hono/node-server': + specifier: ^1.19.11 + version: 1.19.11(hono@4.12.7) '@types/node': specifier: 24 - 24.13 version: 24.10.12 '@typescript/native-preview': specifier: 7.0.0-dev.20260315.1 version: 7.0.0-dev.20260315.1 + hono: + specifier: ^4.12.7 + version: 4.12.7 openapi-typescript: specifier: ^7.13.0 version: 7.13.0(typescript@5.9.3) @@ -200,6 +206,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -883,6 +895,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1258,6 +1274,10 @@ snapshots: '@esbuild/win32-x64@0.27.7': optional: true + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + '@jridgewell/sourcemap-codec@1.5.5': {} '@oxfmt/binding-android-arm-eabi@0.49.0': @@ -1767,6 +1787,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.12.7: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 From fdb464cf5ced070ea1c70ed2a244100732948ffe Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Sat, 14 Mar 2026 08:02:16 +0000 Subject: [PATCH 15/18] deps(devcontainer): Add `ghcr.io/tailscale/codespace/tailscale` --- .devcontainer/devcontainer.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 283ab19..9c845bc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,7 +9,8 @@ "version": "lts", "pnpmVersion": "latest", "nvmVersion": "latest" - } + }, + "ghcr.io/tailscale/codespace/tailscale:1.0.8": {} }, "workspaceFolder": "/mogomogo", "workspaceMount": "source=${localWorkspaceFolder},target=${containerWorkspaceFolder},type=bind,consistency=cached", From 38124b34e4389101dd6dde6aa6153d87b724b491 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Fri, 15 May 2026 13:26:52 +0000 Subject: [PATCH 16/18] feat(util): `ReadonlyRecursive` type --- src/util/type.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/util/type.ts b/src/util/type.ts index 0d816b6..ebfd963 100644 --- a/src/util/type.ts +++ b/src/util/type.ts @@ -8,3 +8,27 @@ export type UnionToIntersection = (U extends any ? (k: U) => void : never) ex ) => void ? I : never; + +/** + * @example + * type Result = ReadonlyRecursive<{ + * a: string; + * b: { + * c: number; + * }; + * }>; + * // ^? => { + * // readonly a: string; + * // readonly b: { + * // readonly c: number; + * // }; + * // } + * } + */ +export type ReadonlyRecursive = T extends Function + ? T + : T extends object + ? { + readonly [K in keyof T]: ReadonlyRecursive; + } + : T; From a4b3ab240cf1ce4dd88f7cf4682e96c605eb3b8c Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Fri, 15 May 2026 13:36:34 +0000 Subject: [PATCH 17/18] env(devcontainer): Add `devcontainer-lock.json` --- .devcontainer/devcontainer-lock.json | 14 ++++++++++++++ .oxfmtrc.json | 1 + 2 files changed, 15 insertions(+) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..5eab267 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,14 @@ +{ + "features": { + "ghcr.io/devcontainers/features/node:2": { + "version": "2.0.0", + "resolved": "ghcr.io/devcontainers/features/node@sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f", + "integrity": "sha256:fedd4c11f7adfb64283b578dddc7da906728daa25fa293351c9d913231acf12f" + }, + "ghcr.io/tailscale/codespace/tailscale:1.0.8": { + "version": "1.0.8", + "resolved": "ghcr.io/tailscale/codespace/tailscale@sha256:fa5ce7ca298e7ab95842fef99e107a01b090b8ca8a687789220f3f3c887ccdb2", + "integrity": "sha256:fa5ce7ca298e7ab95842fef99e107a01b090b8ca8a687789220f3f3c887ccdb2" + } + } +} \ No newline at end of file diff --git a/.oxfmtrc.json b/.oxfmtrc.json index 556d84b..b877461 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,6 +1,7 @@ { "$schema": "./node_modules/oxfmt/configuration_schema.json", "ignorePatterns": [ + ".devcontainer/devcontainer-lock.json", "src/server/web-rtc-server-provider/provider/cf-realtime/api-client/schema.ts" ], "useTabs": true, From b1b8bbf799e2354c1637dad80d21c98d199023e9 Mon Sep 17 00:00:00 2001 From: ThinaticSystem Date: Mon, 23 Feb 2026 07:27:28 +0000 Subject: [PATCH 18/18] docs: Add example --- .env.example | 4 + .oxfmtrc.json | 3 +- src/env/variables/keys.ts | 2 + src/example/backend/db.ts | 123 ++++++++ src/example/backend/env-vars.ts | 24 ++ src/example/backend/index.ts | 362 +++++++++++++++++++++++ src/example/backend/logger.ts | 65 ++++ src/example/backend/session.ts | 159 ++++++++++ src/example/frontend/consts.js | 21 ++ src/example/frontend/index.html | 18 ++ src/example/frontend/main.js | 14 + src/example/frontend/service.js | 188 ++++++++++++ src/example/frontend/util/backend-api.js | 74 +++++ src/example/frontend/util/logger.js | 43 +++ src/example/frontend/util/result.js | 112 +++++++ src/example/frontend/util/view/dom.js | 122 ++++++++ src/example/frontend/util/view/scene.js | 61 ++++ src/example/frontend/view.js | 246 +++++++++++++++ src/example/serve.sh | 7 + 19 files changed, 1647 insertions(+), 1 deletion(-) create mode 100644 src/example/backend/db.ts create mode 100644 src/example/backend/env-vars.ts create mode 100644 src/example/backend/index.ts create mode 100644 src/example/backend/logger.ts create mode 100644 src/example/backend/session.ts create mode 100644 src/example/frontend/consts.js create mode 100644 src/example/frontend/index.html create mode 100644 src/example/frontend/main.js create mode 100644 src/example/frontend/service.js create mode 100644 src/example/frontend/util/backend-api.js create mode 100644 src/example/frontend/util/logger.js create mode 100644 src/example/frontend/util/result.js create mode 100644 src/example/frontend/util/view/dom.js create mode 100644 src/example/frontend/util/view/scene.js create mode 100644 src/example/frontend/view.js create mode 100755 src/example/serve.sh diff --git a/.env.example b/.env.example index f96cab7..2e7c031 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,7 @@ # Permissions: # - Calls Write # CLOUDFLARE_CALLS_API_TOKEN= + +# Cloudflare SFU app +CLOUDFLARE_SFU_APP_ID= +CLOUDFLARE_SFU_APP_API_TOKEN= diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b877461..1c3ebe4 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -16,5 +16,6 @@ "unknown" ], "partitionByComment": true - } + }, + "embeddedLanguageFormatting": "off" } diff --git a/src/env/variables/keys.ts b/src/env/variables/keys.ts index ca9cb58..6a98a22 100644 --- a/src/env/variables/keys.ts +++ b/src/env/variables/keys.ts @@ -3,4 +3,6 @@ export const ENVIRONMENT_VARIABLE_KEYS = { CLOUDFLARE_CALLS_API_TOKEN: "CLOUDFLARE_CALLS_API_TOKEN", /** For `create-cloudflare-sfu-app.ts` */ CLOUDFLARE_ACCOUNT_ID: "CLOUDFLARE_ACCOUNT_ID", + CLOUDFLARE_SFU_APP_ID: "CLOUDFLARE_SFU_APP_ID", + CLOUDFLARE_SFU_APP_API_TOKEN: "CLOUDFLARE_SFU_APP_API_TOKEN", } as const; diff --git a/src/example/backend/db.ts b/src/example/backend/db.ts new file mode 100644 index 0000000..5f8de8d --- /dev/null +++ b/src/example/backend/db.ts @@ -0,0 +1,123 @@ +import type { + MogomogoSessionId, + MuttererSession, + TapperSession, +} from "../../server/storage-provider/interface.ts"; +import type { ReadonlyRecursive } from "../../util/type.ts"; + +export namespace Db { + export interface Api { + Create(columns: ReadonlyRecursive): Promise; + Read(key: TColumns[TKey]): Promise; + List(condition: (columns: TColumns) => boolean): Promise; + Update( + condition: (columns: TColumns) => boolean, + columns: Partial>, + ): Promise>; + Delete(condition: (columns: TColumns) => boolean): Promise>; + } + + // Application land schema + export type UserId = string; + export type SessionId = string; + export interface User { + userId: UserId; + name: string; + } + export interface Session { + sessionId: SessionId; + expiresAt: Date; + userId: UserId; + } + export interface MutteringState { + userId: UserId; + mogomogoSessionId: MogomogoSessionId; + } + export interface TappingState { + userId: UserId; + mogomogoSessionId: MogomogoSessionId; + } + + export interface Tables { + // Mogomogo land tables + muttererSessions: Api< + MuttererSession & { mogomogoSessionId: MogomogoSessionId }, + "mogomogoSessionId" + >; + tapperSessions: Api< + TapperSession & { mogomogoSessionId: MogomogoSessionId }, + "mogomogoSessionId" + >; + + // Application land tables + users: Api; + sessions: Api; + mutteringStates: Api; + tappingStates: Api; + } +} + +const emptyTable = (keyColumn: K): Db.Api => { + const map = new Map(); + + return { + Create: async (columns) => { + map.set((columns as V)[keyColumn], columns as V); + }, + Read: async (key) => map.get(key) ?? null, + List: async (condition) => + map + .values() + .filter((columns) => condition(columns)) + .toArray(), + Update: async (condition, columns) => + new Set( + map + .entries() + .filter(([_, v]) => condition(v)) + .map(([key, found]) => { + // NOTE: For performance reasons, use a shared loop with foreach + map.set(key, { ...found, ...columns }); + return key; + }), + ), + Delete: async (condition) => + new Set( + map + .entries() + .filter(([_, v]) => condition(v)) + .map(([key]) => { + // NOTE: For performance reasons, use a shared loop with foreach + map.delete(key); + return key; + }), + ), + }; +}; + +export const createDb = (): Db.Tables => { + // Mogomogo land tables + const muttererSessions = emptyTable< + MuttererSession & { mogomogoSessionId: MogomogoSessionId }, + "mogomogoSessionId" + >("mogomogoSessionId"); + const tapperSessions = emptyTable< + TapperSession & { mogomogoSessionId: MogomogoSessionId }, + "mogomogoSessionId" + >("mogomogoSessionId"); + + // Application land tables + const users = emptyTable("userId"); + const sessions = emptyTable("sessionId"); + const mutteringStates = emptyTable("userId"); + const tappingStates = emptyTable("userId"); + + return { + muttererSessions, + tapperSessions, + users, + sessions, + mutteringStates, + tappingStates, + }; +}; diff --git a/src/example/backend/env-vars.ts b/src/example/backend/env-vars.ts new file mode 100644 index 0000000..a5d2f1a --- /dev/null +++ b/src/example/backend/env-vars.ts @@ -0,0 +1,24 @@ +import type { EnvironmentVariablesContext } from "../../env/variables/context.ts"; +import type { Needs } from "../../util/contextual/core.ts"; + +import { getEnvironmentVariables as getEnvironmentVariablesGlobal } from "../../env/variables/context.ts"; +import { ENVIRONMENT_VARIABLE_KEYS } from "../../env/variables/keys.ts"; +import { EnvironmentVariableValidators } from "../../env/variables/validator.ts"; + +export interface GetEnvironmentVariablesResult { + CLOUDFLARE_SFU_APP_ID: string; + CLOUDFLARE_SFU_APP_API_TOKEN: string; +} + +export function* getEnvironmentVariables(): Needs< + EnvironmentVariablesContext, + Readonly +> { + const globalVariables = yield* getEnvironmentVariablesGlobal(); + const { required } = new EnvironmentVariableValidators(globalVariables); + const KEYS = ENVIRONMENT_VARIABLE_KEYS; + return { + CLOUDFLARE_SFU_APP_ID: required(KEYS.CLOUDFLARE_SFU_APP_ID), + CLOUDFLARE_SFU_APP_API_TOKEN: required(KEYS.CLOUDFLARE_SFU_APP_API_TOKEN), + } as const; +} diff --git a/src/example/backend/index.ts b/src/example/backend/index.ts new file mode 100644 index 0000000..1e39068 --- /dev/null +++ b/src/example/backend/index.ts @@ -0,0 +1,362 @@ +import type { HonoBase } from "hono/hono-base"; + +import path from "node:path"; + +import { serve } from "@hono/node-server"; +import { serveStatic } from "@hono/node-server/serve-static"; +import { Hono } from "hono"; +import { validator } from "hono/validator"; + +import { runWithEnvironmentVariables } from "../../env/variables/context.ts"; +import { Mogomogo } from "../../server/core.ts"; +import { CfRealtimeWebRtcServerProvider } from "../../server/web-rtc-server-provider/provider/cf-realtime/provider.ts"; +import { createDb } from "./db.ts"; +import { getEnvironmentVariables } from "./env-vars.ts"; +import { loggerOf } from "./logger.ts"; +import { Session } from "./session.ts"; + +const db = createDb(); +await Promise.all([ + db.users.Create({ userId: "userA", name: "User A" }), + db.users.Create({ userId: "userB", name: "User B" }), +]); + +const api = await runWithEnvironmentVariables(async function* () { + const env = yield* getEnvironmentVariables(); + + const mgmg = new Mogomogo({ + webRtcServerProvider: new CfRealtimeWebRtcServerProvider({ + apiToken: env.CLOUDFLARE_SFU_APP_API_TOKEN, + appId: env.CLOUDFLARE_SFU_APP_ID, + }), + storageProvider: { + saveMuttererSession: async (mogomogoSessionId, relations) => { + await db.muttererSessions.Create({ mogomogoSessionId, ...relations }); + }, + loadMuttererSession: async (mogomogoSessionId) => { + return await db.muttererSessions.Read(mogomogoSessionId); + }, + deleteOutdatedMuttererSessions: async (olderThan) => { + await db.muttererSessions.Delete( + ({ createdAt }) => createdAt.getTime() < olderThan.getTime(), + ); + }, + deleteOutdatedTapperSessions: async (olderThan) => { + await db.tapperSessions.Delete( + ({ createdAt }) => createdAt.getTime() < olderThan.getTime(), + ); + }, + saveTapperSession: async (mogomogoSessionId, relations) => { + await db.tapperSessions.Create({ mogomogoSessionId, ...relations }); + }, + loadTapperSession: async (mogomogoSessionId) => { + return await db.tapperSessions.Read(mogomogoSessionId); + }, + }, + config: { + debugMode: true, + }, + }); + + const api = new Hono() + .get("/login-state", async (ctx) => { + const logger = loggerOf("API(GET /login-state)"); + + const session = await Session.tryVerify(ctx, db.sessions); + if (!session) { + logger.info("No valid session found."); + return ctx.json({ isLoggedIn: false }); + } + logger.info(`Valid session found for user ID ${session.userId}.`); + + // NOTE: User deletion is not implemented, so if a session exists, the user is guaranteed to exist + const user = (await db.users.Read(session.userId))!; + + return ctx.json({ + isLoggedIn: true, + user: { + id: session.userId, + name: user.name, + }, + }); + }) + .post( + "/login", + validator( + "json", + (value) => + value as { + idToken: string; + }, + ), + async (ctx) => { + const logger = loggerOf("API(POST /login)"); + + const userId = ((): string => { + const { idToken } = ctx.req.valid("json"); + const [, payloadBase64] = idToken.split("."); + // Skips signature verification (wild etiquette) + const payload = JSON.parse(atob(payloadBase64!)); + return payload.sub; + })(); + + const user = await db.users.Read(userId); + if (!user) { + logger.warn(`User with ID ${userId} not found.`); + return ctx.body(null, 401); + } + + const session = await Session.issueFor(userId, db.sessions, ctx); + logger.info(`New session issued for user ID ${userId}.`); + + return ctx.json({ + user: { + id: session.userId, + name: user.name, + }, + }); + }, + ) + .post("/logout", Session.sessionValidator(db.sessions), async (ctx) => { + const logger = loggerOf("API(POST /logout)"); + + const session = ctx.req.valid("cookie"); + + await session.destroy(db.sessions, ctx); + logger.info(`User with ID ${session.userId} logged out.`); + + return ctx.body(null, 204); + }) + .post( + "/start-muttering", + Session.sessionValidator(db.sessions), + validator( + "json", + (value) => + value as { + sessionDescription: { + sdp: string; + }; + tracks: { + trackName: string; + mid: string; + }[]; + }, + ), + async (ctx) => { + const logger = loggerOf("API(POST /start-muttering)"); + + const session = ctx.req.valid("cookie"); + const body = ctx.req.valid("json"); + + const muttererSession = await mgmg.startMuttering({ + fromBrowser: { + sessionDescription: body.sessionDescription, + tracks: body.tracks, + }, + }); + logger.debug(`Mogomogo session started on web RTC server for user ID ${session.userId}.`); + + // Stores muttering session in the database + // to find the muttering session later when the tapper starts tapping or stopping muttering + await db.mutteringStates.Create({ + userId: session.userId, + mogomogoSessionId: muttererSession.session.mogomogoSessionId, + }); + + logger.info( + `User with ID ${session.userId} started muttering with Mogomogo session ID ${muttererSession.session.mogomogoSessionId}.`, + ); + + // Returns session ID and SDP to the Mutterer client + return ctx.json(muttererSession.toBrowser); + }, + ) + .get("/mutterings", Session.sessionValidator(db.sessions), async (ctx) => { + const logger = loggerOf("API(GET /mutterings)"); + + // Loads mutterings list from the database + const mutterings = (await db.mutteringStates.List(() => true)).map( + ({ userId, mogomogoSessionId }) => ({ + userId, + mogomogoSessionId, + }), + ); + logger.info(`Loaded mutterings list with ${mutterings.length} entries.`); + + return ctx.json(mutterings); + }) + .post( + "/start-tapping", + Session.sessionValidator(db.sessions), + validator( + "json", + (value) => + value as { + muttererSessionId: string; + }, + ), + async (ctx) => { + const logger = loggerOf("API(POST /start-tapping)"); + + const session = ctx.req.valid("cookie"); + const body = ctx.req.valid("json"); + + const tapperSession = await mgmg.startTapping({ + muttererMogomogoSessionId: body.muttererSessionId, + }); + logger.debug(`Tapper session started on web RTC server for user ID ${session.userId}.`); + + // Stores tapping session in the database + // to find the tapping session later when renegotiating or stopping tapping + await db.tappingStates.Create({ + userId: session.userId, + mogomogoSessionId: tapperSession.session.mogomogoSessionId, + }); + logger.info( + `User with ID ${session.userId} started tapping with Mogomogo session ID ${tapperSession.session.mogomogoSessionId}.`, + ); + + // Returns session ID and SDP to the Tapper client + return ctx.json(tapperSession.toBrowser); + }, + ) + .post( + "/renegotiate-tapping", + Session.sessionValidator(db.sessions), + validator( + "json", + (value) => + value as { + sessionDescription: { + sdp: string; + }; + }, + ), + async (ctx) => { + const logger = loggerOf("API(POST /renegotiate-tapping)"); + + const session = ctx.req.valid("cookie"); + const body = ctx.req.valid("json"); + + // Loads tapping state from the database + const tappingState = await db.tappingStates.Read(session.userId); + if (!tappingState) { + logger.warn( + `User with ID ${session.userId} attempted to renegotiate tapping but is not tapping.`, + ); + return ctx.json({ error: "Not tapping" }, 409); + } + + await mgmg.renegotiateTapperSession({ + mogomogoSessionId: tappingState.mogomogoSessionId, + sessionDescription: body.sessionDescription, + }); + logger.info( + `User with ID ${session.userId} renegotiated tapping session with Mogomogo session ID ${tappingState.mogomogoSessionId}.`, + ); + + return ctx.body(null, 204); + }, + ) + .post( + "/stop-muttering", + Session.sessionValidator(db.sessions), + validator( + "json", + (value) => + value as { + sessionDescription: { + sdp: string; + }; + }, + ), + async (ctx) => { + const logger = loggerOf("API(POST /stop-muttering)"); + + const session = ctx.req.valid("cookie"); + const body = ctx.req.valid("json"); + + // Loads muttering state from the database + const mutteringState = await db.mutteringStates.Read(session.userId); + if (!mutteringState) { + logger.warn( + `User with ID ${session.userId} attempted to close track but is not muttering.`, + ); + return ctx.json({ error: "Not muttering" }, 409); + } + + await mgmg.stopMuttering({ + mogomogoSessionId: mutteringState.mogomogoSessionId, + sessionDescription: body.sessionDescription, + }); + logger.info( + `User with ID ${session.userId} closed track on muttering session with Mogomogo session ID ${mutteringState.mogomogoSessionId}.`, + ); + + return ctx.body(null, 204); + }, + ) + .post( + "/stop-tapping", + Session.sessionValidator(db.sessions), + validator( + "json", + (value) => + value as { + muttererSessionId: string; + sessionDescription: { + sdp: string; + }; + }, + ), + async (ctx) => { + const logger = loggerOf("API(POST /stop-tapping)"); + + const session = ctx.req.valid("cookie"); + const body = ctx.req.valid("json"); + + // Loads tapping state from the database + const tappingState = await db.tappingStates.Read(session.userId); + if (!tappingState) { + logger.warn( + `User with ID ${session.userId} attempted to stop tapping but is not tapping.`, + ); + return ctx.json({ error: "Not tapping" }, 409); + } + + await mgmg.stopTapping({ + mogomogoSessionId: tappingState.mogomogoSessionId, + muttererMogomogoSessionId: body.muttererSessionId, + sessionDescription: body.sessionDescription, + }); + logger.info( + `User with ID ${session.userId} stopped tapping session with Mogomogo session ID ${tappingState.mogomogoSessionId}.`, + ); + + return ctx.body(null, 204); + }, + ); + + return api; +}); + +const frontend = new Hono() // + .use("/*", serveStatic({ root: path.resolve(import.meta.dirname, "..", "frontend") + "/" })); + +const app = new Hono() // + .route("/api", api) + .route("/", frontend); + +const logger = loggerOf("Server"); +serve( + { + fetch: app.fetch, + port: 3000, + }, + ({ port }) => { + logger.info(`Server is running on http://localhost:${port}`); + }, +); + +export type BackendApiSchema = typeof api extends HonoBase ? TSchema : never; diff --git a/src/example/backend/logger.ts b/src/example/backend/logger.ts new file mode 100644 index 0000000..8a26b09 --- /dev/null +++ b/src/example/backend/logger.ts @@ -0,0 +1,65 @@ +type AsciiColorEscapes = `\x1b[${string}m`; + +/** ASCII color codes presets */ +const COLORS = { + cyan: "\x1b[36m", + yellow: "\x1b[33m", + red: "\x1b[31m", + gray: "\x1b[90m", + initial: "\x1b[0m", +} as const satisfies Record; +/** Generates a unique color for each ID */ +const withOwnColor = (() => { + const colorMap = new Map(); + + return (id: string) => { + const knownEscape = colorMap.get(id); + if (knownEscape) { + return knownEscape; + } + + // Generates a color based on the hash of the ID + const hash = Array.from(id).reduce((acc, char) => acc + char.charCodeAt(0), 0); + const colorCode = 16 + (hash % 216); // 16-231 are the 6x6x6 color palette in 256-color ANSI + const escape = `\x1b[38;5;${colorCode}m` as const; + + colorMap.set(id, escape); + return escape; + }; +})(); + +/** Wraps text with the given ASCII color escape */ +const colored = (color: AsciiColorEscapes, text: string) => `${color}${text}${COLORS.initial}`; + +/** + * @param context Log prefix tag text + * @returns {Logger} + */ +export const loggerOf = (context: string) => { + return { + info: (...args: unknown[]) => + console.info( + colored(COLORS.cyan, "[INFO]"), + `${colored(withOwnColor(context), context)}:`, + ...args, + ), + warn: (...args: unknown[]) => + console.warn( + colored(COLORS.yellow, "[WARN]"), + `${colored(withOwnColor(context), context)}:`, + ...args, + ), + error: (...args: unknown[]) => + console.error( + colored(COLORS.red, "[ERROR]"), + `${colored(withOwnColor(context), context)}:`, + ...args, + ), + debug: (...args: unknown[]) => + console.debug( + colored(COLORS.gray, "[DEBUG]"), + `${colored(withOwnColor(context), context)}:`, + ...args, + ), + }; +}; diff --git a/src/example/backend/session.ts b/src/example/backend/session.ts new file mode 100644 index 0000000..507a47a --- /dev/null +++ b/src/example/backend/session.ts @@ -0,0 +1,159 @@ +import type { Db } from "./db.ts"; +import type { Context, MiddlewareHandler, TypedResponse } from "hono"; + +import { randomUUID } from "node:crypto"; + +import { deleteCookie, getSignedCookie, setSignedCookie } from "hono/cookie"; +import { validator } from "hono/validator"; + +import { loggerOf } from "./logger.ts"; + +export namespace SessionClass { + export interface Static { + issueFor(userId: string, sessionsTable: Db.Tables["sessions"], ctx: Context): Promise; + tryVerify(ctx: Context, sessionsTable: Db.Tables["sessions"]): Promise; + sessionValidator( + sessionsTables: Db.Tables["sessions"], + ): MiddlewareHandler< + any, + string, + { out: { cookie: Instance } }, + TypedResponse + >; + deleteCookie(ctx: Context): void; + } + export interface Instance { + id: string; + expiresAt: Date; + userId: string; + destroy(sessionsTable: Db.Tables["sessions"], ctx: Context): Promise; + } + + export type Type = Static & (new (...args: any) => Instance); +} + +export const Session: SessionClass.Type = class Session implements SessionClass.Instance { + static readonly #SESSION_ID_COOKIE_NAME = "session_id"; + static readonly #SESSION_ID_COOKIE_SECRET_KEY = "honi"; + + id: string; + expiresAt: Date; + userId: string; + + constructor({ id, expiresAt, userId }: { id: string; expiresAt: Date; userId: string }) { + this.id = id; + this.expiresAt = expiresAt; + this.userId = userId; + } + + static async issueFor(userId: string, sessionsTable: Db.Tables["sessions"], ctx: Context) { + const id = randomUUID(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1_000); + + await sessionsTable.Create({ + sessionId: id, + expiresAt, + userId, + }); + + await setSignedCookie( + ctx, + Session.#SESSION_ID_COOKIE_NAME, + id, + Session.#SESSION_ID_COOKIE_SECRET_KEY, + { + // secure: true, + sameSite: "Strict", + path: "/", + httpOnly: true, + expires: expiresAt, + }, + ); + + return new Session({ + id, + expiresAt, + userId, + }); + } + + get isExpired() { + return this.expiresAt < new Date(); + } + + get shouldRefresh() { + // Less than 1 hour remaining + return this.expiresAt.getTime() - Date.now() < 60 * 60 * 1_000; + } + + static async #getIdFromCookie(ctx: Context): Promise { + const sessionId = await getSignedCookie( + ctx, + Session.#SESSION_ID_COOKIE_SECRET_KEY, + Session.#SESSION_ID_COOKIE_NAME, + ); + return sessionId || null; + } + + static async tryVerify( + ctx: Context, + sessionsTable: Db.Tables["sessions"], + ): Promise { + const logger = loggerOf("SessionImpl.tryVerify"); + + const sessionId = await Session.#getIdFromCookie(ctx); + if (!sessionId) { + logger.debug("No session ID found in cookies."); + return null; + } + + const sessionRecord = await sessionsTable.Read(sessionId); + if (!sessionRecord) { + logger.warn(`No session record found for ID ${sessionId}`, "Deleting cookie."); + Session.deleteCookie(ctx); + return null; + } + + const session = new Session({ id: sessionId, ...sessionRecord }); + if (session.isExpired) { + logger.debug(`Session with ID ${sessionId} is expired.`, "Destroying session."); + await session.destroy(sessionsTable, ctx); + logger.debug(`Session with ID ${sessionId} destroyed due to expiration.`, "Deleting cookie."); + return null; + } + if (session.shouldRefresh) { + logger.debug(`Session with ID ${sessionId} is nearing expiration.`, "Refreshing session."); + await session.destroy(sessionsTable, ctx); + logger.debug(`Session with ID ${sessionId} destroyed for refresh.`, "Issuing new session."); + const newSession = await Session.issueFor(session.userId, sessionsTable, ctx); + logger.debug(`New session with ID ${newSession.id} issued for user ID ${session.userId}.`); + return newSession; + } + + return session; + } + + static sessionValidator(sessionsTable: Db.Tables["sessions"]) { + return validator("cookie", async (_value, ctx) => { + const logger = loggerOf("SessionImpl.sessionValidator"); + + const session = await Session.tryVerify(ctx, sessionsTable); + if (!session) { + logger.debug("Session validation failed: No valid session found."); + return ctx.body(null, 401); + } + logger.info(`Session verified for user ID: ${session.userId}.`); + + return session; + }); + } + + static deleteCookie(ctx: Context) { + deleteCookie(ctx, Session.#SESSION_ID_COOKIE_NAME); + } + + async destroy(sessionsTable: Db.Tables["sessions"], ctx: Context) { + await sessionsTable.Delete(({ sessionId }) => sessionId === this.id); + Session.deleteCookie(ctx); + } +}; diff --git a/src/example/frontend/consts.js b/src/example/frontend/consts.js new file mode 100644 index 0000000..343d905 --- /dev/null +++ b/src/example/frontend/consts.js @@ -0,0 +1,21 @@ +// @ts-check +"use strict"; + +/** + * @typedef {import("../backend/index").BackendApiSchema} BackendApiSchema + * @typedef {import("hono/types").Endpoint} Endpoint + */ + +export const CONSTS = { + baseUrl: `${location.origin}/api`, + users: { + userA: { + name: "User A", + idToken: "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyQSJ9.", + }, + userB: { + name: "User B", + idToken: "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyQiJ9.", + }, + }, +}; diff --git a/src/example/frontend/index.html b/src/example/frontend/index.html new file mode 100644 index 0000000..515a69e --- /dev/null +++ b/src/example/frontend/index.html @@ -0,0 +1,18 @@ + + + + + + Mogomogo Test Client + + + + + +
+ + diff --git a/src/example/frontend/main.js b/src/example/frontend/main.js new file mode 100644 index 0000000..71e92d0 --- /dev/null +++ b/src/example/frontend/main.js @@ -0,0 +1,14 @@ +// @ts-check +"use strict"; + +import { getLoginState } from "./service.js"; +import { rootScene } from "./view.js"; + +const loginState = await getLoginState(); +if (loginState.isLoggedIn) { + rootScene.goto("loggedIn", { + user: loginState.user, + }); +} else { + rootScene.goto("loggedOut"); +} diff --git a/src/example/frontend/service.js b/src/example/frontend/service.js new file mode 100644 index 0000000..9475258 --- /dev/null +++ b/src/example/frontend/service.js @@ -0,0 +1,188 @@ +// @ts-check +"use strict"; + +import { callBackendApi } from "./util/backend-api.js"; +import { Result } from "./util/result.js"; + +export const getLoginState = async () => { + const loginState = await callBackendApi("/login-state", "GET", {}); + return loginState; +}; + +const createPeerConnection = () => + new RTCPeerConnection({ + iceServers: [ + { + urls: "stun:stun.cloudflare.com:3478", + }, + ], + bundlePolicy: "max-bundle", + }); + +/** + * @param {object} opts + * @param {HTMLVideoElement} opts.$video + */ +export const startMuttering = ({ $video }) => + Result.wrapAsyncProcess(async () => { + const media = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: true, + }); + $video.srcObject = media; + + // Start muttering + const peerConnection = createPeerConnection(); + const transceivers = media.getTracks().map((track) => + peerConnection.addTransceiver(track, { + direction: "sendonly", + }), + ); + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + + const { sessionDescription } = await callBackendApi("/start-muttering", "POST", { + body: { + sessionDescription: { + sdp: offer.sdp, + }, + tracks: transceivers.map(({ mid, sender }) => ({ + trackName: sender.track?.id, + mid, + })), + }, + }); + + // Setting up the ICE connection state handler needs to happen before + // setting the remote description to avoid race conditions. + const connected = new Promise((res, rej) => { + // timeout after 5s + setTimeout(rej, 5000); + const iceConnectionStateChangeHandler = () => { + if (peerConnection.iceConnectionState === "connected") { + peerConnection.removeEventListener( + "iceconnectionstatechange", + iceConnectionStateChangeHandler, + ); + res(undefined); + } + }; + peerConnection.addEventListener("iceconnectionstatechange", iceConnectionStateChangeHandler); + }); + // We take the answer we got from the Calls API and set it as the + // peer connection's remote description, which is an answer in this case. + await peerConnection.setRemoteDescription(new RTCSessionDescription(sessionDescription)); + + // Wait until the peer connection's iceConnectionState is "connected" + await connected; + + return { + sessionDescription, + }; + }); + +/** + * @param {object} opts + * @param {{ + * sdp: string; + * }} opts.sessionDescription + */ +export const stopMuttering = async ({ sessionDescription }) => + Result.wrapAsyncProcess(async () => { + await callBackendApi("/stop-muttering", "POST", { + body: { + sessionDescription: { + sdp: sessionDescription.sdp, + }, + }, + }); + }); + +/** + * @param {object} opts + * @param {string} opts.muttererSessionId + * @param {HTMLVideoElement} opts.$video + */ +export const startTapping = async ({ muttererSessionId, $video }) => + Result.wrapAsyncProcess(async () => { + const startTappingResult = await callBackendApi("/start-tapping", "POST", { + body: { + muttererSessionId, + }, + }); + + const remotePeerConnection = createPeerConnection(); + + const resolvingTracks = Promise.all( + startTappingResult.tracks.map( + ({ mid }) => + // This will resolve when the track for the corresponding mid is added. + /** @type {Promise} */ ( + new Promise((res, rej) => { + setTimeout(rej, 5000); + /** + * @param {RTCTrackEvent} event + */ + const handleTrack = ({ transceiver, track }) => { + if (transceiver.mid !== mid) return; + remotePeerConnection.removeEventListener("track", handleTrack); + res(track); + }; + remotePeerConnection.addEventListener("track", handleTrack); + }) + ), + ), + ); + + // Handle renegotiation, this will always be true when pulling tracks + if (startTappingResult.requiresImmediateRenegotiation) { + // We got a session description from the remote in the response, + // we need to set it as the remote description + await remotePeerConnection.setRemoteDescription(startTappingResult.sessionDescription); + // Create an answer + const remoteAnswer = await remotePeerConnection.createAnswer(); + // And set it as local description + await remotePeerConnection.setLocalDescription(remoteAnswer); + // Send our answer back to the Calls API + await callBackendApi("/renegotiate-tapping", "POST", { + body: { + sessionDescription: { + sdp: remoteAnswer.sdp, + }, + }, + }); + } + + // Now we wait for the tracks to resolve + const pulledTracks = await resolvingTracks; + + // Lastly, we set them in the remoteVideo to display + const remoteVideoStream = new MediaStream(); + $video.srcObject = remoteVideoStream; + for (const track of pulledTracks) { + remoteVideoStream.addTrack(track); + } + + return { + sessionDescription: remotePeerConnection.localDescription, + }; + }); + +/** + * @param {object} opts + * @param {{ + * sdp: string; + * }} opts.sessionDescription + * @param {string} opts.muttererSessionId + */ +export const stopTapping = async ({ sessionDescription, muttererSessionId }) => + Result.wrapAsyncProcess(async () => { + await callBackendApi("/stop-tapping", "POST", { + body: { + sessionDescription: { + sdp: sessionDescription.sdp, + }, + muttererSessionId, + }, + }); + }); diff --git a/src/example/frontend/util/backend-api.js b/src/example/frontend/util/backend-api.js new file mode 100644 index 0000000..5697c92 --- /dev/null +++ b/src/example/frontend/util/backend-api.js @@ -0,0 +1,74 @@ +// @ts-check +"use strict"; + +/** + * @typedef {import("../../backend/index").BackendApiSchema} BackendApiSchema + * @typedef {import("hono/types").Endpoint} Endpoint + * @typedef {import("hono/utils/http-status").SuccessStatusCode} SuccessStatusCode + */ +import { CONSTS } from "../consts.js"; +import { loggerOf } from "./logger.js"; + +/** + * Type helper + * @template {Endpoint} TEndpoint + * @template {number} TStatusCode + * @typedef {TEndpoint extends { status: infer TIStatus; output: infer TIOutput; } + * ? TIStatus extends TStatusCode + * ? TIOutput + * : never + * : never + * } ResponseSchemaOf + */ + +/** + * @template {keyof BackendApiSchema} TPath + * @template {keyof BackendApiSchema[TPath]} TMethod + * @param {TPath} path + * @param {( + * TMethod extends `$${infer TMethodWithOutPrefix}` + * ? Uppercase + * : never + * )} method + * @param {( + * {} + * & BackendApiSchema[TPath][TMethod] extends { + * input: { + * json: infer TBody; + * }; + * } ? { body: TBody } + * : { body?: never } + * )} opts + * @returns {Promise< + * ResponseSchemaOf< + * Extract< + * BackendApiSchema[TPath][TMethod], + * Endpoint + * >, + * SuccessStatusCode + * > + * >} + */ +export const callBackendApi = async (path, method, { body }) => { + const logger = loggerOf("callBackendApi"); + + const headers = { + "Content-Type": "application/json", + ...(body ? { "Content-Type": "application/json" } : {}), + }; + const response = await fetch(`${CONSTS.baseUrl}${path}`, { + method, + headers, + // oxlint-disable-next-line unicorn/no-invalid-fetch-options + body: body ? JSON.stringify(body) : null, + }); + if (!response.ok) { + logger.error("Failed status response:", response); + throw new Error(`API call failed: ${response.statusText}`); + } + + return response.status === 204 + ? // @ts-expect-error + null + : response.json(); +}; diff --git a/src/example/frontend/util/logger.js b/src/example/frontend/util/logger.js new file mode 100644 index 0000000..d69fa37 --- /dev/null +++ b/src/example/frontend/util/logger.js @@ -0,0 +1,43 @@ +// @ts-check +"use strict"; + +import { $console } from "../view.js"; + +/** + * @param {"debug" | "info" | "warn" | "error"} logLevel + * @param {string} message + */ +const print = (logLevel, message) => { + console[logLevel](message); + $console.textContent += `[${logLevel.toUpperCase()}] ${message}\n`; +}; + +/** + * @param {unknown[]} args + */ +const joinArgs = (args) => + args // + .map((arg) => String(arg)) + .join(" "); + +/** + * @param {string} context + */ +export const loggerOf = (context) => /** @type {const} */ ({ + /** + * @param {unknown[]} args + */ + debug: (...args) => print("debug", `${context}: ${joinArgs(args)}`), + /** + * @param {unknown[]} args + */ + info: (...args) => print("info", `${context}: ${joinArgs(args)}`), + /** + * @param {unknown[]} args + */ + warn: (...args) => print("warn", `${context}: ${joinArgs(args)}`), + /** + * @param {unknown[]} args + */ + error: (...args) => print("error", `${context}: ${joinArgs(args)}`), +}); diff --git a/src/example/frontend/util/result.js b/src/example/frontend/util/result.js new file mode 100644 index 0000000..1f958af --- /dev/null +++ b/src/example/frontend/util/result.js @@ -0,0 +1,112 @@ +// @ts-check +"use strict"; + +import { loggerOf } from "./logger.js"; + +const logger = loggerOf("Result"); + +/** + * @template TSuccessValue + * @template {'success' | 'error'} [TStatus='success' | 'error'] + */ +export class Result { + /** + * @readonly + * @type {TStatus} + */ + status; + + /** + * @readonly + * @type {TStatus extends "success" ? TSuccessValue : unknown} + */ + value; + + /** + * @template TSuccessValue + * @param {TSuccessValue} value + * @returns {Result} + */ + static success(value) { + return new Result("success", value); + } + + /** + * @template TErrorValue + * @param {TErrorValue} value + * @returns {Result} + */ + static error(value) { + return new Result("error", value); + } + + /** + * @template TResult + * @param {() => Promise} process + * @returns {Promise>} + */ + static wrapAsyncProcess = (process) => + process() + .then((data) => new Result("success", data)) + .catch((error) => new Result("error", error)); + + /** + * @param {TStatus} status + * @param {TStatus extends "success" ? TSuccessValue : unknown} value + */ + constructor(status, value) { + this.status = status; + this.value = value; + } + + /** + * @template TSuccessMapped + * @template TErrorMapped + * @param {{ + * success: (value: TSuccessValue) => TSuccessMapped, + * error: (value: unknown) => TErrorMapped, + * }} handlers + * @returns {Result} + */ + map(handlers) { + try { + switch (this.status) { + case "success": { + const mappedValue = handlers.success(/** @type {TSuccessValue} */ (this.value)); + return Result.success(mappedValue); + } + case "error": { + const mappedError = handlers.error(/** @type {unknown} */ (this.value)); + // @ts-expect-error + return Result.error(mappedError); + } + } + } catch (mappingError) { + logger.error("Error occurred while mapping Result:", mappingError); + // @ts-expect-error + return Result.error(mappingError); + } + } + + unwrapOrThrow() { + switch (this.status) { + case "success": + return /** @type {TSuccessValue} */ (this.value); + case "error": + logger.error("Error occurred while unwrapping Result:", this.value); + throw this.value; + } + } + + /** + * @template TDefaultValue + * @param {TDefaultValue} defaultValue + */ + unwrapOr(defaultValue) { + const mapped = this.map({ + success: (value) => value, + error: () => defaultValue, + }); + return mapped.unwrapOrThrow(); + } +} diff --git a/src/example/frontend/util/view/dom.js b/src/example/frontend/util/view/dom.js new file mode 100644 index 0000000..953af50 --- /dev/null +++ b/src/example/frontend/util/view/dom.js @@ -0,0 +1,122 @@ +// @ts-check +"use strict"; + +/** + * @template {HTMLElement} [THTMLElement=HTMLElement] + * @typedef {( + * & THTMLElement + * & { + * ( + * element: TChildElement, + * block?: (builder: $BuilderFromElement) => void, + * ): TChildElement; + * ( + * tagName: TChildTagName, + * block?: (builder: $BuilderFromTag) => void, + * ): HTMLElementTagNameMap[TChildTagName]; + * } + * )} $BuilderFromElement + */ +/** + * @template {keyof HTMLElementTagNameMap} [TTagName=keyof HTMLElementTagNameMap] + * @typedef {( + * & HTMLElementTagNameMap[TTagName] + * & { + * ( + * element: TChildElement, + * block?: (builder: $BuilderFromElement) => void, + * ): TChildElement; + * ( + * tagName: TChildTagName, + * block?: (builder: $BuilderFromTag) => void, + * ): HTMLElementTagNameMap[TChildTagName]; + * } + * )} $BuilderFromTag + */ + +/** + * Utility type to get tag name from element type + * @template {HTMLElement} TElement + * @typedef {{ [K in keyof HTMLElementTagNameMap]: HTMLElementTagNameMap[K] extends TElement ? K : never }[keyof HTMLElementTagNameMap]} TagNameByElement + */ +/** + * DOM element builder + * @type {{ + * ( + * element: TElement, + * block?: { + * (builder: $BuilderFromElement): void; + * (builder: $BuilderFromTag>): void; + * }, + * ): TElement; + * ( + * tagName: TTagName, + * block?: { + * (builder: $BuilderFromElement): void; + * (builder: $BuilderFromTag): void; + * }, + * ): HTMLElementTagNameMap[TTagName]; + * }} + * @param {HTMLElement | keyof HTMLElementTagNameMap} elementOrTagName + * @param {(builder: $BuilderFromElement | $BuilderFromTag) => void} [block] + * @returns {HTMLElement} + */ +export const $ = (elementOrTagName, block) => { + const element = + elementOrTagName instanceof HTMLElement + ? elementOrTagName + : // Builds element if elementOrTagName is tag name + document.createElement(elementOrTagName); + + /** @type {HTMLElement[]} */ + const children = []; + /** + * @param {HTMLElement | keyof HTMLElementTagNameMap} childElementOrTagName + * @param {(builder: $BuilderFromElement | $BuilderFromTag) => void} [childBlock] + * @returns {HTMLElement} + */ + const childBuilder = (childElementOrTagName, childBlock) => { + const childElement = + childElementOrTagName instanceof HTMLElement + ? childElementOrTagName + : // Builds element with childBlock if childElementOrTagName is tag name + $(childElementOrTagName, childBlock); + children.push(childElement); + return childElement; + }; + + // Merge childBuilder and element + const elementAndChildBuilder = + /** @type {$BuilderFromTag | $BuilderFromElement} */ ( + new Proxy(childBuilder, { + get(_target, prop) { + const value = element[/** @type {keyof HTMLElement} */ (prop)]; + + if (typeof value === "function") return value.bind(element); + return value; + }, + set(_target, prop, value) { + element[ + /** + * Pick writable properties + * @type {( + * NonNullable< + * { + * [K in keyof HTMLElement]: + * Pick extends { readonly [P in K]: unknown } + * ? never + * : K + * }[keyof HTMLElement] + * > + * )} + */ (prop) + ] = value; + return true; + }, + }) + ); + block?.(elementAndChildBuilder); + element.append(...children); + + return element; +}; diff --git a/src/example/frontend/util/view/scene.js b/src/example/frontend/util/view/scene.js new file mode 100644 index 0000000..6195409 --- /dev/null +++ b/src/example/frontend/util/view/scene.js @@ -0,0 +1,61 @@ +// @ts-check +"use strict"; + +/** + * @template {Record} TArgs + */ +export class Scene { + /** + * @readonly + */ + #parentNode; + + /** + * @readonly + */ + #states; + + /** + * @type {null | Element} + */ + #currentElement = null; + + /** + * @param {Node} parentNode + * @param {{ [K in keyof TArgs]: (opts: { scene: Scene, args: TArgs[K] }) => Element }} states + */ + constructor(parentNode, states) { + this.#parentNode = parentNode; + this.#states = states; + } + + /** + * @template {{ [K in keyof TArgs]: TArgs[K] extends undefined ? K : never }[keyof TArgs]} TState + * @overload + * @param {TState} state + * @returns {void} + */ + /** + * @template {keyof TArgs} TState + * @overload + * @param {TState} state + * @param {TArgs[TState]} stateArgs + * @returns {void} + */ + /** + * @param {keyof TArgs} state + * @param {TArgs[keyof TArgs]} [args] + * @returns {void} + */ + goto(state, args) { + this.#currentElement?.remove(); + this.#currentElement = this.#states[state]( + // @ts-expect-error + { + scene: this, + args, + }, + ); + this.#parentNode.appendChild(this.#currentElement); + } +} diff --git a/src/example/frontend/view.js b/src/example/frontend/view.js new file mode 100644 index 0000000..ec6d65f --- /dev/null +++ b/src/example/frontend/view.js @@ -0,0 +1,246 @@ +// @ts-check +"use strict"; + +import { CONSTS } from "./consts.js"; +import { startMuttering, startTapping, stopMuttering, stopTapping } from "./service.js"; +import { callBackendApi } from "./util/backend-api.js"; +import { loggerOf } from "./util/logger.js"; +import { $ } from "./util/view/dom.js"; +import { Scene } from "./util/view/scene.js"; + +const $main = $("main"); +export const $console = $("pre"); +$(/** @type {HTMLDivElement} */ (document.getElementById("container")), ($) => { + $("header", ($) => { + $("h1", ($) => { + $.textContent = "Mogomogo Test Client"; + }); + }); + + $($main); + + $("section", ($) => { + $("h2", ($) => { + $.textContent = "Console"; + }); + $($console); + }); +}); + +/** + * @satisfies {Record HTMLElement>} + */ +const components = { + /** + * @param {{ onClicked: () => void }} opts + */ + LoggedOutButton: ({ onClicked }) => + $("button", ($) => { + $.textContent = "Log out"; + $.addEventListener("click", async () => { + await callBackendApi("/logout", "POST", {}); + onClicked(); + }); + }), +}; + +/** + * @type {Scene<{ + * loggedOut: undefined; + * loggedIn: { + * user: { + * id: string; + * name: string; + * }, + * }; + * }>} + */ +export const rootScene = new Scene($main, { + loggedOut: ({ scene }) => + $("div", ($) => { + $("h2", ($) => { + $.textContent = "Welcome to the Calls API demo app"; + }); + const USER_SELECT_ID = "user-select"; + $("label", ($) => { + $.textContent = "Login as:"; + $.htmlFor = USER_SELECT_ID; + }); + const $userSelect = $("select", ($) => { + $.id = USER_SELECT_ID; + for (const [key, user] of Object.entries(CONSTS.users)) { + $("option", ($) => { + $.value = key; + $.textContent = user.name; + }); + } + }); + $("button", ($) => { + $.textContent = "Log in"; + $.addEventListener("click", async () => { + const selectedUser = /** @type {keyof typeof CONSTS.users} */ ($userSelect.value); + const { user } = await callBackendApi("/login", "POST", { + body: { + idToken: CONSTS.users[selectedUser].idToken, + }, + }); + scene.goto("loggedIn", { + user, + }); + }); + }); + }), + + loggedIn: ({ scene, args: { user } }) => + $("div", ($) => { + $("div", ($) => { + $("h2", ($) => { + $.textContent = `You are ${user.name}`; + }); + $( + components.LoggedOutButton({ + onClicked: () => scene.goto("loggedOut"), + }), + ); + }); + const $video = $("video", ($) => { + $.id = "my-video"; + $.autoplay = true; + $.muted = true; + $.playsInline = true; + }); + + /** + * @type {Scene<{ + * stopped: undefined; + * started: { + * sessionDescription: RTCSessionDescriptionInit; + * }; + * }>} + */ + const mutteringScene = new Scene($, { + stopped: ({ scene }) => + $("button", ($) => { + $.textContent = "Start muttering"; + $.addEventListener("click", async () => { + const logger = loggerOf("startMutteringHandler"); + + $.textContent = "Starting muttering..."; + (await startMuttering({ $video })) + .map({ + success: ({ sessionDescription }) => { + scene.goto("started", { sessionDescription }); + }, + error: (value) => { + logger.error(value); + alert("Failed to start muttering.\nPlease check the console for details."); + $.textContent = "Start muttering"; + }, + }) + .unwrapOrThrow(); + }); + }), + started: ({ scene, args: { sessionDescription } }) => + $("button", ($) => { + const logger = loggerOf("stopMutteringHandler"); + + $.textContent = "Stop muttering"; + $.addEventListener("click", async () => { + (await stopMuttering({ sessionDescription })) + .map({ + success: () => { + scene.goto("stopped"); + }, + error: (value) => { + logger.error(value); + alert("Failed to stop muttering.\nPlease check the console for details."); + $.textContent = "Stop muttering"; + }, + }) + .unwrapOrThrow(); + }); + }), + }); + mutteringScene.goto("stopped"); + + $("div", ($) => { + const MUTTERING_LIST_ID = "muttering-list"; + $("label", ($) => { + $.textContent = "Mutterings:"; + $.htmlFor = MUTTERING_LIST_ID; + }); + $("select", async ($) => { + $.id = MUTTERING_LIST_ID; + void callBackendApi("/mutterings", "GET", {}).then((mutterings) => { + const options = mutterings.map((muttering) => + $("option", ($) => { + $.value = muttering.mogomogoSessionId; + $.textContent = `${muttering.userId}`; + }), + ); + // NOTE: This is an asynchronous context + // the parent element has already been rendered, and we need to call append() ourselves + $.append(...options); + }); + }); + }); + + /** + * @type {Scene<{ + * stopped: undefined; + * started: { + * sessionDescription: RTCSessionDescriptionInit; + * muttererSessionId: string; + * }; + * }>} + */ + const tappingScene = new Scene($, { + stopped: ({ scene }) => + $("button", ($) => { + $.textContent = "Start tapping"; + $.addEventListener("click", async () => { + const logger = loggerOf("startTappingHandler"); + + $.textContent = "Starting tapping..."; + const mutteringSelect = /** @type {HTMLSelectElement} */ ( + document.getElementById("muttering-list") + ); + const muttererSessionId = mutteringSelect.value; + (await startTapping({ muttererSessionId, $video })) + .map({ + success: ({ sessionDescription }) => { + scene.goto("started", { sessionDescription, muttererSessionId }); + }, + error: (value) => { + logger.error(value); + alert("Failed to start tapping.\nPlease check the console for details."); + $.textContent = "Start tapping"; + }, + }) + .unwrapOrThrow(); + }); + }), + started: ({ scene, args: { sessionDescription, muttererSessionId } }) => + $("button", ($) => { + const logger = loggerOf("stopTappingHandler"); + + $.textContent = "Stop tapping"; + $.addEventListener("click", async () => { + (await stopTapping({ sessionDescription, muttererSessionId })) + .map({ + success: () => { + scene.goto("stopped"); + }, + error: (value) => { + logger.error(value); + alert("Failed to stop tapping.\nPlease check the console for details."); + $.textContent = "Stop tapping"; + }, + }) + .unwrapOrThrow(); + }); + }), + }); + tappingScene.goto("stopped"); + }), +}); diff --git a/src/example/serve.sh b/src/example/serve.sh new file mode 100755 index 0000000..e771528 --- /dev/null +++ b/src/example/serve.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' + +script_dir="$(realpath "$(dirname "${BASH_SOURCE[0]}")")" + +node "$script_dir/backend/index.ts"