Skip to content

Commit d903e17

Browse files
committed
feat: durable objects alarms as cron jobs
1 parent 2d01f47 commit d903e17

File tree

14 files changed

+421
-267
lines changed

14 files changed

+421
-267
lines changed

biome.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"rules": {
2323
"recommended": true,
2424
"complexity": {
25-
"noBannedTypes": "off"
25+
"noBannedTypes": "off",
26+
"noUselessConstructor": "off"
2627
},
2728
"performance": {
2829
"noDelete": "off"

client/src/App.tsx

Lines changed: 61 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ import { type Errors, type Hex, Json, Value } from 'ox'
77
import { SERVER_URL, permissions } from './constants.ts'
88
import { useEffect, useState, useSyncExternalStore } from 'react'
99
import { useCallsStatus, useSendCalls } from 'wagmi/experimental'
10-
import { useBalance, useClearLocalStorage, useDebug } from './hooks.ts'
10+
import {
11+
nukeEverything,
12+
useBalance,
13+
useClearLocalStorage,
14+
useDebug,
15+
} from './hooks.ts'
1116

1217
export function App() {
1318
useClearLocalStorage()
@@ -35,7 +40,7 @@ export function App() {
3540
<hr />
3641
<Mint />
3742
<hr />
38-
<DemoCron />
43+
<DemoScheduler />
3944
</main>
4045
)
4146
}
@@ -55,12 +60,10 @@ function DebugLink() {
5560
style={{
5661
top: 200,
5762
right: 0,
58-
gap: '3px',
5963
padding: '0px',
6064
display: 'flex',
6165
position: 'fixed',
6266
paddingTop: '5px',
63-
alignItems: 'flex-end',
6467
flexDirection: 'column',
6568
}}
6669
>
@@ -71,11 +74,13 @@ function DebugLink() {
7174
style={{
7275
padding: '6px',
7376
color: 'white',
77+
width: '100%',
7478
fontWeight: '700',
75-
fontSize: '1.25rem',
76-
textAlign: 'center',
7779
textDecoration: 'none',
7880
backgroundColor: 'black',
81+
borderColor: 'darkgray',
82+
borderWidth: '1px',
83+
borderStyle: 'solid',
7984
}}
8085
>
8186
DEBUG
@@ -84,18 +89,39 @@ function DebugLink() {
8489
target="_blank"
8590
rel="noreferrer"
8691
hidden={!import.meta.env.DEV}
87-
href={`${SERVER_URL}/__scheduled?cron=*+*+*+*+*`}
92+
href={`${SERVER_URL}/init`}
8893
style={{
8994
padding: '6px',
9095
color: 'white',
96+
width: '100%',
9197
fontWeight: '700',
92-
fontSize: '1.25rem',
9398
textDecoration: 'none',
9499
backgroundColor: 'black',
100+
borderColor: 'darkgray',
101+
borderWidth: '1px',
102+
borderStyle: 'solid',
95103
}}
96104
>
97-
SIMULATE CRON
105+
INIT SCHEDULER
98106
</a>
107+
<button
108+
hidden={!import.meta.env.DEV}
109+
onClick={() => nukeEverything()}
110+
type="button"
111+
style={{
112+
padding: '6px',
113+
color: 'white',
114+
width: '100%',
115+
fontWeight: '700',
116+
textDecoration: 'none',
117+
backgroundColor: 'black',
118+
borderColor: 'darkgray',
119+
borderWidth: '1px',
120+
borderStyle: 'solid',
121+
}}
122+
>
123+
RESET RECORDS
124+
</button>
99125
</div>
100126
)
101127
}
@@ -148,7 +174,9 @@ function Connect() {
148174
disconnectFromAll().then(() =>
149175
connect.mutateAsync({
150176
connector,
151-
grantPermissions: grantPermissions ? permissions : undefined,
177+
grantPermissions: grantPermissions
178+
? permissions()
179+
: undefined,
152180
}),
153181
)
154182
}
@@ -162,7 +190,9 @@ function Connect() {
162190
connect.mutateAsync({
163191
connector,
164192
createAccount: { label },
165-
grantPermissions: grantPermissions ? permissions : undefined,
193+
grantPermissions: grantPermissions
194+
? permissions()
195+
: undefined,
166196
}),
167197
)
168198
}
@@ -210,12 +240,12 @@ function RequestKey() {
210240
const { refetch } = useDebug({ enabled: result !== null, address })
211241
return (
212242
<div>
213-
<h3>[server] Request Key from Server (GET /keys/:address?expiry)</h3>
243+
<h3>[server] Request Key from Server (GET /keys/:address)</h3>
214244
<button
215245
onClick={async (_) => {
216246
if (!address) return
217247
const searchParams = new URLSearchParams({
218-
expiry: permissions.expiry.toString(),
248+
expiry: permissions().expiry.toString(),
219249
})
220250
const response = await fetch(
221251
`${SERVER_URL}/keys/${address.toLowerCase()}?${searchParams.toString()}`,
@@ -238,7 +268,8 @@ function RequestKey() {
238268
<details>
239269
<summary style={{ marginTop: '1rem' }}>
240270
{truncateHexString({ address: result?.publicKey, length: 12 })} -
241-
expires: {new Date(result.expiry * 1000).toLocaleString()}
271+
expires: {new Date(result.expiry * 1_000).toLocaleString()} (local
272+
time)
242273
</summary>
243274
<pre>{Json.stringify(result, undefined, 2)}</pre>
244275
</details>
@@ -267,13 +298,13 @@ function GrantPermissions() {
267298
) as { publicKey: Hex.Hex; type: 'p256'; expiry: number }
268299

269300
// if `expry` is present in both `key` and `permissions`, pick the lower value
270-
const expiry = Math.min(key.expiry, permissions.expiry)
301+
const expiry = Math.min(key.expiry, permissions().expiry)
271302

272303
grantPermissions.mutate({
273304
key,
274305
expiry,
275306
address,
276-
permissions: permissions.permissions,
307+
permissions: permissions().permissions,
277308
})
278309
}}
279310
>
@@ -378,6 +409,7 @@ function Mint() {
378409
}
379410

380411
const schedules = {
412+
'once every 10 seconds': '*/10 * * * * *',
381413
'once every minute': '* * * * *',
382414
'once every hour': '0 * * * *',
383415
'once every day': '0 0 * * *',
@@ -387,7 +419,7 @@ const schedules = {
387419
type Schedule = keyof typeof schedules
388420

389421
/* Check server activity */
390-
function DemoCron() {
422+
function DemoScheduler() {
391423
const [status, setStatus] = useState<
392424
'idle' | 'pending' | 'error' | 'success'
393425
>('idle')
@@ -399,7 +431,10 @@ function DemoCron() {
399431
return (
400432
<div>
401433
<div style={{ display: 'flex', alignItems: 'center' }}>
402-
<h3>[server] Schedule Transactions |</h3>
434+
<h3>[server] Schedule Transactions</h3>
435+
<p style={{ marginLeft: '6px' }}>
436+
| active schedules: {debugData?.schedules?.length} |
437+
</p>
403438
{status !== 'idle' && (
404439
<span
405440
style={{
@@ -422,9 +457,9 @@ function DemoCron() {
422457

423458
if (!address) return setError('No address')
424459

425-
const { expiry } = permissions
460+
const { expiry } = permissions()
426461

427-
if (expiry < Math.floor(Date.now() / 1000)) {
462+
if (expiry < Math.floor(Date.now() / 1_000)) {
428463
setError('Key expired')
429464
throw new Error('Key expired')
430465
}
@@ -464,13 +499,16 @@ function DemoCron() {
464499
}
465500
}}
466501
>
467-
<p>Approve & Transfer 5 EXP</p>
502+
<p>Approve & Transfer 1 EXP</p>
468503
<select
469504
name="schedule"
470505
style={{ marginRight: '10px' }}
471-
defaultValue="once every minute"
506+
defaultValue="once every 10 seconds"
472507
>
473-
<option value="once every minute">once every minute</option>
508+
<option value="once every 10 seconds">once every 10 seconds</option>
509+
<option value="once every minute" disabled>
510+
once every minute (coming soon)
511+
</option>
474512
<option value="once every hour" disabled>
475513
once every hour (coming soon)
476514
</option>

client/src/config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Implementation, Porto } from 'porto'
22
import { odysseyTestnet } from 'wagmi/chains'
33
import { http, createConfig, createStorage } from 'wagmi'
4+
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query'
45

56
const DISABLE_DIALOG = true // we're introducing dialog as own feature in own blog post
67

@@ -19,3 +20,31 @@ export const wagmiConfig = createConfig({
1920
[odysseyTestnet.id]: http(),
2021
},
2122
})
23+
24+
export const queryClient: QueryClient = new QueryClient({
25+
defaultOptions: {
26+
queries: {
27+
gcTime: 1_000 * 60 * 60, // 1 hour
28+
refetchOnReconnect: () => !queryClient.isMutating(),
29+
},
30+
},
31+
/**
32+
* https://tkdodo.eu/blog/react-query-error-handling#putting-it-all-together
33+
* note: only runs in development mode. Production unaffected.
34+
*/
35+
queryCache: new QueryCache({
36+
onError: (error, query) => {
37+
if (import.meta.env.MODE !== 'development') return
38+
if (query.state.data !== undefined) {
39+
console.error(error)
40+
}
41+
},
42+
}),
43+
mutationCache: new MutationCache({
44+
onSettled: () => {
45+
if (queryClient.isMutating() === 1) {
46+
return queryClient.invalidateQueries()
47+
}
48+
},
49+
}),
50+
})

client/src/constants.ts

Lines changed: 27 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,29 +7,30 @@ export const SERVER_URL =
77
? 'https://exp-0003-server.evm.workers.dev'
88
: 'http://localhost:6900')
99

10-
export const permissions = {
11-
expiry: Math.floor(Date.now() / 1_000) + 60 * 60, // 1 hour
12-
permissions: {
13-
calls: [
14-
{
15-
signature: 'mint(address,uint256)',
16-
to: ExperimentERC20.address[0],
17-
},
18-
{
19-
signature: 'approve(address,uint256)',
20-
to: ExperimentERC20.address[0],
21-
},
22-
{
23-
signature: 'transfer(address,uint256)',
24-
to: ExperimentERC20.address[0],
25-
},
26-
],
27-
spend: [
28-
{
29-
period: 'minute',
30-
token: ExperimentERC20.address[0],
31-
limit: Hex.fromNumber(Value.fromEther('500000')),
32-
},
33-
],
34-
},
35-
} as const
10+
export const permissions = () =>
11+
({
12+
expiry: Math.floor(Date.now() / 1_000) + 60 * 2, // 2 minutes
13+
permissions: {
14+
calls: [
15+
{
16+
signature: 'mint(address,uint256)',
17+
to: ExperimentERC20.address[0],
18+
},
19+
{
20+
signature: 'approve(address,uint256)',
21+
to: ExperimentERC20.address[0],
22+
},
23+
{
24+
signature: 'transfer(address,uint256)',
25+
to: ExperimentERC20.address[0],
26+
},
27+
],
28+
spend: [
29+
{
30+
period: 'minute',
31+
token: ExperimentERC20.address[0],
32+
limit: Hex.fromNumber(Value.fromEther('1000000')),
33+
},
34+
],
35+
},
36+
}) as const

client/src/hooks.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import * as React from 'react'
2+
import { queryClient } from './config.ts'
23
import { SERVER_URL } from './constants.ts'
34
import { ExperimentERC20 } from './contracts.ts'
5+
import { useQuery } from '@tanstack/react-query'
46
import { useAccount, useReadContract } from 'wagmi'
57
import { Address, type Hex, Json, Value } from 'ox'
6-
import { useQuery, useQueryClient } from '@tanstack/react-query'
78

89
export function useBalance() {
910
const { address } = useAccount()
@@ -31,6 +32,14 @@ export interface DebugData {
3132
account: Address.Address
3233
privateKey: Address.Address
3334
}
35+
schedules: Array<{
36+
id: number
37+
created_at: string
38+
address: Address.Address
39+
schedule: string
40+
action: string
41+
calls: string
42+
}>
3443
}
3544

3645
export function useDebug({
@@ -57,14 +66,20 @@ export function useDebug({
5766
}
5867

5968
export function useClearLocalStorage() {
60-
const queryClient = useQueryClient()
61-
62-
// biome-ignore lint/correctness/useExhaustiveDependencies: no need
6369
React.useEffect(() => {
6470
// on `d` press
6571
window.addEventListener('keydown', (event) => {
66-
if (event.key === 'd') {
67-
// clear everything
72+
if (event.key === 'd') nukeEverything()
73+
})
74+
}, [])
75+
}
76+
77+
export function nukeEverything() {
78+
try {
79+
if (import.meta.env.MODE !== 'development') return
80+
// clear everything
81+
fetch(`${SERVER_URL}/debug/nuke-everything`)
82+
.then(() => {
6883
queryClient.clear()
6984
queryClient.resetQueries()
7085
queryClient.removeQueries()
@@ -73,7 +88,10 @@ export function useClearLocalStorage() {
7388
window.localStorage.clear()
7489
window.sessionStorage.clear()
7590
window.location.reload()
76-
}
77-
})
78-
}, [])
91+
})
92+
.catch(() => {})
93+
} catch (error) {
94+
const errorMessage = error instanceof Error ? error.message : error
95+
console.error(errorMessage)
96+
}
7997
}

0 commit comments

Comments
 (0)