create-sia-app 0.1.11 → 0.1.13

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/dist/index.js CHANGED
@@ -54,7 +54,7 @@ async function runPrompts() {
54
54
  }
55
55
  const indexerUrl = await p.text({
56
56
  message: "Indexer URL",
57
- initialValue: "https://app.sia.storage"
57
+ initialValue: "https://sia.storage"
58
58
  });
59
59
  if (p.isCancel(indexerUrl)) {
60
60
  p.cancel("Cancelled.");
@@ -173,7 +173,7 @@ function getDefaultOptions(projectName) {
173
173
  return {
174
174
  projectName,
175
175
  appKey: crypto2.randomBytes(32).toString("hex"),
176
- indexerUrl: "https://app.sia.storage",
176
+ indexerUrl: "https://sia.storage",
177
177
  appDescription: "A Sia storage app"
178
178
  };
179
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-sia-app",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "create-sia-app": "./dist/index.js"
@@ -2,9 +2,9 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- A starter template for building decentralized storage apps on the Sia network. Uses `@siafoundation/sia` — a TypeScript SDK that ships a pre-compiled WASM binary for encryption, uploads, downloads, and key management.
5
+ A starter template for building decentralized storage apps on the Sia network. Uses [`sia-storage`](https://www.npmjs.com/package/sia-storage) — a TypeScript SDK that ships a pre-compiled WASM binary for encryption, uploads, downloads, and key management. WASM runs on the main thread (Rust async — no workers required).
6
6
 
7
- **Tech stack:** React 19, TypeScript, Vite, Tailwind CSS 4, Zustand, `@siafoundation/sia`
7
+ **Tech stack:** React 19, TypeScript, Vite, Tailwind CSS 4, Zustand, `sia-storage`
8
8
 
9
9
  ## Architecture
10
10
 
@@ -17,79 +17,93 @@ loading → connect → approve → recovery → connected
17
17
  ```
18
18
 
19
19
  - **loading**: WASM initializes, checks for stored app key
20
- - **connect**: User enters indexer URL, app requests connection
21
- - **approve**: User visits approval URL in another tab
22
- - **recovery**: User generates or enters 12-word recovery phrase
23
- - **connected**: SDK is ready, main app UI renders
20
+ - **connect**: User enters indexer URL, app constructs a `Builder` and calls `requestConnection()`
21
+ - **approve**: User visits approval URL in another tab; app polls `builder.waitForApproval()`
22
+ - **recovery**: User generates or enters a 12-word BIP-39 recovery phrase; `builder.register(phrase)` returns the `Sdk`
23
+ - **connected**: `Sdk` is ready, main app UI renders
24
+
25
+ ### Returning users
26
+
27
+ Returning users skip `requestConnection`/`waitForApproval`/`register` entirely. `AuthFlow` constructs a `Builder` and calls `builder.connected(appKey)` with the persisted key — it returns an `Sdk` if the key is still valid, or `undefined` to fall back to the `connect` step.
24
28
 
25
29
  ### SDK
26
30
 
27
- The SDK (`@siafoundation/sia`) is an npm package that handles:
31
+ `sia-storage` handles:
28
32
  - Encrypted file uploads/downloads (erasure coding + encryption)
29
33
  - Key derivation from recovery phrases (BIP-39)
30
34
  - Object pinning and metadata management
31
35
  - Connection auth with indexers
32
- - Parallel multi-worker uploads and downloads
36
+ - Direct streaming to/from Sia hosts (no worker pool needed)
33
37
 
34
38
  ### Zustand Persistence
35
39
 
36
- Auth state persists to localStorage via Zustand's `persist` middleware. The storage key is `{app-name}-auth`. Persisted fields: `storedKeyHex`, `indexerUrl`.
40
+ Auth state persists to localStorage via Zustand's `persist` middleware. The storage key is `{app-name}-auth`. Persisted fields: `storedKeyHex`, `indexerUrl`. The app key is stored as hex via the TC39 `Uint8Array.prototype.toHex` method.
37
41
 
38
42
  ## Key Files
39
43
 
40
44
  | File | Description |
41
45
  |------|-------------|
42
- | `src/lib/constants.ts` | App key, app name, indexer URL, app metadata |
43
- | `src/stores/auth.ts` | Auth state machine (Zustand + persist) |
46
+ | `src/lib/constants.ts` | App key, app name, indexer URL, app metadata (typed `AppMetadata`) |
47
+ | `src/stores/auth.ts` | Auth state machine (Zustand + persist), holds the `Sdk` |
44
48
  | `src/stores/toast.ts` | Toast notification store (auto-dismiss) |
45
49
  | `src/components/Navbar.tsx` | App navbar with title, public key, sign out |
46
50
  | `src/components/Toast.tsx` | Toast overlay component |
47
51
  | `src/components/CopyButton.tsx` | Copy-to-clipboard button with toast |
48
- | `src/components/auth/AuthFlow.tsx` | Auth orchestrator |
49
- | `src/components/auth/ConnectScreen.tsx` | Indexer connection + CORS fallback |
52
+ | `src/components/auth/AuthFlow.tsx` | Auth orchestrator — `initSia()`, returning-user reconnect via `Builder.connected` |
53
+ | `src/components/auth/ConnectScreen.tsx` | Indexer URL form; constructs `new Builder(url, APP_META)` and calls `requestConnection()` |
50
54
  | `src/components/auth/ApproveScreen.tsx` | Approval polling (auto-polls on mount) |
51
- | `src/components/auth/RecoveryScreen.tsx` | Recovery phrase generation/import |
55
+ | `src/components/auth/RecoveryScreen.tsx` | Recovery phrase generation/import; `builder.register(phrase)` → `Sdk` |
52
56
  | `src/components/upload/UploadZone.tsx` | File upload/download dropzone + file list |
53
- | `src/components/DevNote.tsx` | Developer callout component (unused, available for customization) |
57
+ | `src/components/DevNote.tsx` | Developer callout component |
58
+ | `src/types/uint8array-hex.d.ts` | Ambient types for TC39 `Uint8Array.toHex`/`fromHex` (drop once TS lib ships them) |
54
59
 
55
60
  ## SDK Usage Patterns
56
61
 
57
62
  ### Upload a file
58
63
 
59
64
  ```ts
60
- const pinnedObject = await client.upload(file, (progress) => {
61
- // progress.phase: 'connecting' | 'uploading' | 'assembling' | 'pinning'
62
- console.log(`${progress.slabsComplete}/${progress.slabsTotal} slabs`)
65
+ import { PinnedObject } from 'sia-storage'
66
+
67
+ const object = new PinnedObject()
68
+ const pinnedObject = await sdk.upload(object, file.stream(), {
69
+ maxInflight: 10,
70
+ onShardUploaded: (progress) => {
71
+ // progress: { hostKey, shardSize, shardIndex, slabIndex, elapsedMs }
72
+ console.log(`shard ${progress.shardIndex} uploaded (${progress.shardSize}b)`)
73
+ },
63
74
  })
75
+
64
76
  pinnedObject.updateMetadata(new TextEncoder().encode(JSON.stringify({
65
77
  name: 'file.txt',
66
78
  type: 'text/plain',
67
79
  size: file.size,
68
80
  hash: '...',
69
81
  })))
70
- await client.pinObject(pinnedObject)
71
- await client.updateObjectMetadata(pinnedObject)
82
+ await sdk.pinObject(pinnedObject)
83
+ await sdk.updateObjectMetadata(pinnedObject)
72
84
  ```
73
85
 
74
86
  ### Download a file
75
87
 
88
+ `sdk.download` returns a `ReadableStream` of `Uint8Array` chunks. Buffer it, or stream it directly into a `Response`/`Blob`:
89
+
76
90
  ```ts
77
- const data = await client.download(pinnedObject, (progress) => {
78
- // progress.phase: 'connecting' | 'downloading' | 'assembling'
79
- console.log(`${progress.slabsComplete}/${progress.slabsTotal} slabs`)
91
+ const stream = sdk.download(pinnedObject, {
92
+ maxInflight: 10,
93
+ onShardDownloaded: (progress) => {
94
+ console.log(`shard ${progress.shardIndex} downloaded`)
95
+ },
80
96
  })
81
- // data is Uint8Array
97
+ const blob = await new Response(stream).blob()
82
98
  ```
83
99
 
84
100
  ### List files
85
101
 
86
102
  ```ts
87
- import { decodeMetadata } from '@siafoundation/sia'
88
-
89
- const events = await client.objectEvents(null, 100)
103
+ const events = await sdk.objectEvents(undefined, 100)
90
104
  for (const event of events) {
91
105
  if (!event.deleted && event.object) {
92
- const meta = decodeMetadata(event.object.metadata())
106
+ const meta = JSON.parse(new TextDecoder().decode(event.object.metadata()))
93
107
  console.log(meta.name, event.object.size())
94
108
  }
95
109
  }
@@ -98,21 +112,21 @@ for (const event of events) {
98
112
  ### Delete a file
99
113
 
100
114
  ```ts
101
- await client.deleteObject(objectId)
115
+ await sdk.deleteObject(objectId)
102
116
  ```
103
117
 
104
118
  ### Share a file
105
119
 
106
120
  ```ts
107
- const validUntil = Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days
108
- const shareUrl = client.shareObject(pinnedObject, validUntil)
121
+ const validUntil = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
122
+ const shareUrl = sdk.shareObject(pinnedObject, validUntil)
109
123
  ```
110
124
 
111
- ### Download shared file
125
+ ### Download a shared file
112
126
 
113
127
  ```ts
114
- const object = await client.sharedObject(shareUrl)
115
- const data = await client.download(object)
128
+ const object = await sdk.sharedObject(shareUrl)
129
+ const stream = sdk.download(object)
116
130
  ```
117
131
 
118
132
  ## Customization
@@ -122,12 +136,12 @@ const data = await client.download(object)
122
136
  Edit `src/lib/constants.ts`. The app key is a 32-byte hex string that identifies your app to the indexer. Generate one with:
123
137
 
124
138
  ```ts
125
- crypto.randomBytes(32).toString('hex')
139
+ crypto.getRandomValues(new Uint8Array(32)).toHex()
126
140
  ```
127
141
 
128
142
  ### Replace the upload UI
129
143
 
130
- The post-auth UI is rendered in `src/App.tsx`. Replace `<UploadZone />` with your own component. The `SiaClient` is available via `useAuthStore((s) => s.client)`. `SiaClient` exposes all SDK methods directly — use `client.upload()`, `client.download()`, `client.objectEvents()`, etc.
144
+ The post-auth UI is rendered in `src/App.tsx`. Replace `<UploadZone />` with your own component. The `Sdk` is available via `useAuthStore((s) => s.sdk)`. All SDK methods live directly on `Sdk` — `sdk.upload()`, `sdk.download()`, `sdk.objectEvents()`, etc.
131
145
 
132
146
  ### Add routes
133
147
 
@@ -45,14 +45,14 @@ Indexer service
45
45
 
46
46
  ### Auth Flow
47
47
 
48
- 1. **Connect** — Enter an indexer URL (default: `https://app.sia.storage`). The app sends your app metadata to request a connection.
48
+ 1. **Connect** — Enter an indexer URL (default: `https://sia.storage`). The app sends your app metadata to request a connection.
49
49
  2. **Approve** — Visit the approval URL in another tab to authorize your app.
50
50
  3. **Recovery Phrase** — Generate a new 12-word phrase or enter an existing one. This deterministically derives all cryptographic keys.
51
51
  4. **Connected** — The SDK is ready. Your app key is saved to localStorage for future sessions.
52
52
 
53
53
  ### Upload Flow
54
54
 
55
- Files are encrypted in the browser, erasure coded into shards, and uploaded directly to Sia hosts. The SDK handles all of this — your code just calls `client.upload(file, onProgress)`. Metadata (filename, type, size) is also encrypted and pinned to the indexer.
55
+ Files are encrypted in the browser, erasure coded into shards, and streamed directly to Sia hosts. The SDK handles all of this — your code just calls `sdk.upload(object, file.stream(), { onShardUploaded })`. Metadata (filename, type, size) is also encrypted and pinned to the indexer.
56
56
 
57
57
  ## Customization
58
58
 
@@ -65,7 +65,7 @@ Edit `src/lib/constants.ts` to set your app key, name, description, and indexer
65
65
  The main post-auth component is `src/components/upload/UploadZone.tsx`. Replace it with your own UI — the SDK is available via:
66
66
 
67
67
  ```tsx
68
- const client = useAuthStore((s) => s.client)
68
+ const sdk = useAuthStore((s) => s.sdk)
69
69
  ```
70
70
 
71
71
  ## Tech Stack
@@ -76,14 +76,15 @@ const client = useAuthStore((s) => s.client)
76
76
  - [Tailwind CSS](https://tailwindcss.com) 4
77
77
  - [Zustand](https://zustand.docs.pmnd.rs) (state management)
78
78
  - [Biome](https://biomejs.dev) (linting & formatting)
79
- - [@siafoundation/sia](https://www.npmjs.com/package/@siafoundation/sia) (Sia SDK — encryption, erasure coding, host transfers)
79
+ - [sia-storage](https://www.npmjs.com/package/sia-storage) (Sia SDK — encryption, erasure coding, direct host transfers via WASM)
80
80
 
81
81
  ## Project Structure
82
82
 
83
83
  ```
84
84
  src/
85
85
  ├── lib/ # Constants, utilities
86
- ├── stores/ # Zustand auth store
86
+ ├── stores/ # Zustand stores (auth + toast)
87
+ ├── types/ # Ambient type declarations (TC39 Uint8Array.toHex/fromHex)
87
88
  └── components/
88
89
  ├── auth/ # Auth flow screens
89
90
  ├── upload/ # Upload dropzone
@@ -93,4 +94,4 @@ src/
93
94
  ## Learn More
94
95
 
95
96
  - [Sia Documentation](https://docs.sia.tech)
96
- - [@siafoundation/sia](https://www.npmjs.com/package/@siafoundation/sia)
97
+ - [sia-storage](https://www.npmjs.com/package/sia-storage)
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.4.1/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
3
3
  "vcs": {
4
4
  "enabled": false,
5
5
  "clientKind": "git",
@@ -10,21 +10,20 @@
10
10
  "check": "biome check ."
11
11
  },
12
12
  "dependencies": {
13
- "@siafoundation/sia": "^0.6.2",
14
13
  "react": "^19.2.0",
15
14
  "react-dom": "^19.2.0",
15
+ "sia-storage": "^0.0.8",
16
16
  "zustand": "^5.0.11"
17
17
  },
18
18
  "devDependencies": {
19
- "@biomejs/biome": "^2.4.0",
19
+ "@biomejs/biome": "^2.4.12",
20
+ "@playwright/test": "^1.59.1",
20
21
  "@tailwindcss/vite": "^4.1.18",
21
22
  "@types/react": "^19.2.7",
22
23
  "@types/react-dom": "^19.2.3",
23
24
  "@vitejs/plugin-react": "^5.1.1",
24
25
  "tailwindcss": "^4.1.18",
25
26
  "typescript": "~5.9.3",
26
- "vite": "^7.3.1",
27
- "vite-plugin-top-level-await": "^1.6.0",
28
- "vite-plugin-wasm": "^3.5.0"
27
+ "vite": "^7.3.1"
29
28
  }
30
29
  }
@@ -5,17 +5,17 @@ import { CopyButton } from './CopyButton'
5
5
 
6
6
  export function Navbar() {
7
7
  const step = useAuthStore((s) => s.step)
8
- const client = useAuthStore((s) => s.client)
8
+ const sdk = useAuthStore((s) => s.sdk)
9
9
  const reset = useAuthStore((s) => s.reset)
10
10
  const isConnected = step === 'connected'
11
11
 
12
12
  const publicKey = useMemo(() => {
13
13
  try {
14
- return client?.appKey().publicKey() ?? null
14
+ return sdk?.appKey().publicKey() ?? null
15
15
  } catch {
16
16
  return null
17
17
  }
18
- }, [client])
18
+ }, [sdk])
19
19
 
20
20
  function handleSignOut() {
21
21
  reset()
@@ -1,5 +1,5 @@
1
- import type { Builder } from '@siafoundation/sia'
2
1
  import { useEffect, useRef, useState } from 'react'
2
+ import type { Builder } from 'sia-storage'
3
3
  import { useAuthStore } from '../../stores/auth'
4
4
  import { CopyButton } from '../CopyButton'
5
5
  import { DevNote } from '../DevNote'
@@ -1,11 +1,6 @@
1
- import {
2
- AppKey,
3
- type Builder,
4
- connect,
5
- fromHex,
6
- initSia,
7
- } from '@siafoundation/sia'
8
1
  import { useEffect, useRef } from 'react'
2
+ import { AppKey, Builder, initSia } from 'sia-storage'
3
+ import { APP_META } from '../../lib/constants'
9
4
  import { useAuthStore } from '../../stores/auth'
10
5
  import { ApproveScreen } from './ApproveScreen'
11
6
  import { ConnectScreen } from './ConnectScreen'
@@ -22,19 +17,19 @@ export function AuthFlow() {
22
17
  let cancelled = false
23
18
 
24
19
  async function init() {
25
- const { storedKeyHex, indexerUrl, setClient, setStep } =
20
+ const { storedKeyHex, indexerUrl, setSdk, setStep } =
26
21
  useAuthStore.getState()
27
22
  try {
28
23
  await initSia()
29
24
 
30
25
  if (storedKeyHex && indexerUrl) {
31
- const keyBytes = fromHex(storedKeyHex)
32
- const appKey = new AppKey(keyBytes)
33
- const client = await connect(indexerUrl, appKey)
26
+ const appKey = new AppKey(Uint8Array.fromHex(storedKeyHex))
27
+ const builder = new Builder(indexerUrl, APP_META)
28
+ const sdk = await builder.connected(appKey)
34
29
 
35
30
  if (cancelled) return
36
- if (client) {
37
- setClient(client)
31
+ if (sdk) {
32
+ setSdk(sdk)
38
33
  return
39
34
  }
40
35
  }
@@ -1,6 +1,6 @@
1
- import { Builder } from '@siafoundation/sia'
2
1
  import { useState } from 'react'
3
- import { APP_KEY, APP_META, DEFAULT_INDEXER_URL } from '../../lib/constants'
2
+ import { Builder } from 'sia-storage'
3
+ import { APP_META, DEFAULT_INDEXER_URL } from '../../lib/constants'
4
4
  import { useAuthStore } from '../../stores/auth'
5
5
  import { DevNote } from '../DevNote'
6
6
 
@@ -13,24 +13,26 @@ export function ConnectScreen({
13
13
  useAuthStore()
14
14
  const [url, setUrl] = useState(indexerUrl || DEFAULT_INDEXER_URL)
15
15
  const [loading, setLoading] = useState(false)
16
- const [showCurl, setShowCurl] = useState(false)
17
- const [curlResponse, setCurlResponse] = useState('')
18
16
 
19
17
  async function handleConnect() {
20
18
  setLoading(true)
21
19
  setError(null)
22
20
  try {
23
- const b = new Builder(url)
21
+ const b = new Builder(url, APP_META)
24
22
  builder.current = b
25
23
  setIndexerUrl(url)
26
24
 
27
25
  try {
28
- await b.requestConnection(APP_META)
26
+ await b.requestConnection()
29
27
  const approvalUrl = b.responseUrl()
30
28
  setApprovalUrl(approvalUrl)
31
29
  setStep('approve')
32
- } catch {
33
- setShowCurl(true)
30
+ } catch (e) {
31
+ setError(
32
+ e instanceof Error
33
+ ? `Connection failed: ${e.message}. Check the indexer URL and that it allows requests from this origin (CORS).`
34
+ : 'Connection failed. Check the indexer URL and CORS configuration.',
35
+ )
34
36
  }
35
37
  } catch (e) {
36
38
  setError(e instanceof Error ? e.message : 'Failed to connect')
@@ -39,26 +41,6 @@ export function ConnectScreen({
39
41
  }
40
42
  }
41
43
 
42
- function handleCurlSubmit() {
43
- try {
44
- const b = builder.current
45
- if (!b) {
46
- setError('No builder instance')
47
- return
48
- }
49
- b.setConnectionResponse(APP_KEY, curlResponse)
50
- const approvalUrl = b.responseUrl()
51
- setApprovalUrl(approvalUrl)
52
- setStep('approve')
53
- } catch (e) {
54
- setError(e instanceof Error ? e.message : 'Invalid response')
55
- }
56
- }
57
-
58
- const curlCommand = `curl -X POST ${url}/auth/connect \\
59
- -H "Content-Type: application/json" \\
60
- -d '${APP_META}'`
61
-
62
44
  return (
63
45
  <div className="flex flex-col items-center justify-center flex-1 px-4">
64
46
  <div className="w-full max-w-md space-y-6">
@@ -74,14 +56,14 @@ export function ConnectScreen({
74
56
  <DevNote title="Indexer URL & App Key">
75
57
  <p>
76
58
  The indexer URL points to your Sia storage provider. The default is{' '}
77
- <code className="text-amber-300">https://app.sia.storage</code>.
78
- Your app key (set in{' '}
59
+ <code className="text-amber-300">https://sia.storage</code>. Your
60
+ app key (set in{' '}
79
61
  <code className="text-amber-300">src/lib/constants.ts</code>)
80
62
  uniquely identifies your app to the indexer.
81
63
  </p>
82
64
  <p className="mt-1">
83
- If the direct connection fails (CORS), the app falls back to a
84
- manual curl flow where the user pastes the response.
65
+ If the connection fails with a CORS error, the indexer must allow
66
+ requests from this app&apos;s origin.
85
67
  </p>
86
68
  </DevNote>
87
69
 
@@ -90,7 +72,7 @@ export function ConnectScreen({
90
72
  type="url"
91
73
  value={url}
92
74
  onChange={(e) => setUrl(e.target.value)}
93
- placeholder="https://app.sia.storage"
75
+ placeholder="https://sia.storage"
94
76
  className="w-full px-4 py-3 bg-neutral-900 border border-neutral-700 rounded-lg text-white placeholder-neutral-500 focus:outline-none focus:border-green-500"
95
77
  />
96
78
 
@@ -103,33 +85,6 @@ export function ConnectScreen({
103
85
  {loading ? 'Connecting...' : 'Connect'}
104
86
  </button>
105
87
  </div>
106
-
107
- {showCurl && (
108
- <div className="space-y-4 p-4 bg-neutral-900 rounded-lg border border-neutral-700">
109
- <p className="text-sm text-neutral-300">
110
- Direct connection failed (CORS). Run this command and paste the
111
- response:
112
- </p>
113
- <pre className="text-xs bg-neutral-950 p-3 rounded overflow-x-auto text-green-400">
114
- {curlCommand}
115
- </pre>
116
- <textarea
117
- value={curlResponse}
118
- onChange={(e) => setCurlResponse(e.target.value)}
119
- placeholder="Paste the JSON response here..."
120
- rows={4}
121
- className="w-full px-3 py-2 bg-neutral-950 border border-neutral-700 rounded text-sm text-white placeholder-neutral-500 focus:outline-none focus:border-green-500 font-mono"
122
- />
123
- <button
124
- type="button"
125
- onClick={handleCurlSubmit}
126
- disabled={!curlResponse}
127
- className="w-full py-2 bg-green-600 hover:bg-green-700 disabled:bg-neutral-700 disabled:text-neutral-500 text-white text-sm font-medium rounded-lg transition-colors"
128
- >
129
- Submit Response
130
- </button>
131
- </div>
132
- )}
133
88
  </div>
134
89
  </div>
135
90
  )
@@ -1,10 +1,9 @@
1
+ import { useState } from 'react'
1
2
  import {
2
3
  type Builder,
3
4
  generateRecoveryPhrase,
4
- toHex,
5
5
  validateRecoveryPhrase,
6
- } from '@siafoundation/sia'
7
- import { useState } from 'react'
6
+ } from 'sia-storage'
8
7
  import { useAuthStore } from '../../stores/auth'
9
8
  import { CopyButton } from '../CopyButton'
10
9
  import { DevNote } from '../DevNote'
@@ -14,7 +13,7 @@ export function RecoveryScreen({
14
13
  }: {
15
14
  builder: React.RefObject<Builder | null>
16
15
  }) {
17
- const { setClient, setStoredKeyHex, setError } = useAuthStore()
16
+ const { setSdk, setStoredKeyHex, setError } = useAuthStore()
18
17
  const [mode, setMode] = useState<'choose' | 'generate' | 'import'>('choose')
19
18
  const [phrase, setPhrase] = useState('')
20
19
  const [generatedPhrase, setGeneratedPhrase] = useState('')
@@ -57,11 +56,9 @@ export function RecoveryScreen({
57
56
 
58
57
  setLoading(true)
59
58
  try {
60
- const client = await b.register(mnemonic)
61
- const appKey = client.appKey()
62
- const exported = appKey.export()
63
- setStoredKeyHex(toHex(exported))
64
- setClient(client)
59
+ const sdk = await b.register(mnemonic)
60
+ setStoredKeyHex(sdk.appKey().export().toHex())
61
+ setSdk(sdk)
65
62
  } catch (e) {
66
63
  setError(e instanceof Error ? e.message : 'Registration failed')
67
64
  } finally {
@@ -1,11 +1,5 @@
1
- import {
2
- type DownloadProgress,
3
- decodeMetadata,
4
- type PinnedObject,
5
- toHex,
6
- type UploadProgress,
7
- } from '@siafoundation/sia'
8
1
  import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { PinnedObject, type ShardProgress } from 'sia-storage'
9
3
  import { APP_KEY } from '../../lib/constants'
10
4
  import { useAuthStore } from '../../stores/auth'
11
5
  import { DevNote } from '../DevNote'
@@ -27,26 +21,40 @@ function formatBytes(bytes: number): string {
27
21
  return `${parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`
28
22
  }
29
23
 
24
+ function decodeMetadata(bytes: Uint8Array): FileMetadata | null {
25
+ try {
26
+ return JSON.parse(new TextDecoder().decode(bytes)) as FileMetadata
27
+ } catch {
28
+ return null
29
+ }
30
+ }
31
+
30
32
  type UploadedFile = {
31
33
  id: string
32
34
  metadata: FileMetadata
33
35
  object: PinnedObject
34
36
  }
35
37
 
36
- type ActiveUpload = {
38
+ type UploadProgress = {
37
39
  fileName: string
38
- progress: UploadProgress
40
+ shardsDone: number
41
+ bytesUploaded: number
42
+ totalBytes: number
43
+ }
44
+
45
+ type DownloadProgress = {
46
+ shardsDone: number
47
+ bytesDownloaded: number
48
+ totalBytes: number
39
49
  }
40
50
 
41
- // Detect unconfigured template placeholder. The string is split so the
42
- // scaffolder's {{APP_KEY}} replacement doesn't rewrite this check.
43
51
  const isPlaceholderKey = APP_KEY.startsWith('{' + '{')
44
52
 
45
53
  export function UploadZone() {
46
- const client = useAuthStore((s) => s.client)
54
+ const sdk = useAuthStore((s) => s.sdk)
47
55
  const [files, setFiles] = useState<UploadedFile[]>([])
48
56
  const [uploading, setUploading] = useState(false)
49
- const [activeUpload, setActiveUpload] = useState<ActiveUpload | null>(null)
57
+ const [activeUpload, setActiveUpload] = useState<UploadProgress | null>(null)
50
58
  const [dragOver, setDragOver] = useState(false)
51
59
  const [error, setError] = useState<string | null>(null)
52
60
  const [downloading, setDownloading] = useState<string | null>(null)
@@ -55,14 +63,14 @@ export function UploadZone() {
55
63
  const fileInputRef = useRef<HTMLInputElement>(null)
56
64
 
57
65
  const loadFiles = useCallback(async () => {
58
- if (!client) return
66
+ if (!sdk) return
59
67
  try {
60
- const events = await client.objectEvents(null, 100)
68
+ const events = await sdk.objectEvents(undefined, 100)
61
69
  const loaded: UploadedFile[] = []
62
70
  for (const event of events) {
63
71
  if (event.deleted || !event.object) continue
64
- const meta = decodeMetadata(event.object.metadata()) as FileMetadata
65
- if (meta.name) {
72
+ const meta = decodeMetadata(event.object.metadata())
73
+ if (meta?.name) {
66
74
  loaded.push({ id: event.id, metadata: meta, object: event.object })
67
75
  }
68
76
  }
@@ -70,37 +78,45 @@ export function UploadZone() {
70
78
  } catch (e) {
71
79
  console.error('Failed to load files:', e)
72
80
  }
73
- }, [client])
81
+ }, [sdk])
74
82
 
75
83
  useEffect(() => {
76
84
  loadFiles()
77
85
  }, [loadFiles])
78
86
 
79
87
  async function uploadFile(file: File) {
80
- if (!client) return
88
+ if (!sdk) return
81
89
  setUploading(true)
82
90
  setError(null)
83
91
  setActiveUpload({
84
92
  fileName: file.name,
85
- progress: {
86
- phase: 'connecting',
87
- slabsComplete: 0,
88
- slabsTotal: 0,
89
- shardsComplete: 0,
90
- shardsTotal: 0,
91
- },
93
+ shardsDone: 0,
94
+ bytesUploaded: 0,
95
+ totalBytes: file.size,
92
96
  })
93
97
 
94
98
  try {
95
- // Compute SHA-256 hash for metadata
96
99
  const hashBuffer = await crypto.subtle.digest(
97
100
  'SHA-256',
98
101
  await file.arrayBuffer(),
99
102
  )
100
- const hash = toHex(new Uint8Array(hashBuffer))
103
+ const hash = new Uint8Array(hashBuffer).toHex()
101
104
 
102
- const pinnedObject = await client.upload(file, (p) => {
103
- setActiveUpload({ fileName: file.name, progress: p })
105
+ const object = new PinnedObject()
106
+ let shardsDone = 0
107
+ let bytesUploaded = 0
108
+ const pinnedObject = await sdk.upload(object, file.stream(), {
109
+ maxInflight: 10,
110
+ onShardUploaded: (progress: ShardProgress) => {
111
+ shardsDone++
112
+ bytesUploaded += progress.shardSize
113
+ setActiveUpload({
114
+ fileName: file.name,
115
+ shardsDone,
116
+ bytesUploaded,
117
+ totalBytes: file.size,
118
+ })
119
+ },
104
120
  })
105
121
 
106
122
  const metadata: FileMetadata = {
@@ -114,8 +130,8 @@ export function UploadZone() {
114
130
  pinnedObject.updateMetadata(
115
131
  new TextEncoder().encode(JSON.stringify(metadata)),
116
132
  )
117
- await client.pinObject(pinnedObject)
118
- await client.updateObjectMetadata(pinnedObject)
133
+ await sdk.pinObject(pinnedObject)
134
+ await sdk.updateObjectMetadata(pinnedObject)
119
135
 
120
136
  setFiles((prev) => [
121
137
  { id: pinnedObject.id(), metadata, object: pinnedObject },
@@ -130,14 +146,43 @@ export function UploadZone() {
130
146
  }
131
147
 
132
148
  async function downloadFile(file: UploadedFile) {
133
- if (!client) return
149
+ if (!sdk) return
134
150
  setDownloading(file.id)
135
- setDownloadProgress(null)
151
+ setDownloadProgress({
152
+ shardsDone: 0,
153
+ bytesDownloaded: 0,
154
+ totalBytes: file.metadata.size,
155
+ })
136
156
  try {
137
- const data = await client.download(file.object, (p) => {
138
- setDownloadProgress(p)
157
+ let shardsDone = 0
158
+ const stream = sdk.download(file.object, {
159
+ maxInflight: 10,
160
+ onShardDownloaded: () => {
161
+ shardsDone++
162
+ setDownloadProgress((prev) => ({
163
+ shardsDone,
164
+ bytesDownloaded: prev?.bytesDownloaded ?? 0,
165
+ totalBytes: file.metadata.size,
166
+ }))
167
+ },
139
168
  })
140
- const blob = new Blob([data as BlobPart], { type: file.metadata.type })
169
+
170
+ const reader = stream.getReader()
171
+ const chunks: Uint8Array[] = []
172
+ let bytesDownloaded = 0
173
+ while (true) {
174
+ const { done, value } = await reader.read()
175
+ if (done) break
176
+ chunks.push(value)
177
+ bytesDownloaded += value.length
178
+ setDownloadProgress((prev) => ({
179
+ shardsDone: prev?.shardsDone ?? 0,
180
+ bytesDownloaded,
181
+ totalBytes: file.metadata.size,
182
+ }))
183
+ }
184
+
185
+ const blob = new Blob(chunks as BlobPart[], { type: file.metadata.type })
141
186
  const url = URL.createObjectURL(blob)
142
187
  const a = document.createElement('a')
143
188
  a.href = url
@@ -166,30 +211,14 @@ export function UploadZone() {
166
211
  }
167
212
  }
168
213
 
169
- const progress = activeUpload?.progress
170
- const isIndeterminate =
171
- progress &&
172
- (progress.phase === 'connecting' ||
173
- progress.phase === 'assembling' ||
174
- progress.phase === 'pinning')
175
- const progressPercent =
176
- progress && progress.slabsTotal > 0
177
- ? Math.round((progress.slabsComplete / progress.slabsTotal) * 100)
178
- : 0
179
-
180
- function progressLabel(): string {
181
- if (!progress) return ''
182
- switch (progress.phase) {
183
- case 'connecting':
184
- return 'Connecting to hosts...'
185
- case 'uploading':
186
- return `${progress.slabsComplete}/${progress.slabsTotal} slabs \u00B7 ${progressPercent}%`
187
- case 'assembling':
188
- return 'Assembling object...'
189
- case 'pinning':
190
- return 'Pinning object...'
191
- }
192
- }
214
+ const uploadPercent = activeUpload
215
+ ? Math.min(
216
+ 100,
217
+ Math.round(
218
+ (activeUpload.bytesUploaded / activeUpload.totalBytes) * 100,
219
+ ),
220
+ )
221
+ : 0
193
222
 
194
223
  return (
195
224
  <div className="flex-1 p-6 space-y-5 max-w-5xl mx-auto w-full">
@@ -208,14 +237,14 @@ export function UploadZone() {
208
237
  <DevNote title="Upload & Download">
209
238
  <p>
210
239
  <code className="text-amber-300">
211
- client.upload(file, onProgress)
240
+ sdk.upload(object, file.stream(), opts)
212
241
  </code>{' '}
213
- encrypts, erasure-codes, and uploads to Sia hosts using parallel Web
214
- Workers.{' '}
215
- <code className="text-amber-300">
216
- client.download(object, onProgress)
217
- </code>{' '}
218
- retrieves and decrypts files the same way.
242
+ encrypts, erasure-codes, and streams shards directly to Sia hosts.{' '}
243
+ <code className="text-amber-300">sdk.download(object, opts)</code>{' '}
244
+ returns a <code className="text-amber-300">ReadableStream</code> of
245
+ decrypted bytes. Per-shard progress is reported via{' '}
246
+ <code className="text-amber-300">onShardUploaded</code> /{' '}
247
+ <code className="text-amber-300">onShardDownloaded</code>.
219
248
  </p>
220
249
  </DevNote>
221
250
 
@@ -266,30 +295,23 @@ export function UploadZone() {
266
295
  {activeUpload ? (
267
296
  <div className="space-y-4">
268
297
  <p className="text-neutral-300 text-sm">
269
- {progress?.phase === 'connecting' ? (
270
- <>
271
- Preparing upload for{' '}
272
- <span className="text-white">{activeUpload.fileName}</span>
273
- </>
274
- ) : (
275
- <>
276
- Uploading{' '}
277
- <span className="text-white">{activeUpload.fileName}</span>
278
- </>
279
- )}
298
+ Uploading{' '}
299
+ <span className="text-white">{activeUpload.fileName}</span>
280
300
  </p>
281
301
  <div className="w-full max-w-xs mx-auto bg-neutral-800 rounded-full h-1.5 overflow-hidden">
282
- {isIndeterminate ? (
302
+ {activeUpload.shardsDone === 0 ? (
283
303
  <div className="bg-green-500 h-full rounded-full w-1/4 animate-indeterminate" />
284
304
  ) : (
285
305
  <div
286
306
  className="bg-green-500 h-full rounded-full transition-all duration-300"
287
- style={{ width: `${progressPercent}%` }}
307
+ style={{ width: `${uploadPercent}%` }}
288
308
  />
289
309
  )}
290
310
  </div>
291
311
  <p className="text-neutral-600 text-xs font-mono">
292
- {progressLabel()}
312
+ {activeUpload.shardsDone} shards &middot;{' '}
313
+ {formatBytes(activeUpload.bytesUploaded)} /{' '}
314
+ {formatBytes(activeUpload.totalBytes)}
293
315
  </p>
294
316
  </div>
295
317
  ) : (
@@ -340,12 +362,11 @@ export function UploadZone() {
340
362
  )}
341
363
  {isDownloading && downloadProgress && (
342
364
  <span>
343
- {' &middot; '}
344
- {downloadProgress.phase === 'downloading'
345
- ? `${downloadProgress.slabsComplete}/${downloadProgress.slabsTotal} slabs`
346
- : downloadProgress.phase === 'connecting'
347
- ? 'Connecting...'
348
- : 'Assembling...'}
365
+ {' '}
366
+ &middot;{' '}
367
+ {formatBytes(downloadProgress.bytesDownloaded)} /{' '}
368
+ {formatBytes(downloadProgress.totalBytes)} (
369
+ {downloadProgress.shardsDone} shards)
349
370
  </span>
350
371
  )}
351
372
  </p>
@@ -1,9 +1,14 @@
1
+ import type { AppMetadata } from 'sia-storage'
2
+
3
+ // biome-ignore format: scaffolder substitutes a 64-char hex string here
1
4
  export const APP_KEY = '{{APP_KEY}}'
2
5
  export const APP_NAME = '{{APP_NAME}}'
3
6
  export const DEFAULT_INDEXER_URL = '{{INDEXER_URL}}'
4
- export const APP_META = JSON.stringify({
5
- appID: APP_KEY,
7
+ export const APP_META: AppMetadata = {
8
+ appId: APP_KEY,
6
9
  name: APP_NAME,
7
10
  description: '{{APP_DESCRIPTION}}',
8
- serviceURL: '{{INDEXER_URL}}',
9
- })
11
+ serviceUrl: '{{INDEXER_URL}}',
12
+ logoUrl: undefined,
13
+ callbackUrl: undefined,
14
+ }
@@ -1,4 +1,4 @@
1
- import type { SiaClient } from '@siafoundation/sia'
1
+ import type { Sdk } from 'sia-storage'
2
2
  import { create } from 'zustand'
3
3
  import { persist } from 'zustand/middleware'
4
4
 
@@ -10,13 +10,13 @@ export type AuthStep =
10
10
  | 'connected'
11
11
 
12
12
  type AuthState = {
13
- client: SiaClient | null
13
+ sdk: Sdk | null
14
14
  storedKeyHex: string | null
15
15
  indexerUrl: string
16
16
  step: AuthStep
17
17
  error: string | null
18
18
  approvalUrl: string | null
19
- setClient: (client: SiaClient) => void
19
+ setSdk: (sdk: Sdk) => void
20
20
  setStep: (step: AuthStep) => void
21
21
  setError: (error: string | null) => void
22
22
  setStoredKeyHex: (hex: string) => void
@@ -28,13 +28,13 @@ type AuthState = {
28
28
  export const useAuthStore = create<AuthState>()(
29
29
  persist(
30
30
  (set) => ({
31
- client: null,
31
+ sdk: null,
32
32
  storedKeyHex: null,
33
33
  indexerUrl: '',
34
34
  step: 'loading',
35
35
  error: null,
36
36
  approvalUrl: null,
37
- setClient: (client) => set({ client, step: 'connected', error: null }),
37
+ setSdk: (sdk) => set({ sdk, step: 'connected', error: null }),
38
38
  setStep: (step) => set({ step, error: null }),
39
39
  setError: (error) => set({ error }),
40
40
  setStoredKeyHex: (hex) => set({ storedKeyHex: hex }),
@@ -42,7 +42,7 @@ export const useAuthStore = create<AuthState>()(
42
42
  setApprovalUrl: (url) => set({ approvalUrl: url }),
43
43
  reset: () =>
44
44
  set({
45
- client: null,
45
+ sdk: null,
46
46
  storedKeyHex: null,
47
47
  step: 'loading',
48
48
  error: null,
@@ -0,0 +1,10 @@
1
+ // TC39 Uint8Array hex methods (Stage 3, supported in modern browsers).
2
+ // Drop this file once `lib.es*.d.ts` ships them.
3
+
4
+ interface Uint8Array {
5
+ toHex(): string
6
+ }
7
+
8
+ interface Uint8ArrayConstructor {
9
+ fromHex(hex: string): Uint8Array
10
+ }
@@ -15,9 +15,6 @@
15
15
  "moduleDetection": "force",
16
16
  "noEmit": true,
17
17
  "jsx": "react-jsx",
18
- "paths": {
19
- "sia-wasm": ["./node_modules/@siafoundation/sia/wasm/sia.d.ts"]
20
- },
21
18
 
22
19
  /* Linting */
23
20
  "strict": true,
@@ -1,17 +1,10 @@
1
1
  import tailwindcss from '@tailwindcss/vite'
2
2
  import react from '@vitejs/plugin-react'
3
3
  import { defineConfig } from 'vite'
4
- import topLevelAwait from 'vite-plugin-top-level-await'
5
- import wasm from 'vite-plugin-wasm'
6
4
 
7
5
  export default defineConfig({
8
- plugins: [react(), tailwindcss(), wasm(), topLevelAwait()],
9
- optimizeDeps: {
10
- exclude: ['@siafoundation/sia', 'sia-wasm'],
11
- },
12
- resolve: {
13
- alias: {
14
- 'sia-wasm': '@siafoundation/sia/wasm',
15
- },
16
- },
6
+ plugins: [react(), tailwindcss()],
7
+ // sia-storage loads its WASM via `new URL(..., import.meta.url)`; excluding
8
+ // it from the deps pre-bundler keeps that URL pointing at the real file.
9
+ optimizeDeps: { exclude: ['sia-storage'] },
17
10
  })
@@ -1,143 +0,0 @@
1
- # Sia Starter — AI Assistant Guide
2
-
3
- ## Overview
4
-
5
- A starter template for building decentralized storage apps on the Sia network. Uses `@siafoundation/sia` — a TypeScript SDK that ships a pre-compiled WASM binary for encryption, uploads, downloads, and key management.
6
-
7
- **Tech stack:** React 19, TypeScript, Vite, Tailwind CSS 4, Zustand, `@siafoundation/sia`
8
-
9
- ## Architecture
10
-
11
- ### Auth Flow State Machine
12
-
13
- The app uses a step-based auth flow managed by Zustand (`src/stores/auth.ts`):
14
-
15
- ```
16
- loading → connect → approve → recovery → connected
17
- ```
18
-
19
- - **loading**: WASM initializes, checks for stored app key
20
- - **connect**: User enters indexer URL, app requests connection
21
- - **approve**: User visits approval URL in another tab
22
- - **recovery**: User generates or enters 12-word recovery phrase
23
- - **connected**: SDK is ready, main app UI renders
24
-
25
- ### SDK
26
-
27
- The SDK (`@siafoundation/sia`) is an npm package that handles:
28
- - Encrypted file uploads/downloads (erasure coding + encryption)
29
- - Key derivation from recovery phrases (BIP-39)
30
- - Object pinning and metadata management
31
- - Connection auth with indexers
32
- - Parallel multi-worker uploads and downloads
33
-
34
- ### Zustand Persistence
35
-
36
- Auth state persists to localStorage via Zustand's `persist` middleware. The storage key is `{app-name}-auth`. Persisted fields: `storedKeyHex`, `indexerUrl`.
37
-
38
- ## Key Files
39
-
40
- | File | Description |
41
- |------|-------------|
42
- | `src/lib/constants.ts` | App key, app name, indexer URL, app metadata |
43
- | `src/stores/auth.ts` | Auth state machine (Zustand + persist) |
44
- | `src/stores/toast.ts` | Toast notification store (auto-dismiss) |
45
- | `src/components/Navbar.tsx` | App navbar with title, public key, sign out |
46
- | `src/components/Toast.tsx` | Toast overlay component |
47
- | `src/components/CopyButton.tsx` | Copy-to-clipboard button with toast |
48
- | `src/components/auth/AuthFlow.tsx` | Auth orchestrator |
49
- | `src/components/auth/ConnectScreen.tsx` | Indexer connection + CORS fallback |
50
- | `src/components/auth/ApproveScreen.tsx` | Approval polling (auto-polls on mount) |
51
- | `src/components/auth/RecoveryScreen.tsx` | Recovery phrase generation/import |
52
- | `src/components/upload/UploadZone.tsx` | File upload/download dropzone + file list |
53
- | `src/components/DevNote.tsx` | Developer callout component (unused, available for customization) |
54
-
55
- ## SDK Usage Patterns
56
-
57
- ### Upload a file
58
-
59
- ```ts
60
- const pinnedObject = await client.upload(file, (progress) => {
61
- // progress.phase: 'connecting' | 'uploading' | 'assembling' | 'pinning'
62
- console.log(`${progress.slabsComplete}/${progress.slabsTotal} slabs`)
63
- })
64
- pinnedObject.updateMetadata(new TextEncoder().encode(JSON.stringify({
65
- name: 'file.txt',
66
- type: 'text/plain',
67
- size: file.size,
68
- hash: '...',
69
- })))
70
- await client.pinObject(pinnedObject)
71
- await client.updateObjectMetadata(pinnedObject)
72
- ```
73
-
74
- ### Download a file
75
-
76
- ```ts
77
- const data = await client.download(pinnedObject, (progress) => {
78
- // progress.phase: 'connecting' | 'downloading' | 'assembling'
79
- console.log(`${progress.slabsComplete}/${progress.slabsTotal} slabs`)
80
- })
81
- // data is Uint8Array
82
- ```
83
-
84
- ### List files
85
-
86
- ```ts
87
- import { decodeMetadata } from '@siafoundation/sia'
88
-
89
- const events = await client.objectEvents(null, 100)
90
- for (const event of events) {
91
- if (!event.deleted && event.object) {
92
- const meta = decodeMetadata(event.object.metadata())
93
- console.log(meta.name, event.object.size())
94
- }
95
- }
96
- ```
97
-
98
- ### Delete a file
99
-
100
- ```ts
101
- await client.deleteObject(objectId)
102
- ```
103
-
104
- ### Share a file
105
-
106
- ```ts
107
- const validUntil = Date.now() + 7 * 24 * 60 * 60 * 1000 // 7 days
108
- const shareUrl = client.shareObject(pinnedObject, validUntil)
109
- ```
110
-
111
- ### Download shared file
112
-
113
- ```ts
114
- const object = await client.sharedObject(shareUrl)
115
- const data = await client.download(object)
116
- ```
117
-
118
- ## Customization
119
-
120
- ### Change app key
121
-
122
- Edit `src/lib/constants.ts`. The app key is a 32-byte hex string that identifies your app to the indexer. Generate one with:
123
-
124
- ```ts
125
- crypto.randomBytes(32).toString('hex')
126
- ```
127
-
128
- ### Replace the upload UI
129
-
130
- The post-auth UI is rendered in `src/App.tsx`. Replace `<UploadZone />` with your own component. The `SiaClient` is available via `useAuthStore((s) => s.client)`. `SiaClient` exposes all SDK methods directly — use `client.upload()`, `client.download()`, `client.objectEvents()`, etc.
131
-
132
- ### Add routes
133
-
134
- Install `react-router-dom` and wrap your app. The auth flow should gate all routes.
135
-
136
- ## Common Commands
137
-
138
- ```bash
139
- bun install # Install dependencies
140
- bun dev # Start dev server
141
- bun run build # Production build
142
- bun run check # Lint with Biome
143
- ```