diff --git a/.changeset/config.json b/.changeset/config.json index e8cc3ed..12e1987 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -14,7 +14,8 @@ "relayer-ws", "bundler-ws", "relayer-get-balance", - "bundler-get-balance" + "bundler-get-balance", + "browser-ws" ], "linked": [], "updateInternalDependencies": "patch" diff --git a/.changeset/tricky-carrots-sniff.md b/.changeset/tricky-carrots-sniff.md new file mode 100644 index 0000000..78d6de7 --- /dev/null +++ b/.changeset/tricky-carrots-sniff.md @@ -0,0 +1,5 @@ +--- +"@gelatocloud/gasless": patch +--- + +fix: ws auth for browsers diff --git a/examples/account/ws/src/global.ts b/examples/account/ws/src/global.ts index 856cfad..1f3b198 100644 --- a/examples/account/ws/src/global.ts +++ b/examples/account/ws/src/global.ts @@ -60,7 +60,7 @@ const main = async () => { console.log(`[reverted] Transaction ${data.id} reverted on block ${data.receipt.blockNumber}`) ); - const taskId = await relayer.sendTransaction({ + const id = await relayer.sendTransaction({ calls: [ { data: '0xd09de08a', @@ -69,7 +69,7 @@ const main = async () => { ] }); - console.log(`Sent transaction ${taskId}`); + console.log(`Sent transaction ${id}`); // Graceful shutdown process.on('SIGINT', async () => { diff --git a/examples/browser-ws/.env.example b/examples/browser-ws/.env.example new file mode 100644 index 0000000..2b09530 --- /dev/null +++ b/examples/browser-ws/.env.example @@ -0,0 +1 @@ +VITE_GELATO_API_KEY="Get your api key at https://app.gelato.cloud/" diff --git a/examples/browser-ws/index.html b/examples/browser-ws/index.html new file mode 100644 index 0000000..f662690 --- /dev/null +++ b/examples/browser-ws/index.html @@ -0,0 +1,143 @@ + + + + + + Gasless WebSocket Test + + + +

Gasless WebSocket Test

+
+ + + + +
+
+ + Disconnected +
+
+ + + diff --git a/examples/browser-ws/package.json b/examples/browser-ws/package.json new file mode 100644 index 0000000..18367e2 --- /dev/null +++ b/examples/browser-ws/package.json @@ -0,0 +1,16 @@ +{ + "name": "browser-ws", + "private": true, + "version": "0.0.1", + "scripts": { + "dev": "vite" + }, + "dependencies": { + "@gelatocloud/gasless": "workspace:*", + "viem": "catalog:" + }, + "devDependencies": { + "typescript": "catalog:", + "vite": "^6.3.5" + } +} diff --git a/examples/browser-ws/src/main.ts b/examples/browser-ws/src/main.ts new file mode 100644 index 0000000..1c11428 --- /dev/null +++ b/examples/browser-ws/src/main.ts @@ -0,0 +1,128 @@ +import type { GelatoEvmRelayerClient } from '@gelatocloud/gasless'; +import { createGelatoEvmRelayerClient } from '@gelatocloud/gasless'; +import { baseSepolia } from 'viem/chains'; + +const chain = baseSepolia; +const TARGET_CONTRACT = '0xE27C1359cf02B49acC6474311Bd79d1f10b1f8De'; +const INCREMENT_CALLDATA = '0xd09de08a'; + +// Pre-fill API key from env (Vite exposes VITE_-prefixed vars) +const envApiKey = import.meta.env['VITE_GELATO_API_KEY'] as string | undefined; + +// DOM elements +const apiKeyInput = document.getElementById('apiKey') as HTMLInputElement; +const connectBtn = document.getElementById('connectBtn') as HTMLButtonElement; +const sendBtn = document.getElementById('sendBtn') as HTMLButtonElement; +const disconnectBtn = document.getElementById('disconnectBtn') as HTMLButtonElement; +const statusDot = document.getElementById('statusDot') as HTMLSpanElement; +const statusText = document.getElementById('statusText') as HTMLSpanElement; +const logEl = document.getElementById('log') as HTMLDivElement; + +if (envApiKey) apiKeyInput.value = envApiKey; + +let relayer: GelatoEvmRelayerClient | null = null; +let subscriptionId: string | null = null; + +function log( + message: string, + type: 'info' | 'submitted' | 'success' | 'rejected' | 'reverted' | 'error' = 'info' +) { + const entry = document.createElement('div'); + entry.className = `log-entry ${type}`; + const time = new Date().toLocaleTimeString(); + entry.innerHTML = `${time}${message}`; + logEl.appendChild(entry); + logEl.scrollTop = logEl.scrollHeight; +} + +function setConnected(connected: boolean) { + connectBtn.disabled = connected; + apiKeyInput.disabled = connected; + sendBtn.disabled = !connected; + disconnectBtn.disabled = !connected; + statusDot.className = connected ? 'dot connected' : 'dot'; + statusText.textContent = connected ? 'Connected' : 'Disconnected'; +} + +connectBtn.addEventListener('click', async () => { + const apiKey = apiKeyInput.value.trim(); + if (!apiKey) { + log('Please enter an API key', 'error'); + return; + } + + try { + log('Creating relayer client...'); + + relayer = createGelatoEvmRelayerClient({ + apiKey, + testnet: chain.testnet + }); + + log('Subscribing to transaction updates...'); + const subscription = await relayer.ws.subscribe(); + subscriptionId = subscription.subscriptionId; + + subscription.on('submitted', (data) => + log(`[submitted] Transaction ${data.id} submitted with hash: ${data.hash}`, 'submitted') + ); + subscription.on('success', (data) => + log( + `[success] Transaction ${data.id} included in block ${data.receipt.blockNumber}`, + 'success' + ) + ); + subscription.on('rejected', (data) => + log(`[rejected] Transaction ${data.id} was rejected: ${data.message}`, 'rejected') + ); + subscription.on('reverted', (data) => + log( + `[reverted] Transaction ${data.id} reverted on block ${data.receipt.blockNumber}`, + 'reverted' + ) + ); + + setConnected(true); + log('Connected! Listening for transaction updates...'); + } catch (err) { + log(`Connection failed: ${err instanceof Error ? err.message : err}`, 'error'); + } +}); + +sendBtn.addEventListener('click', async () => { + if (!relayer) return; + + try { + sendBtn.disabled = true; + log(`Sending increment() to ${TARGET_CONTRACT} on ${chain.name}...`); + + const id = await relayer.sendTransaction({ + chainId: chain.id, + data: INCREMENT_CALLDATA, + to: TARGET_CONTRACT + }); + + log(`Transaction sent: ${id}`); + sendBtn.disabled = false; + } catch (err) { + log(`Send failed: ${err instanceof Error ? err.message : err}`, 'error'); + sendBtn.disabled = false; + } +}); + +disconnectBtn.addEventListener('click', async () => { + if (!relayer) return; + + try { + if (subscriptionId) { + await relayer.ws.unsubscribe(subscriptionId); + subscriptionId = null; + } + relayer.ws.disconnect(); + relayer = null; + setConnected(false); + log('Disconnected.'); + } catch (err) { + log(`Disconnect error: ${err instanceof Error ? err.message : err}`, 'error'); + } +}); diff --git a/examples/browser-ws/src/vite-env.d.ts b/examples/browser-ws/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/examples/browser-ws/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/browser-ws/tsconfig.json b/examples/browser-ws/tsconfig.json new file mode 100644 index 0000000..5c6637f --- /dev/null +++ b/examples/browser-ws/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["DOM", "ESNext"] + } +} diff --git a/examples/browser-ws/vite.config.ts b/examples/browser-ws/vite.config.ts new file mode 100644 index 0000000..f7aecab --- /dev/null +++ b/examples/browser-ws/vite.config.ts @@ -0,0 +1,10 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + resolve: { + alias: { + '@gelatocloud/gasless': resolve(__dirname, '../../src/index.ts') + } + } +}); diff --git a/examples/relayer/sponsored/README.md b/examples/relayer/sponsored/README.md index e62b806..812cd98 100644 --- a/examples/relayer/sponsored/README.md +++ b/examples/relayer/sponsored/README.md @@ -89,6 +89,6 @@ Polls until the transaction reaches a final state (Success, Rejected, or Reverte | Concept | Description | |---------|-------------| | `sponsored()` | Gelato pays gas fees | -| `sendTransaction` | Async - returns immediately with task ID | +| `sendTransaction` | Async - returns immediately with ID | | `waitForStatus` | Blocks until transaction is finalized | | `StatusCode.Success` | Transaction successfully included on-chain | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36e6838..638f844 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,22 @@ importers: specifier: 'catalog:' version: 5.9.3 + examples/browser-ws: + dependencies: + '@gelatocloud/gasless': + specifier: workspace:* + version: link:../../src + viem: + specifier: 'catalog:' + version: 2.39.3(typescript@5.9.3)(zod@4.1.13) + devDependencies: + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.1(@types/node@25.2.3)(tsx@4.21.0) + examples/bundler/gelato/sponsored: dependencies: '@gelatocloud/gasless': @@ -403,156 +419,312 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.1': resolution: {integrity: sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.1': resolution: {integrity: sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.1': resolution: {integrity: sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.1': resolution: {integrity: sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.1': resolution: {integrity: sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.1': resolution: {integrity: sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.1': resolution: {integrity: sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.1': resolution: {integrity: sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.1': resolution: {integrity: sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.1': resolution: {integrity: sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.1': resolution: {integrity: sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.1': resolution: {integrity: sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.1': resolution: {integrity: sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.1': resolution: {integrity: sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.1': resolution: {integrity: sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.1': resolution: {integrity: sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.1': resolution: {integrity: sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.27.1': resolution: {integrity: sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.1': resolution: {integrity: sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.27.1': resolution: {integrity: sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.1': resolution: {integrity: sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.27.1': resolution: {integrity: sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.1': resolution: {integrity: sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.1': resolution: {integrity: sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.1': resolution: {integrity: sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.1': resolution: {integrity: sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==} engines: {node: '>=18'} @@ -894,6 +1066,11 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.27.1: resolution: {integrity: sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==} engines: {node: '>=18'} @@ -1351,19 +1528,19 @@ packages: typescript: optional: true - vite@7.3.1: - resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} - engines: {node: ^20.19.0 || >=22.12.0} + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 jiti: '>=1.21.0' - less: ^4.0.0 + less: '*' lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' terser: ^5.16.0 tsx: ^4.8.1 yaml: ^2.4.2 @@ -1662,81 +1839,159 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/aix-ppc64@0.27.1': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm64@0.27.1': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-arm@0.27.1': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/android-x64@0.27.1': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.27.1': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.27.1': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.27.1': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.27.1': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.27.1': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-arm@0.27.1': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-ia32@0.27.1': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-loong64@0.27.1': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.27.1': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.27.1': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.27.1': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-s390x@0.27.1': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + '@esbuild/linux-x64@0.27.1': optional: true + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-arm64@0.27.1': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.27.1': optional: true + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-arm64@0.27.1': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.27.1': optional: true + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/openharmony-arm64@0.27.1': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.27.1': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.27.1': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-ia32@0.27.1': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@esbuild/win32-x64@0.27.1': optional: true @@ -1924,13 +2179,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0))': + '@vitest/mocker@4.0.18(vite@6.4.1(@types/node@25.2.3)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0) + vite: 6.4.1(@types/node@25.2.3)(tsx@4.21.0) '@vitest/pretty-format@4.0.18': dependencies: @@ -2021,6 +2276,35 @@ snapshots: es-module-lexer@1.7.0: {} + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + esbuild@0.27.1: optionalDependencies: '@esbuild/aix-ppc64': 0.27.1 @@ -2464,9 +2748,9 @@ snapshots: - utf-8-validate - zod - vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0): + vite@6.4.1(@types/node@25.2.3)(tsx@4.21.0): dependencies: - esbuild: 0.27.1 + esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -2480,7 +2764,7 @@ snapshots: vitest@4.0.18(@types/node@25.2.3)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0)) + '@vitest/mocker': 4.0.18(vite@6.4.1(@types/node@25.2.3)(tsx@4.21.0)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -2497,7 +2781,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0) + vite: 6.4.1(@types/node@25.2.3)(tsx@4.21.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.3 diff --git a/src/version.ts b/src/version.ts index fe9f16c..30ae1a5 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const version = '0.0.8'; +export const version = '0.0.9'; diff --git a/src/ws/env.ts b/src/ws/env.ts new file mode 100644 index 0000000..f4b060b --- /dev/null +++ b/src/ws/env.ts @@ -0,0 +1 @@ +export const isBrowser = typeof window !== 'undefined' && typeof window.document !== 'undefined'; diff --git a/src/ws/manager.test.ts b/src/ws/manager.test.ts index c8e7bd1..241d1f8 100644 --- a/src/ws/manager.test.ts +++ b/src/ws/manager.test.ts @@ -5,6 +5,7 @@ import { createWebSocketManager } from './manager.js'; // Mock state const mockSend = vi.fn(); const mockClose = vi.fn(); +const mockConstructorArgs: unknown[][] = []; let mockOnOpen: (() => void) | undefined; let mockOnMessage: ((event: { data: string }) => void) | undefined; @@ -15,7 +16,8 @@ vi.mock('isomorphic-ws', () => { send = mockSend; close = mockClose; - constructor() { + constructor(...args: unknown[]) { + mockConstructorArgs.push(args); setTimeout(() => mockOnOpen?.(), 0); } @@ -37,6 +39,10 @@ vi.mock('isomorphic-ws', () => { return { default: MockWebSocket }; }); +vi.mock('./env.js', () => ({ + isBrowser: false +})); + /** Helper to connect a manager and advance timers */ async function connectManager(manager: ReturnType) { const p = manager.connect(); @@ -53,6 +59,7 @@ describe('createWebSocketManager', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); + mockConstructorArgs.length = 0; mockOnOpen = undefined; mockOnMessage = undefined; }); @@ -84,6 +91,128 @@ describe('createWebSocketManager', () => { }); }); + describe('connect authentication', () => { + it('passes Authorization header in Node.js', async () => { + const manager = createWebSocketManager(config); + await connectManager(manager); + + const [url, opts] = mockConstructorArgs[0] as [ + string, + { headers: { Authorization: string } } + ]; + expect(url).toBe('wss://api.gelato.cloud/ws'); + expect(opts).toEqual({ headers: { Authorization: 'Bearer test-key' } }); + }); + + it('does not include apiKey in URL in Node.js', async () => { + const manager = createWebSocketManager(config); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + expect(url).not.toContain('apiKey'); + }); + + it('passes apiKey as query parameter in browser', async () => { + const env = await import('./env.js'); + vi.mocked(env).isBrowser = true; + + const manager = createWebSocketManager(config); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + const parsed = new URL(url); + expect(parsed.searchParams.get('apiKey')).toBe('test-key'); + expect(mockConstructorArgs[0]).toHaveLength(1); + + vi.mocked(env).isBrowser = false; + }); + + it('URL-encodes special characters in apiKey for browser', async () => { + const env = await import('./env.js'); + vi.mocked(env).isBrowser = true; + + const specialKey = 'key+with/special=chars&more'; + const manager = createWebSocketManager({ ...config, apiKey: specialKey }); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + const parsed = new URL(url); + expect(parsed.searchParams.get('apiKey')).toBe(specialKey); + expect(url).not.toContain('+with/'); + + vi.mocked(env).isBrowser = false; + }); + + it('converts https to wss in browser', async () => { + const env = await import('./env.js'); + vi.mocked(env).isBrowser = true; + + const manager = createWebSocketManager(config); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + expect(url).toMatch(/^wss:\/\//); + + vi.mocked(env).isBrowser = false; + }); + + it('converts http to ws in browser', async () => { + const env = await import('./env.js'); + vi.mocked(env).isBrowser = true; + + const manager = createWebSocketManager({ ...config, baseUrl: 'http://localhost:3000' }); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + expect(url).toMatch(/^ws:\/\/localhost:3000\/ws/); + + vi.mocked(env).isBrowser = false; + }); + + it('does not pass Authorization header in browser', async () => { + const env = await import('./env.js'); + vi.mocked(env).isBrowser = true; + + const manager = createWebSocketManager(config); + await connectManager(manager); + + expect(mockConstructorArgs[0]).toHaveLength(1); + + vi.mocked(env).isBrowser = false; + }); + + it('preserves base URL path in browser', async () => { + const env = await import('./env.js'); + vi.mocked(env).isBrowser = true; + + const manager = createWebSocketManager({ ...config, baseUrl: 'https://api.gelato.cloud' }); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + const parsed = new URL(url); + expect(parsed.pathname).toBe('/ws'); + expect(parsed.hostname).toBe('api.gelato.cloud'); + + vi.mocked(env).isBrowser = false; + }); + + it('converts https to wss', async () => { + const manager = createWebSocketManager(config); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + expect(url).toMatch(/^wss:\/\//); + }); + + it('converts http to ws', async () => { + const manager = createWebSocketManager({ ...config, baseUrl: 'http://localhost:3000' }); + await connectManager(manager); + + const [url] = mockConstructorArgs[0] as [string]; + expect(url).toMatch(/^ws:\/\/localhost:3000\/ws/); + }); + }); + describe('disconnect', () => { it('closes connection and clears state', async () => { const manager = createWebSocketManager(config); diff --git a/src/ws/manager.ts b/src/ws/manager.ts index 8faec36..4347c1f 100644 --- a/src/ws/manager.ts +++ b/src/ws/manager.ts @@ -1,5 +1,6 @@ import WebSocketImpl from 'isomorphic-ws'; import { statusSchema } from '../types/schema.js'; +import { isBrowser } from './env.js'; import { WebSocketConnectionError, WebSocketSubscriptionError } from './errors.js'; import { createSubscription } from './subscription.js'; import type { @@ -197,12 +198,17 @@ export function createWebSocketManager( connectionPromise = new Promise((resolve, reject) => { const wsUrl = `${fullConfig.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://')}/ws`; - // Create WebSocket with authorization header - ws = new WebSocketImpl(wsUrl, { - headers: { - Authorization: `Bearer ${fullConfig.apiKey}` - } - }); + if (isBrowser) { + const url = new URL(wsUrl); + url.searchParams.set('apiKey', fullConfig.apiKey); + ws = new WebSocketImpl(url.toString()); + } else { + ws = new WebSocketImpl(wsUrl, { + headers: { + Authorization: `Bearer ${fullConfig.apiKey}` + } + }); + } ws.onopen = () => { reconnectAttempts = 0;