@ic-reactor/core 3.0.0-beta.1 β 3.0.0-beta.2
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.
- package/README.md +506 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
# @ic-reactor/core
|
|
2
|
+
|
|
3
|
+
<div align="center">
|
|
4
|
+
<strong>The Core Library for Internet Computer Applications</strong>
|
|
5
|
+
<br><br>
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/@ic-reactor/core)
|
|
8
|
+
[](https://opensource.org/licenses/MIT)
|
|
9
|
+
[](https://www.typescriptlang.org/)
|
|
10
|
+
</div>
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
Framework-agnostic core library for building type-safe Internet Computer applications with [TanStack Query](https://tanstack.com/query) integration.
|
|
15
|
+
|
|
16
|
+
> **Note**: For React applications, use [`@ic-reactor/react`](../react) instead, which re-exports everything from this package plus React-specific hooks.
|
|
17
|
+
|
|
18
|
+
## Features
|
|
19
|
+
|
|
20
|
+
- π **End-to-End Type Safety** β From Candid to your application
|
|
21
|
+
- β‘ **TanStack Query Integration** β Automatic caching, background refetching, optimistic updates
|
|
22
|
+
- π **Auto Transformations** β `DisplayReactor` converts BigInt to string, Principal to text
|
|
23
|
+
- π¦ **Result Unwrapping** β Automatic `Ok`/`Err` handling from Candid Result types
|
|
24
|
+
- π **Internet Identity** β Built-in authentication with session restoration
|
|
25
|
+
- ποΈ **Multi-Canister Support** β Shared authentication across canisters
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
# Core library
|
|
31
|
+
npm install @ic-reactor/core @icp-sdk/core @tanstack/query-core
|
|
32
|
+
|
|
33
|
+
# Optional: For Internet Identity authentication
|
|
34
|
+
npm install @icp-sdk/auth
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Core Concepts
|
|
38
|
+
|
|
39
|
+
### Architecture Overview
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
βββββββββββββββββββ ββββββββββββββββ βββββββββββββββββββββββ
|
|
43
|
+
β ClientManager βββββΆβ Reactor βββββΆβ TanStack Query β
|
|
44
|
+
β (Agent + Auth) β β (Canister) β β (Caching Layer) β
|
|
45
|
+
βββββββββββββββββββ ββββββββββββββββ βββββββββββββββββββββββ
|
|
46
|
+
β β
|
|
47
|
+
β βββββββΌββββββ
|
|
48
|
+
β β Display β
|
|
49
|
+
β β Reactor β
|
|
50
|
+
β βββββββββββββ
|
|
51
|
+
β (Type Transforms)
|
|
52
|
+
βΌ
|
|
53
|
+
βββββββββββββββββββ
|
|
54
|
+
β Internet β
|
|
55
|
+
β Identity β
|
|
56
|
+
βββββββββββββββββββ
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Quick Start
|
|
60
|
+
|
|
61
|
+
### 1. Create ClientManager
|
|
62
|
+
|
|
63
|
+
The `ClientManager` handles the IC agent, authentication, and query client:
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
import { ClientManager } from "@ic-reactor/core"
|
|
67
|
+
import { QueryClient } from "@tanstack/query-core"
|
|
68
|
+
|
|
69
|
+
const queryClient = new QueryClient({
|
|
70
|
+
defaultOptions: {
|
|
71
|
+
queries: { staleTime: 5 * 60 * 1000 }, // 5 minutes
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
const clientManager = new ClientManager({
|
|
76
|
+
queryClient,
|
|
77
|
+
withProcessEnv: true, // Reads DFX_NETWORK from environment
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Initialize agent and restore session
|
|
81
|
+
await clientManager.initialize()
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### 2. Create Reactor
|
|
85
|
+
|
|
86
|
+
The `Reactor` class wraps a canister with type-safe methods and caching:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
import { Reactor } from "@ic-reactor/core"
|
|
90
|
+
import { idlFactory, type _SERVICE } from "./declarations/my_canister"
|
|
91
|
+
|
|
92
|
+
const backend = new Reactor<_SERVICE>({
|
|
93
|
+
clientManager,
|
|
94
|
+
idlFactory,
|
|
95
|
+
canisterId: "rrkah-fqaaa-aaaaa-aaaaq-cai",
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Call Methods
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// Direct call (no caching)
|
|
103
|
+
const greeting = await backend.callMethod({
|
|
104
|
+
functionName: "greet",
|
|
105
|
+
args: ["World"],
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// Fetch with caching
|
|
109
|
+
const cachedGreeting = await backend.fetchQuery({
|
|
110
|
+
functionName: "greet",
|
|
111
|
+
args: ["World"],
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Get from cache (no network call)
|
|
115
|
+
const fromCache = backend.getQueryData({
|
|
116
|
+
functionName: "greet",
|
|
117
|
+
args: ["World"],
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Invalidate cache
|
|
121
|
+
backend.invalidateQueries({ functionName: "greet" })
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## ClientManager API
|
|
125
|
+
|
|
126
|
+
### Constructor Options
|
|
127
|
+
|
|
128
|
+
```typescript
|
|
129
|
+
interface ClientManagerParameters {
|
|
130
|
+
queryClient: QueryClient // TanStack Query client
|
|
131
|
+
port?: number // Local replica port (default: 4943)
|
|
132
|
+
withLocalEnv?: boolean // Force local network
|
|
133
|
+
withProcessEnv?: boolean // Read DFX_NETWORK from env
|
|
134
|
+
agentOptions?: HttpAgentOptions // Custom agent options
|
|
135
|
+
authClient?: AuthClient // Pre-configured auth client
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
### Authentication Methods
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
// Initialize agent and restore previous session
|
|
143
|
+
await clientManager.initialize()
|
|
144
|
+
|
|
145
|
+
// Trigger login flow (opens Internet Identity)
|
|
146
|
+
await clientManager.login({
|
|
147
|
+
identityProvider: "https://identity.ic0.app", // optional, auto-detected
|
|
148
|
+
onSuccess: () => console.log("Logged in!"),
|
|
149
|
+
onError: (error) => console.error(error),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// Logout and revert to anonymous identity
|
|
153
|
+
await clientManager.logout()
|
|
154
|
+
|
|
155
|
+
// Manually authenticate (restore session)
|
|
156
|
+
const identity = await clientManager.authenticate()
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### State Subscriptions
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
// Subscribe to agent state changes
|
|
163
|
+
const unsubAgent = clientManager.subscribeAgentState((state) => {
|
|
164
|
+
console.log("Agent state:", state.isInitialized, state.network)
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
// Subscribe to auth state changes
|
|
168
|
+
const unsubAuth = clientManager.subscribeAuthState((state) => {
|
|
169
|
+
console.log("Auth state:", state.isAuthenticated, state.identity)
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// Subscribe to identity changes
|
|
173
|
+
const unsubIdentity = clientManager.subscribe((identity) => {
|
|
174
|
+
console.log("New identity:", identity.getPrincipal().toText())
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// Cleanup
|
|
178
|
+
unsubAgent()
|
|
179
|
+
unsubAuth()
|
|
180
|
+
unsubIdentity()
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Properties
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
clientManager.agent // HttpAgent instance
|
|
187
|
+
clientManager.agentState // { isInitialized, isInitializing, error, network, isLocalhost }
|
|
188
|
+
clientManager.authState // { identity, isAuthenticated, isAuthenticating, error }
|
|
189
|
+
clientManager.queryClient // TanStack QueryClient
|
|
190
|
+
clientManager.network // "ic" | "local"
|
|
191
|
+
clientManager.isLocal // boolean
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Reactor API
|
|
195
|
+
|
|
196
|
+
### Constructor Options
|
|
197
|
+
|
|
198
|
+
```typescript
|
|
199
|
+
interface ReactorParameters<A> {
|
|
200
|
+
clientManager: ClientManager
|
|
201
|
+
idlFactory: IDL.InterfaceFactory
|
|
202
|
+
canisterId: string | Principal
|
|
203
|
+
name?: string // Optional display name
|
|
204
|
+
pollingOptions?: PollingOptions // Custom polling for update calls
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Core Methods
|
|
209
|
+
|
|
210
|
+
```typescript
|
|
211
|
+
// Call a canister method (auto-detects query vs update)
|
|
212
|
+
const result = await reactor.callMethod({
|
|
213
|
+
functionName: "my_method",
|
|
214
|
+
args: [arg1, arg2],
|
|
215
|
+
callConfig: { effectiveCanisterId: ... }, // optional
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
// Fetch and cache data
|
|
219
|
+
const data = await reactor.fetchQuery({
|
|
220
|
+
functionName: "get_data",
|
|
221
|
+
args: [],
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Get cached data (synchronous, no network)
|
|
225
|
+
const cached = reactor.getQueryData({
|
|
226
|
+
functionName: "get_data",
|
|
227
|
+
args: [],
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
// Invalidate cached queries
|
|
231
|
+
reactor.invalidateQueries() // all queries for this canister
|
|
232
|
+
reactor.invalidateQueries({ functionName: "get_data" }) // specific method
|
|
233
|
+
reactor.invalidateQueries({ functionName: "get_user", args: ["user-1"] }) // specific args
|
|
234
|
+
|
|
235
|
+
// Get query options for TanStack Query
|
|
236
|
+
const options = reactor.getQueryOptions({ functionName: "get_data" })
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Properties
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
reactor.canisterId // Principal
|
|
243
|
+
reactor.service // IDL.ServiceClass
|
|
244
|
+
reactor.queryClient // TanStack QueryClient
|
|
245
|
+
reactor.agent // HttpAgent
|
|
246
|
+
reactor.name // string
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## DisplayReactor
|
|
250
|
+
|
|
251
|
+
`DisplayReactor` extends `Reactor` with automatic type transformations for UI-friendly values:
|
|
252
|
+
|
|
253
|
+
### Type Transformations
|
|
254
|
+
|
|
255
|
+
| Candid Type | Reactor (raw) | DisplayReactor |
|
|
256
|
+
| -------------------------------- | ------------- | -------------- |
|
|
257
|
+
| `nat`, `int` | `bigint` | `string` |
|
|
258
|
+
| `nat8/16/32/64`, `int8/16/32/64` | `bigint` | `string` |
|
|
259
|
+
| `Principal` | `Principal` | `string` |
|
|
260
|
+
| `vec nat8` (β€32 bytes) | `Uint8Array` | `string` (hex) |
|
|
261
|
+
| `Result<Ok, Err>` | Unwrapped | Unwrapped |
|
|
262
|
+
|
|
263
|
+
### Usage
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
import { DisplayReactor } from "@ic-reactor/core"
|
|
267
|
+
|
|
268
|
+
const backend = new DisplayReactor<_SERVICE>({
|
|
269
|
+
clientManager,
|
|
270
|
+
idlFactory,
|
|
271
|
+
canisterId: "rrkah-fqaaa-aaaaa-aaaaq-cai",
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Args and results use display-friendly types
|
|
275
|
+
const balance = await backend.callMethod({
|
|
276
|
+
functionName: "icrc1_balance_of",
|
|
277
|
+
args: [{ owner: "aaaaa-aa", subaccount: [] }], // string instead of Principal
|
|
278
|
+
})
|
|
279
|
+
// balance is "100000000" (string) instead of 100000000n (bigint)
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
### Form Validation
|
|
283
|
+
|
|
284
|
+
`DisplayReactor` supports validators for mutation arguments:
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { DisplayReactor, ValidationError } from "@ic-reactor/core"
|
|
288
|
+
|
|
289
|
+
const backend = new DisplayReactor<_SERVICE>({
|
|
290
|
+
clientManager,
|
|
291
|
+
idlFactory,
|
|
292
|
+
canisterId: "...",
|
|
293
|
+
validators: {
|
|
294
|
+
transfer: (args) => {
|
|
295
|
+
const [{ to, amount }] = args
|
|
296
|
+
const issues = []
|
|
297
|
+
|
|
298
|
+
if (!to || to.length < 5) {
|
|
299
|
+
issues.push({ path: ["to"], message: "Invalid recipient" })
|
|
300
|
+
}
|
|
301
|
+
if (!amount || parseFloat(amount) <= 0) {
|
|
302
|
+
issues.push({ path: ["amount"], message: "Amount must be positive" })
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return issues.length > 0 ? { success: false, issues } : { success: true }
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
// Validate before calling
|
|
311
|
+
const result = await backend.validate("transfer", [{ to: "", amount: "0" }])
|
|
312
|
+
if (!result.success) {
|
|
313
|
+
console.log(result.issues) // [{ path: ["to"], message: "Invalid recipient" }, ...]
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Or call with validation (throws ValidationError on failure)
|
|
317
|
+
try {
|
|
318
|
+
await backend.callMethodWithValidation({
|
|
319
|
+
functionName: "transfer",
|
|
320
|
+
args: [{ to: "", amount: "0" }],
|
|
321
|
+
})
|
|
322
|
+
} catch (error) {
|
|
323
|
+
if (error instanceof ValidationError) {
|
|
324
|
+
console.log(error.issues)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
## Error Handling
|
|
330
|
+
|
|
331
|
+
### Error Types
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
import {
|
|
335
|
+
CallError,
|
|
336
|
+
CanisterError,
|
|
337
|
+
ValidationError,
|
|
338
|
+
isCallError,
|
|
339
|
+
isCanisterError,
|
|
340
|
+
isValidationError,
|
|
341
|
+
} from "@ic-reactor/core"
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
| Error Type | Description |
|
|
345
|
+
| ------------------ | -------------------------------------------------------- |
|
|
346
|
+
| `CallError` | Network/agent errors (canister not found, timeout, etc.) |
|
|
347
|
+
| `CanisterError<E>` | Canister returned an `Err` result |
|
|
348
|
+
| `ValidationError` | Argument validation failed (DisplayReactor) |
|
|
349
|
+
|
|
350
|
+
### Handling Errors
|
|
351
|
+
|
|
352
|
+
```typescript
|
|
353
|
+
try {
|
|
354
|
+
await backend.callMethod({
|
|
355
|
+
functionName: "transfer",
|
|
356
|
+
args: [{ to: principal, amount: 100n }],
|
|
357
|
+
})
|
|
358
|
+
} catch (error) {
|
|
359
|
+
if (isCanisterError(error)) {
|
|
360
|
+
// Business logic error from canister
|
|
361
|
+
console.log("Canister error:", error.code, error.err)
|
|
362
|
+
// error.err is typed based on your Candid Result type
|
|
363
|
+
} else if (isCallError(error)) {
|
|
364
|
+
// Network/agent error
|
|
365
|
+
console.log("Network error:", error.message)
|
|
366
|
+
} else if (isValidationError(error)) {
|
|
367
|
+
// Validation error (DisplayReactor only)
|
|
368
|
+
console.log("Validation failed:", error.issues)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
### CanisterError Properties
|
|
374
|
+
|
|
375
|
+
```typescript
|
|
376
|
+
interface CanisterError<E> {
|
|
377
|
+
err: E // The raw error value from canister
|
|
378
|
+
code: string // Error code (from variant key or "code" field)
|
|
379
|
+
message: string // Human-readable message
|
|
380
|
+
details?: Map<string, string> // Optional details
|
|
381
|
+
}
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
## Utilities
|
|
385
|
+
|
|
386
|
+
### Result Unwrapping
|
|
387
|
+
|
|
388
|
+
Results are automatically unwrapped. The `extractOkResult` utility handles both uppercase (`Ok`/`Err`) and lowercase (`ok`/`err`) variants:
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
import { extractOkResult } from "@ic-reactor/core"
|
|
392
|
+
|
|
393
|
+
// Candid: Result<Text, TransferError>
|
|
394
|
+
// Returns the Ok value or throws CanisterError with the Err value
|
|
395
|
+
const result = extractOkResult({ Ok: "success" }) // "success"
|
|
396
|
+
const result2 = extractOkResult({ ok: "success" }) // "success"
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
### Query Key Generation
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
const queryKey = reactor.generateQueryKey({
|
|
403
|
+
functionName: "get_user",
|
|
404
|
+
args: ["user-123"],
|
|
405
|
+
})
|
|
406
|
+
// ["canister-id", "get_user", "serialized-args"]
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
## TypeScript Types
|
|
410
|
+
|
|
411
|
+
### Actor Types
|
|
412
|
+
|
|
413
|
+
```typescript
|
|
414
|
+
import type {
|
|
415
|
+
FunctionName, // Method names from actor service
|
|
416
|
+
ActorMethodParameters, // Parameter types for a method
|
|
417
|
+
ActorMethodReturnType, // Return type for a method
|
|
418
|
+
ReactorArgs, // Args with optional transforms
|
|
419
|
+
ReactorReturnOk, // Return type (Ok extracted from Result)
|
|
420
|
+
ReactorReturnErr, // Error type (Err from Result)
|
|
421
|
+
} from "@ic-reactor/core"
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
### State Types
|
|
425
|
+
|
|
426
|
+
```typescript
|
|
427
|
+
import type { AgentState, AuthState } from "@ic-reactor/core"
|
|
428
|
+
|
|
429
|
+
interface AgentState {
|
|
430
|
+
isInitialized: boolean
|
|
431
|
+
isInitializing: boolean
|
|
432
|
+
error: Error | undefined
|
|
433
|
+
network: "ic" | "local" | undefined
|
|
434
|
+
isLocalhost: boolean
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
interface AuthState {
|
|
438
|
+
identity: Identity | null
|
|
439
|
+
isAuthenticated: boolean
|
|
440
|
+
isAuthenticating: boolean
|
|
441
|
+
error: Error | undefined
|
|
442
|
+
}
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
## Advanced Usage
|
|
446
|
+
|
|
447
|
+
### Multiple Canisters
|
|
448
|
+
|
|
449
|
+
```typescript
|
|
450
|
+
const clientManager = new ClientManager({ queryClient, withProcessEnv: true })
|
|
451
|
+
|
|
452
|
+
// All reactors share the same agent and authentication
|
|
453
|
+
const backend = new Reactor<Backend>({
|
|
454
|
+
clientManager,
|
|
455
|
+
idlFactory: backendIdl,
|
|
456
|
+
canisterId: "...",
|
|
457
|
+
})
|
|
458
|
+
const ledger = new DisplayReactor<Ledger>({
|
|
459
|
+
clientManager,
|
|
460
|
+
idlFactory: ledgerIdl,
|
|
461
|
+
canisterId: "...",
|
|
462
|
+
})
|
|
463
|
+
const nft = new Reactor<NFT>({
|
|
464
|
+
clientManager,
|
|
465
|
+
idlFactory: nftIdl,
|
|
466
|
+
canisterId: "...",
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// Login once, all canisters use the same identity
|
|
470
|
+
await clientManager.login()
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
### Custom Polling Options
|
|
474
|
+
|
|
475
|
+
```typescript
|
|
476
|
+
const backend = new Reactor<_SERVICE>({
|
|
477
|
+
clientManager,
|
|
478
|
+
idlFactory,
|
|
479
|
+
canisterId: "...",
|
|
480
|
+
pollingOptions: {
|
|
481
|
+
maxRetries: 5,
|
|
482
|
+
strategyFactory: () => /* custom strategy */,
|
|
483
|
+
},
|
|
484
|
+
})
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
### Direct Agent Access
|
|
488
|
+
|
|
489
|
+
```typescript
|
|
490
|
+
// Get subnet ID
|
|
491
|
+
const subnetId = await backend.subnetId()
|
|
492
|
+
|
|
493
|
+
// Read subnet state
|
|
494
|
+
const state = await backend.subnetState({ paths: [...] })
|
|
495
|
+
|
|
496
|
+
// Access underlying agent
|
|
497
|
+
const agent = backend.agent
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
## Documentation
|
|
501
|
+
|
|
502
|
+
For comprehensive guides and API reference, visit the [documentation site](https://b3pay.github.io/ic-reactor/v3).
|
|
503
|
+
|
|
504
|
+
## License
|
|
505
|
+
|
|
506
|
+
MIT Β© [Behrad Deylami](https://github.com/b3hr4d)
|