Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# @goodwidget/core

Runtime helpers for GoodWidget providers, host detection, wallet context, and viem client setup.

## viem fallback clients

`createViemFallbackClient` wraps viem `createPublicClient` and `createWalletClient` so callers get a fallback HTTP transport built from cached Chainlist RPC URLs for the requested chain.

The helper accepts a small key-value storage adapter. On initialization it reads cached RPC metadata from storage. If the cache is missing or older than 1 day, it starts a background refresh from Chainlist. Client creation waits for that first refresh only when no cached RPCs are available for the requested chain.

Caller-provided `fallbackRpcs` are always tried first, then the chain's own default RPC URLs, and finally any cached Chainlist URLs. The generated viem fallback transport enables ranking and a single retry so unhealthy discovered RPCs can be deprioritized during use instead of being trusted purely by cache order.

```ts
import { createViemFallbackClient } from '@goodwidget/core/viemFallbackClient'
import { celo } from 'viem/chains'

const viemClient = createViemFallbackClient(localStorage, {
onError(error) {
console.warn('RPC refresh failed', error)
},
})

const publicClient = await viemClient.createPublicClient({
chain: celo,
fallbackRpcs: ['https://forno.celo.org'],
})
```

The same helper can create wallet clients. Any viem wallet client option, such as `account`, can be passed through.

```ts
import { privateKeyToAccount } from 'viem/accounts'
import { celo } from 'viem/chains'

const account = privateKeyToAccount('0x...')

const walletClient = await viemClient.createWalletClient({
account,
chain: celo,
fallbackRpcs: ['https://forno.celo.org'],
})
```

If you pass `transport`, the wrapper leaves it unchanged and does not create a fallback transport for that client.

```ts
import { http } from 'viem'
import { celo } from 'viem/chains'

const client = await viemClient.createPublicClient({
chain: celo,
transport: http('https://forno.celo.org'),
})
```

## Storage adapters

The adapter supports browser-style storage and Worker-style KV storage.

```ts
createViemFallbackClient(localStorage)
```

```ts
createViemFallbackClient({
get: (key) => env.KV.get(key, 'json'),
put: (key, value) => env.KV.put(key, value),
})
```

Cached values use this shape:

```ts
type ViemRpcCacheEntry = {
fetchedAt: string
rpcs: Array<{
chainId: number
rpcs: string[]
}>
}
```

Only HTTPS RPC URLs are used. URLs containing Chainlist template placeholders are ignored.

## Options

```ts
createViemFallbackClient(storage, {
cacheKey: 'goodwidget:viem-rpcs',
chainlistRpcsUrl: 'https://chainlist.org/rpcs.json',
refreshIntervalMs: 24 * 60 * 60 * 1000,
fetchTimeoutMs: 10_000,
fetch: globalThis.fetch,
onError: console.warn,
})
```

`chainlistRpcsUrl` defaults to Chainlist's RPC JSON endpoint, but can be overridden. Refresh requests still use a timeout and `redirect: 'error'`.
9 changes: 8 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@
"types": "./dist/wagmi.d.ts",
"import": "./dist/wagmi.js",
"require": "./dist/wagmi.cjs"
},
"./viemFallbackClient": {
"types": "./dist/viemFallbackClient.d.ts",
"import": "./dist/viemFallbackClient.js",
"require": "./dist/viemFallbackClient.cjs"
}
},
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"lint": "eslint src/",
"test": "node --test --experimental-strip-types --test-force-exit src/**/*.test.ts",
"clean": "rm -rf dist .turbo",
"bump-version": "pnpm version patch --no-git-tag-version && git add package.json && git commit -m \"chore(release): bump @goodwidget/core to $(node -p 'JSON.parse(require(\"fs\").readFileSync(\"package.json\")).version')\""
},
Expand All @@ -35,7 +41,8 @@
},
"dependencies": {
"@goodwidget/ui": "workspace:*",
"tamagui": "1.121.0"
"tamagui": "1.121.0",
"viem": "^2.0.0"
},
"devDependencies": {
"react": "^18.3.0",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ export { GoodWidgetProvider } from './provider'
export type { WalletContextValue, HostContextValue, GoodWidgetContextValue } from './provider'
export { useWallet, useHost, useGoodWidget } from './hooks'
export { detectHost } from './detect'
export { createViemFallbackClient } from './viemFallbackClient'
export type {
CachedChainRpcs,
ViemFallbackClient,
ViemFallbackClientOptions,
ViemFallbackPublicClientParameters,
ViemFallbackStorage,
ViemFallbackWalletClientParameters,
ViemRpcCacheEntry,
} from './viemFallbackClient'

export type {
EIP1193Provider,
Expand Down
153 changes: 153 additions & 0 deletions packages/core/src/viemFallbackClient.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from 'node:assert/strict'
import test from 'node:test'

import { celo } from 'viem/chains'

import { createViemFallbackClient, type ViemFallbackStorage } from './viemFallbackClient.ts'

const chainlistUrl = 'https://chainlist.org/rpcs.json'

function createStorage(initialValue?: string): ViemFallbackStorage {
let value = initialValue ?? null

return {
async getItem() {
return value
},
async setItem(_key, nextValue) {
value = nextValue
},
}
}

function createChainlistResponse(rpcs: string[]) {
return new Response(
JSON.stringify([
{
chainId: celo.id,
rpc: rpcs,
},
]),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
)
}

function createChain(defaultRpcs: string[]) {
return {
...celo,
rpcUrls: {
...celo.rpcUrls,
default: {
...celo.rpcUrls.default,
http: defaultRpcs,
},
},
}
}

test(
'getRpcUrls prefers caller and chain defaults before cached Chainlist URLs',
{ concurrency: false },
async () => {
const client = createViemFallbackClient(createStorage(), {
fetch: async () =>
createChainlistResponse(['https://cached.example', 'https://default.example']),
})

await client.ready

const urls = await client.getRpcUrls(
createChain(['https://default.example']),
['https://caller.example'],
)

assert.deepEqual(urls, [
'https://caller.example',
'https://default.example',
'https://cached.example',
])
},
)

test('refreshRpcs accepts a custom RPC list URL', { concurrency: false }, async () => {
const customUrl = 'http://example.com/rpcs.json'
const fetchCalls: string[] = []
const client = createViemFallbackClient(createStorage(), {
chainlistRpcsUrl: customUrl,
fetch: async (input) => {
fetchCalls.push(input instanceof Request ? input.url : String(input))
return createChainlistResponse(['https://cached.example'])
},
})

await client.ready

assert.ok(fetchCalls.length >= 1)
assert.ok(fetchCalls.every((url) => url === customUrl))
assert.deepEqual(await client.getRpcUrls(createChain([])), ['https://cached.example'])
})

test(
'createPublicClient falls back to a working discovered RPC when the first one fails',
{ concurrency: false },
async () => {
const originalFetch = globalThis.fetch
const badRpcCalls: string[] = []
const goodRpcCalls: string[] = []

const mockFetch: typeof fetch = async (input, init) => {
const url = input instanceof Request ? input.url : String(input)

if (url === chainlistUrl) {
return createChainlistResponse(['https://bad.example', 'https://good.example'])
}

if (url === 'https://bad.example/' || url === 'https://bad.example') {
badRpcCalls.push(url)
return new Response('rate limited', { status: 429 })
}

if (url === 'https://good.example/' || url === 'https://good.example') {
goodRpcCalls.push(url)
const payload = JSON.parse(String(init?.body))

return new Response(
JSON.stringify({
jsonrpc: '2.0',
id: payload.id,
result: '0xa4ec',
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
)
}

throw new Error(`Unexpected fetch URL: ${url}`)
}

globalThis.fetch = mockFetch

try {
const client = createViemFallbackClient(createStorage(), {
chainlistRpcsUrl: chainlistUrl,
fetch: mockFetch,
})
const publicClient = await client.createPublicClient({
chain: createChain([]),
})

const chainId = await publicClient.request({ method: 'eth_chainId' })

assert.equal(chainId, '0xa4ec')
assert.ok(badRpcCalls.length >= 1)
assert.ok(goodRpcCalls.length >= 1)
} finally {
globalThis.fetch = originalFetch
}
},
)
Loading
Loading