@lukso/transaction-decoder 1.0.1-dev.0f1bea5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +486 -0
  3. package/dist/browser.cjs +6912 -0
  4. package/dist/browser.cjs.map +1 -0
  5. package/dist/browser.d.cts +6 -0
  6. package/dist/browser.d.ts +6 -0
  7. package/dist/browser.js +131 -0
  8. package/dist/browser.js.map +1 -0
  9. package/dist/cdn/transaction-decoder.global.js +296 -0
  10. package/dist/cdn/transaction-decoder.global.js.map +1 -0
  11. package/dist/chunk-GGBHTWJL.js +437 -0
  12. package/dist/chunk-GGBHTWJL.js.map +1 -0
  13. package/dist/chunk-GXZOF3QY.js +839 -0
  14. package/dist/chunk-GXZOF3QY.js.map +1 -0
  15. package/dist/chunk-LJ6ES5XF.js +776 -0
  16. package/dist/chunk-LJ6ES5XF.js.map +1 -0
  17. package/dist/chunk-XVHJWV5U.js +4925 -0
  18. package/dist/chunk-XVHJWV5U.js.map +1 -0
  19. package/dist/data.cjs +5518 -0
  20. package/dist/data.cjs.map +1 -0
  21. package/dist/data.d.cts +43 -0
  22. package/dist/data.d.ts +43 -0
  23. package/dist/data.js +55 -0
  24. package/dist/data.js.map +1 -0
  25. package/dist/index-BzXh7poJ.d.cts +524 -0
  26. package/dist/index-BzXh7poJ.d.ts +524 -0
  27. package/dist/index.cjs +6912 -0
  28. package/dist/index.cjs.map +1 -0
  29. package/dist/index.d.cts +756 -0
  30. package/dist/index.d.ts +756 -0
  31. package/dist/index.js +131 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/server.cjs +5644 -0
  34. package/dist/server.cjs.map +1 -0
  35. package/dist/server.d.cts +217 -0
  36. package/dist/server.d.ts +217 -0
  37. package/dist/server.js +644 -0
  38. package/dist/server.js.map +1 -0
  39. package/dist/utils-CBAkjQh3.d.cts +108 -0
  40. package/dist/utils-xT9-km0r.d.ts +108 -0
  41. package/package.json +101 -0
  42. package/src/browser.ts +13 -0
  43. package/src/client/resolveAddresses.ts +157 -0
  44. package/src/core/addressCollector.ts +153 -0
  45. package/src/core/addressResolver.ts +135 -0
  46. package/src/core/dataModel.ts +888 -0
  47. package/src/core/instance.ts +33 -0
  48. package/src/core/integrateDecoder.ts +325 -0
  49. package/src/data.ts +70 -0
  50. package/src/decoder/GENERATOR_PROPOSAL.md +182 -0
  51. package/src/decoder/THREE_PHASE_EXAMPLE.md +108 -0
  52. package/src/decoder/aggregation.ts +218 -0
  53. package/src/decoder/browserCache.ts +237 -0
  54. package/src/decoder/cache/README.md +126 -0
  55. package/src/decoder/cache/index.ts +44 -0
  56. package/src/decoder/cache.ts +139 -0
  57. package/src/decoder/constants.ts +125 -0
  58. package/src/decoder/decodeTransaction.ts +292 -0
  59. package/src/decoder/errors.ts +95 -0
  60. package/src/decoder/events.ts +192 -0
  61. package/src/decoder/functionSignature.ts +344 -0
  62. package/src/decoder/getDataFromExternalSources.ts +248 -0
  63. package/src/decoder/graphqlWS.ts +22 -0
  64. package/src/decoder/interfaces.ts +185 -0
  65. package/src/decoder/keyValue.ts +5 -0
  66. package/src/decoder/kvCache.ts +241 -0
  67. package/src/decoder/lruCache.ts +184 -0
  68. package/src/decoder/lsp7Mint.test.ts +179 -0
  69. package/src/decoder/lsp7TransferBatch.test.ts +105 -0
  70. package/src/decoder/plugins/RegistryAbi.ts +562 -0
  71. package/src/decoder/plugins/enhanceBurntPix.ts +132 -0
  72. package/src/decoder/plugins/enhanceGraffiti.ts +70 -0
  73. package/src/decoder/plugins/enhanceLSP0ERC725Account.ts +179 -0
  74. package/src/decoder/plugins/enhanceLSP26FollowerSystem.ts +88 -0
  75. package/src/decoder/plugins/enhanceLSP6KeyManager.ts +231 -0
  76. package/src/decoder/plugins/enhanceLSP7DigitalAsset.ts +165 -0
  77. package/src/decoder/plugins/enhanceLSP8IdentifiableDigitalAsset.ts +170 -0
  78. package/src/decoder/plugins/enhanceLSP9Vault.ts +57 -0
  79. package/src/decoder/plugins/enhanceRetrieveAbi.ts +85 -0
  80. package/src/decoder/plugins/enhanceSetData.ts +135 -0
  81. package/src/decoder/plugins/index.ts +99 -0
  82. package/src/decoder/plugins/schemaDefault.ts +318 -0
  83. package/src/decoder/plugins/standardPlugin.ts +202 -0
  84. package/src/decoder/registry.ts +322 -0
  85. package/src/decoder/singleGQL.ts +293 -0
  86. package/src/decoder/transaction.ts +198 -0
  87. package/src/decoder/types.ts +465 -0
  88. package/src/decoder/utils.ts +212 -0
  89. package/src/example/usage.ts +172 -0
  90. package/src/index.ts +174 -0
  91. package/src/server/addressResolver.ts +68 -0
  92. package/src/server/caches.ts +209 -0
  93. package/src/server/decodeTransactionSync.ts +156 -0
  94. package/src/server/decodeTransactionsBatch.ts +207 -0
  95. package/src/server/finishDecoding.ts +116 -0
  96. package/src/server/index.ts +81 -0
  97. package/src/server/lsp23Resolver.test.ts +46 -0
  98. package/src/server/lsp23Resolver.ts +419 -0
  99. package/src/server/types.ts +168 -0
  100. package/src/server.ts +22 -0
  101. package/src/shared/addressResolver.ts +651 -0
  102. package/src/shared/cache.ts +144 -0
  103. package/src/shared/constants.ts +21 -0
  104. package/src/stubs/tty.ts +13 -0
  105. package/src/stubs/util.ts +42 -0
  106. package/src/types/index.ts +154 -0
  107. package/src/types/provider.ts +46 -0
  108. package/src/umd.ts +13 -0
  109. package/src/utils/debug.ts +49 -0
  110. package/src/utils/json-bigint.ts +47 -0
@@ -0,0 +1,248 @@
1
+ import type {
2
+ DecodeDataOutput,
3
+ ERC725JSONSchema,
4
+ GetDataExternalSourcesOutput,
5
+ URLDataWithHash,
6
+ } from '@erc725/erc725.js'
7
+ import { isDataAuthentic, patchIPFSUrlsIfApplicable } from '@erc725/erc725.js'
8
+
9
+ export async function loadFile(
10
+ _url: string,
11
+ ipfsGateway: string
12
+ ): Promise<ArrayBuffer | null> {
13
+ let url = _url
14
+ if (url.startsWith('data:')) {
15
+ // Handle data URLs
16
+ const base64Data = url.split(',')[1]
17
+ const binaryString = atob(base64Data)
18
+ const len = binaryString.length
19
+ const bytes = new Uint8Array(len)
20
+ for (let i = 0; i < len; i++) {
21
+ bytes[i] = binaryString.charCodeAt(i)
22
+ }
23
+ return Promise.resolve(bytes.buffer)
24
+ }
25
+ if (url.startsWith('ipfs://')) {
26
+ // Handle IPFS URLs
27
+ const ipfsHash = url.replace('ipfs://', '')
28
+ url = `${ipfsGateway}${ipfsHash}`
29
+ }
30
+ return fetch(url)
31
+ .then((response) => {
32
+ if (!response.ok) {
33
+ throw new Error('Network response was not ok')
34
+ }
35
+ return response.arrayBuffer()
36
+ })
37
+ .catch(() => null)
38
+ }
39
+
40
+ export const getDataFromExternalSources = (
41
+ schemas: ERC725JSONSchema[],
42
+ dataFromChain: DecodeDataOutput[],
43
+ ipfsGateway: string,
44
+ throwException = true
45
+ ): Promise<
46
+ Array<
47
+ GetDataExternalSourcesOutput & {
48
+ error?: string
49
+ method?: string
50
+ data?: string
51
+ url?: string
52
+ src?: string
53
+ }
54
+ >
55
+ > => {
56
+ const promises = dataFromChain.map(async (dataEntry) => {
57
+ const schemaElement = schemas.find((schema) => schema.key === dataEntry.key)
58
+
59
+ if (!schemaElement) {
60
+ // It is weird if we can't find the schema element for the key...
61
+ // Let's simply ignore and return it...
62
+ return dataEntry
63
+ }
64
+
65
+ if (
66
+ !['jsonurl', 'asseturl', 'verifiableuri'].includes(
67
+ schemaElement.valueContent.toLowerCase()
68
+ )
69
+ ) {
70
+ return dataEntry
71
+ }
72
+
73
+ const urlDataWithHash: URLDataWithHash = dataEntry.value as URLDataWithHash // Type URLDataWithHash
74
+ let url: string | undefined
75
+ let src: string | undefined
76
+ try {
77
+ // At this stage, value should be of type jsonurl, verifiableuri or asseturl
78
+ if (typeof dataEntry.value === 'string') {
79
+ throw new Error(
80
+ `Value is string instead of ${schemaElement.valueContent}`
81
+ )
82
+ }
83
+
84
+ if (!dataEntry.value) {
85
+ throw new Error(`${schemaElement.valueContent} empty`)
86
+ }
87
+
88
+ if (Array.isArray(dataEntry.value)) {
89
+ throw new Error(
90
+ `Value is Array instead of ${schemaElement.valueContent}`
91
+ )
92
+ }
93
+
94
+ url = urlDataWithHash.url
95
+ ;({ url: src } = patchIPFSUrlsIfApplicable(
96
+ urlDataWithHash as URLDataWithHash,
97
+ ipfsGateway
98
+ ))
99
+ let length = 'unknown'
100
+ if (!url.startsWith('data:') && /[=?/]$/.test(url)) {
101
+ // this URL is not verifiable and the URL ends with a / or ? or = meaning it's not a file
102
+ // and more likely to be some kind of directory or query BaseU=== "errorRI
103
+ return dataEntry
104
+ }
105
+ const receivedData = /^data:|^ipfs:\/\/|\/ipfs\//.test(url)
106
+ ? await loadFile(src, ipfsGateway).then((buffer) =>
107
+ buffer
108
+ ? new Uint8Array(buffer)
109
+ : Promise.reject(new Error('No Data'))
110
+ )
111
+ : await fetch(src).then(async (response) => {
112
+ if (!response.ok) {
113
+ throw new Error(response.statusText)
114
+ }
115
+ return response
116
+ .arrayBuffer()
117
+ .then((buffer) => new Uint8Array(buffer))
118
+ })
119
+ length = receivedData.length.toString()
120
+ const captureHashes: Record<string, string> = {}
121
+ const captureErrors: string[] = []
122
+ if (receivedData.length >= 2) {
123
+ // JSON data cannot be less than 2 characters long.
124
+ try {
125
+ // - Build a string containing the first and last byte of the received data
126
+ // and try to convert it to utf8. If that succeeds then
127
+ // - check whether those could represent valid JSON data.
128
+ // - then validate the data as JSON
129
+ // - then verfiy the data against the verification method
130
+ // Improved JSON detection. We now check the first and up to the last 3 bytes.
131
+ // The 3 bytes can be `]` or '}' with either SPACE, LF or CRLF at the end.
132
+ // When editing JSON using a text editor, a lot of time it's pretty printed
133
+ // and an empty line added to the end. This is a common pattern.
134
+ const capture: number[] = []
135
+ capture.push(receivedData[0])
136
+ if (receivedData.length > 3) {
137
+ capture.push(receivedData[receivedData.length - 3])
138
+ }
139
+ if (receivedData.length > 2) {
140
+ capture.push(receivedData[receivedData.length - 2])
141
+ }
142
+ if (receivedData.length > 1) {
143
+ capture.push(receivedData[receivedData.length - 1])
144
+ }
145
+ const key = String.fromCharCode.apply(null, capture)
146
+ // Currently not supported even though they could be added and can represent valid JSON.
147
+ // " " => JSON.stringify("") NOT SUPPORTED as valid JSON
148
+ // t or f and e => JSON.stringify(true) or JSON.stringify(false) NOT SUPPORTED as valid JSON
149
+ // 0-9 => JSON.stringify(0) integer or float (note .5 is not legitimate JSON) NOT SUPPORTED as valid JSON
150
+ // if (/^(\[\]|\{\}|(tf)e|\d\d)$/.test(key)) {
151
+ // Check if the beginning or end are
152
+ // { and } => JSON.stringify({...}) => pretty much 100% of our JSON will be this.
153
+ // [ and ] => JSON.stringify([...])
154
+ if (/^(\[.*\]|\{.*\})\s*$/s.test(key)) {
155
+ const json = new TextDecoder().decode(new Uint8Array(receivedData))
156
+ const value = JSON.parse(json)
157
+ if (
158
+ isDataAuthentic(
159
+ value,
160
+ urlDataWithHash.verification,
161
+ captureErrors
162
+ )
163
+ ) {
164
+ return {
165
+ ...dataEntry,
166
+ value,
167
+ data: urlDataWithHash.verification?.data,
168
+ method: urlDataWithHash.verification?.method,
169
+ url,
170
+ src,
171
+ }
172
+ }
173
+ captureHashes[captureErrors.pop() || ''] =
174
+ urlDataWithHash.verification?.data || ''
175
+ if (
176
+ isDataAuthentic(
177
+ receivedData,
178
+ urlDataWithHash.verification,
179
+ captureErrors
180
+ )
181
+ ) {
182
+ return {
183
+ ...dataEntry,
184
+ value,
185
+ data: urlDataWithHash.verification?.data,
186
+ method: urlDataWithHash.verification?.method,
187
+ url,
188
+ src,
189
+ }
190
+ }
191
+ captureHashes[captureErrors.pop() || ''] =
192
+ urlDataWithHash.verification?.data || ''
193
+ throw new Error(
194
+ `Data does not validate, ignored ${Object.entries(captureHashes)
195
+ .map(
196
+ ([expected, got]) =>
197
+ `calculated(${got}) != expected(${expected})`
198
+ )
199
+ .join(', ')}`
200
+ )
201
+ }
202
+ } catch {
203
+ // ignore
204
+ }
205
+ }
206
+ if (
207
+ isDataAuthentic(
208
+ receivedData,
209
+ urlDataWithHash.verification,
210
+ captureErrors
211
+ )
212
+ ) {
213
+ return {
214
+ ...dataEntry,
215
+ value: receivedData,
216
+ data: urlDataWithHash.verification?.data,
217
+ method: urlDataWithHash.verification?.method,
218
+ url,
219
+ src,
220
+ }
221
+ }
222
+ captureHashes[captureErrors.pop() || ''] =
223
+ urlDataWithHash.verification?.data || ''
224
+ throw new Error(
225
+ `Data does not validate, ignored ${Object.entries(captureHashes)
226
+ .map(
227
+ ([expected, got]) => `calculated(${got}) != expected(${expected})`
228
+ )
229
+ .join(', ')}`
230
+ )
231
+ } catch (error) {
232
+ const message = (error as { message: string }).message
233
+ if (throwException) {
234
+ throw error
235
+ }
236
+ return {
237
+ ...dataEntry,
238
+ value: null,
239
+ error: message,
240
+ data: urlDataWithHash.verification?.data,
241
+ method: urlDataWithHash.verification?.method,
242
+ url,
243
+ src,
244
+ }
245
+ }
246
+ })
247
+ return Promise.all(promises)
248
+ }
@@ -0,0 +1,22 @@
1
+ import { type Client, createClient } from 'graphql-ws'
2
+ import type { Chain } from 'viem'
3
+ import { lukso } from 'viem/chains'
4
+
5
+ const wsClients: Record<string, Client> = {}
6
+
7
+ /**
8
+ * Get web socket graphql client for a given chain
9
+ *
10
+ * @param chain Chain to use
11
+ * @returns web socket graphql client
12
+ */
13
+ export function getWSClient(chain: Chain) {
14
+ if (wsClients[chain.id]) return wsClients[chain.id]
15
+ wsClients[chain.id] = createClient({
16
+ url:
17
+ chain.id === lukso.id
18
+ ? 'wss://envio.lukso-mainnet.universal.tech/v1/graphql'
19
+ : 'wss://envio.lukso-testnet.universal.tech/v1/graphql',
20
+ })
21
+ return wsClients[chain.id]
22
+ }
@@ -0,0 +1,185 @@
1
+ import { abi as KeyManager } from '@lukso/lsp6-contracts/artifacts/LSP6KeyManager.json'
2
+ import { LRUCache } from 'lru-cache'
3
+ import {
4
+ type Chain,
5
+ type ChainContract,
6
+ encodeFunctionData,
7
+ getContract,
8
+ } from 'viem'
9
+ import { ITEMS } from './constants'
10
+ import type { AddressModifier } from './types'
11
+ import { getPublicClient } from './utils'
12
+
13
+ const TARGETS = new LRUCache<string, Promise<`0x${string}` | undefined>>({
14
+ max: 1000,
15
+ ttl: 1000 * 60 * 60,
16
+ })
17
+
18
+ /**
19
+ * Get the profile from the key manager.
20
+ *
21
+ * @param chain which chain to use
22
+ * @param address address of key manager
23
+ * @returns
24
+ */
25
+ export async function getKMTarget(
26
+ chain: Chain,
27
+ address?: `0x${string}` | null
28
+ ): Promise<`0x${string}` | undefined> {
29
+ if (!address) {
30
+ return undefined
31
+ }
32
+ const info = TARGETS.get(address)
33
+ if (info) {
34
+ return info
35
+ }
36
+ const client = getPublicClient(chain)
37
+ const out = client
38
+ .readContract({
39
+ address,
40
+ abi: KeyManager,
41
+ functionName: 'target',
42
+ })
43
+ .catch(() => undefined) as Promise<`0x${string}` | undefined>
44
+ TARGETS.set(address, out)
45
+ return out
46
+ }
47
+
48
+ const INTERFACE_INFO = new LRUCache<string, Promise<string | undefined>>({
49
+ max: 1000,
50
+ ttl: 1000 * 60 * 60,
51
+ })
52
+
53
+ /**
54
+ * Retrieve interface information, LSP7/8/0 for an address
55
+ *
56
+ * @param address
57
+ * @param blockNumber
58
+ * @param log
59
+ * @returns
60
+ */
61
+ export async function retrieveInterfaces(
62
+ chain: Chain,
63
+ modifier: AddressModifier
64
+ ): Promise<string | undefined> {
65
+ const info = INTERFACE_INFO.get(modifier.address)
66
+ if (info) {
67
+ return info
68
+ }
69
+ const out = _retrieveInterfaces(chain, modifier)
70
+ INTERFACE_INFO.set(modifier.address, out)
71
+ return out
72
+ }
73
+
74
+ const LSP2Fetcher = [
75
+ {
76
+ inputs: [
77
+ {
78
+ internalType: 'bytes4',
79
+ name: 'interfaceId',
80
+ type: 'bytes4',
81
+ },
82
+ ],
83
+ name: 'supportsInterface',
84
+ outputs: [
85
+ {
86
+ internalType: 'bool',
87
+ name: '',
88
+ type: 'bool',
89
+ },
90
+ ],
91
+ stateMutability: 'view',
92
+ type: 'function',
93
+ },
94
+ {
95
+ type: 'function',
96
+ name: 'aggregate3',
97
+ inputs: [
98
+ {
99
+ name: 'calls',
100
+ type: 'tuple[]',
101
+ internalType: 'struct LSP2FetcherWithMulticall3.Call3[]',
102
+ components: [
103
+ { name: 'target', type: 'address', internalType: 'address' },
104
+ { name: 'allowFailure', type: 'bool', internalType: 'bool' },
105
+ { name: 'callData', type: 'bytes', internalType: 'bytes' },
106
+ ],
107
+ },
108
+ ],
109
+ outputs: [
110
+ {
111
+ name: 'returnData',
112
+ type: 'tuple[]',
113
+ internalType: 'struct LSP2FetcherWithMulticall3.Result[]',
114
+ components: [
115
+ { name: 'success', type: 'bool', internalType: 'bool' },
116
+ { name: 'returnData', type: 'bytes', internalType: 'bytes' },
117
+ ],
118
+ },
119
+ ],
120
+ stateMutability: 'payable',
121
+ },
122
+ ]
123
+
124
+ /**
125
+ * Lowlevel function to retrieve interface information
126
+ *
127
+ * @param address
128
+ * @returns
129
+ */
130
+ async function _retrieveInterfaces(
131
+ chain: Chain,
132
+ modifier: AddressModifier
133
+ ): Promise<string | undefined> {
134
+ const { multicall3 } = chain.contracts as Record<string, ChainContract>
135
+ if (!multicall3) {
136
+ return undefined
137
+ }
138
+ const client = getPublicClient(chain)
139
+ const contract = getContract({
140
+ client,
141
+ address: multicall3.address,
142
+ abi: LSP2Fetcher,
143
+ })
144
+ const args = ITEMS.map(({ key }) => {
145
+ return {
146
+ target: modifier.address,
147
+ allowFailure: true,
148
+ callData: encodeFunctionData({
149
+ abi: LSP2Fetcher,
150
+ functionName: 'supportsInterface',
151
+ args: [key],
152
+ }),
153
+ }
154
+ })
155
+ return client
156
+ .readContract({
157
+ address: multicall3.address,
158
+ abi: LSP2Fetcher,
159
+ functionName: 'aggregate3',
160
+ args: [args],
161
+ })
162
+ .catch((error) => {
163
+ console.error(error)
164
+ return []
165
+ })
166
+ .then((val) => {
167
+ const response = val as Array<{ success: boolean; returnData: boolean }>
168
+ for (const [index, value] of response.map(
169
+ ({ success, returnData }, index: number) =>
170
+ [index, success && Number(returnData)] as [number, boolean]
171
+ )) {
172
+ const item = ITEMS[index]
173
+ if (!item) {
174
+ continue
175
+ }
176
+ if (value) {
177
+ return item.standard
178
+ }
179
+ }
180
+ return undefined
181
+ })
182
+ .then((type) => {
183
+ return type
184
+ })
185
+ }
@@ -0,0 +1,5 @@
1
+ import { defaultSchema } from './constants'
2
+ import { decodeKeyValuePlugin } from './plugins/schemaDefault'
3
+
4
+ export const defaultSchemaPlugin = decodeKeyValuePlugin(defaultSchema)
5
+ export default defaultSchemaPlugin
@@ -0,0 +1,241 @@
1
+ /**
2
+ * KV (Key-Value store) implementation of DecoderCache interface
3
+ * Suitable for Cloudflare Workers KV, Deno KV, or similar
4
+ */
5
+
6
+ import type { CacheEntry, CacheOptions, DecoderCache } from './cache'
7
+
8
+ /**
9
+ * KV store interface that implementations must provide
10
+ */
11
+ export interface KVStore {
12
+ get<T = unknown>(key: string): Promise<T | null>
13
+ put(
14
+ key: string,
15
+ value: string,
16
+ options?: { expirationTtl?: number }
17
+ ): Promise<void>
18
+ delete(key: string): Promise<void>
19
+ list?(options?: {
20
+ prefix?: string
21
+ }): Promise<{ keys: Array<{ name: string }> }>
22
+ }
23
+
24
+ /**
25
+ * Options for creating a KV cache instance
26
+ */
27
+ export interface KVCacheOptions {
28
+ /** KV store instance (e.g., Cloudflare KV namespace) */
29
+ kv: KVStore
30
+ /** Prefix for all keys */
31
+ prefix?: string
32
+ /** Default TTL in milliseconds */
33
+ ttl?: number
34
+ /** Default error TTL in milliseconds */
35
+ errorTTL?: number
36
+ /** Default stale-while-revalidate time in milliseconds */
37
+ staleWhileRevalidate?: number
38
+ }
39
+
40
+ /**
41
+ * KV implementation of DecoderCache
42
+ */
43
+ export class KVDecoderCache implements DecoderCache {
44
+ private kv: KVStore
45
+ private prefix: string
46
+ private promises: Map<string, Promise<unknown>>
47
+ private defaultTTL: number
48
+ private defaultErrorTTL: number
49
+ private defaultStaleWhileRevalidate?: number
50
+
51
+ constructor(options: KVCacheOptions) {
52
+ this.kv = options.kv
53
+ this.prefix = options.prefix ?? 'decoder:'
54
+ this.defaultTTL = options.ttl ?? 5 * 60 * 1000 // 5 minutes
55
+ this.defaultErrorTTL = options.errorTTL ?? 30 * 1000 // 30 seconds
56
+ this.defaultStaleWhileRevalidate = options.staleWhileRevalidate
57
+ this.promises = new Map()
58
+ }
59
+
60
+ private getKey(key: string): string {
61
+ return `${this.prefix}${key}`
62
+ }
63
+
64
+ async get<T>(key: string): Promise<CacheEntry<T> | undefined> {
65
+ try {
66
+ const data = await this.kv.get<CacheEntry<T>>(this.getKey(key))
67
+ if (!data) {
68
+ return undefined
69
+ }
70
+
71
+ // Check if expired
72
+ if (data.expires && Date.now() > data.expires) {
73
+ // Don't delete here as it might be used for stale-while-revalidate
74
+ return undefined
75
+ }
76
+
77
+ return data
78
+ } catch (error) {
79
+ console.error('KV get error:', error)
80
+ return undefined
81
+ }
82
+ }
83
+
84
+ async set<T>(key: string, value: T, options?: CacheOptions): Promise<void> {
85
+ const now = Date.now()
86
+ const ttl =
87
+ options?.ttl ?? (options?.errorTTL ? options.errorTTL : this.defaultTTL)
88
+ const isError = options?.errorTTL !== undefined
89
+
90
+ const entry: CacheEntry<T> = {
91
+ value,
92
+ expires: now + ttl,
93
+ isError,
94
+ }
95
+
96
+ const staleWhileRevalidate =
97
+ options?.staleWhileRevalidate ?? this.defaultStaleWhileRevalidate
98
+ if (staleWhileRevalidate) {
99
+ entry.stale = now + ttl + staleWhileRevalidate
100
+ }
101
+
102
+ try {
103
+ // Calculate actual TTL for KV store (including stale time)
104
+ const kvTtl = entry.stale ? entry.stale - now : ttl
105
+
106
+ await this.kv.put(
107
+ this.getKey(key),
108
+ JSON.stringify(entry),
109
+ { expirationTtl: Math.ceil(kvTtl / 1000) } // KV usually wants seconds
110
+ )
111
+ } catch (error) {
112
+ console.error('KV set error:', error)
113
+ throw error
114
+ }
115
+ }
116
+
117
+ async delete(key: string): Promise<void> {
118
+ try {
119
+ await this.kv.delete(this.getKey(key))
120
+ } catch (error) {
121
+ console.error('KV delete error:', error)
122
+ }
123
+ }
124
+
125
+ async getMany<T>(keys: string[]): Promise<Map<string, CacheEntry<T>>> {
126
+ const result = new Map<string, CacheEntry<T>>()
127
+
128
+ // Most KV stores don't support batch get, so we do it in parallel
129
+ const promises = keys.map(async (key) => {
130
+ const entry = await this.get<T>(key)
131
+ if (entry) {
132
+ result.set(key, entry)
133
+ }
134
+ })
135
+
136
+ await Promise.all(promises)
137
+ return result
138
+ }
139
+
140
+ async setMany<T>(
141
+ entries: Array<{ key: string; value: T; options?: CacheOptions }>
142
+ ): Promise<void> {
143
+ // Most KV stores don't support batch set, so we do it in parallel
144
+ const promises = entries.map(({ key, value, options }) =>
145
+ this.set(key, value, options)
146
+ )
147
+
148
+ await Promise.all(promises)
149
+ }
150
+
151
+ async clear(): Promise<void> {
152
+ // Clear in-flight promises
153
+ this.promises.clear()
154
+
155
+ // If KV supports listing, delete all with prefix
156
+ if (this.kv.list) {
157
+ try {
158
+ const { keys } = await this.kv.list({ prefix: this.prefix })
159
+ const deletePromises = keys.map(({ name }) => this.kv.delete(name))
160
+ await Promise.all(deletePromises)
161
+ } catch (error) {
162
+ console.error('KV clear error:', error)
163
+ }
164
+ }
165
+ }
166
+
167
+ async getOrSet<T>(
168
+ key: string,
169
+ factory: (signal?: AbortSignal) => Promise<T>,
170
+ options?: CacheOptions & { signal?: AbortSignal }
171
+ ): Promise<T> {
172
+ // Check cache first
173
+ const cached = await this.get<T>(key)
174
+ const now = Date.now()
175
+
176
+ // If we have a valid (non-expired) entry, return it
177
+ if (cached && (!cached.expires || now < cached.expires)) {
178
+ return cached.value
179
+ }
180
+
181
+ // If we have a stale entry and stale-while-revalidate is enabled
182
+ if (
183
+ cached &&
184
+ cached.stale &&
185
+ now < cached.stale &&
186
+ !this.promises.has(key)
187
+ ) {
188
+ // Return stale value and revalidate in background
189
+ const backgroundPromise = factory(options?.signal)
190
+ .then(async (fresh) => {
191
+ await this.set(key, fresh, options)
192
+ return fresh
193
+ })
194
+ .catch(() => {
195
+ // On error, keep the stale value
196
+ return cached.value
197
+ })
198
+ .finally(() => {
199
+ this.promises.delete(key)
200
+ })
201
+
202
+ this.promises.set(key, backgroundPromise)
203
+ return cached.value
204
+ }
205
+
206
+ // Check if we have an in-flight promise
207
+ const existingPromise = this.promises.get(key) as Promise<T> | undefined
208
+ if (existingPromise) {
209
+ return existingPromise
210
+ }
211
+
212
+ // No valid cache entry, fetch new value
213
+ const promise = factory(options?.signal)
214
+ .then(async (value) => {
215
+ await this.set(key, value, options)
216
+ return value
217
+ })
218
+ .catch(async (error) => {
219
+ // Cache the error with shorter TTL
220
+ await this.set(key, error as T, {
221
+ ...options,
222
+ ttl: options?.errorTTL ?? this.defaultErrorTTL,
223
+ errorTTL: options?.errorTTL ?? this.defaultErrorTTL,
224
+ })
225
+ throw error
226
+ })
227
+ .finally(() => {
228
+ this.promises.delete(key)
229
+ })
230
+
231
+ this.promises.set(key, promise)
232
+ return promise as Promise<T>
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Create a KV cache instance
238
+ */
239
+ export function createKVCache(options: KVCacheOptions): DecoderCache {
240
+ return new KVDecoderCache(options)
241
+ }