Skip to content

Commit 560a1fd

Browse files
authored
Merge pull request #18 from ar-io/fix/manifest-fallback
fix(uploader): set the manifest fallback so SPA deep links resolve
2 parents 7bf84c8 + 5a8e891 commit 560a1fd

9 files changed

Lines changed: 244 additions & 6 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ Your app is now permanently live at `https://myapp.ar.io`.
5555
- **Optional ArNS Updates:** Updates ArNS records via ANT with new transaction IDs
5656
- **Automated Workflow:** Integrates with GitHub Actions for continuous deployment
5757
- **Git Hash Tagging:** In CI (GitHub Actions), tags uploaded data items with the deploying commit SHA
58-
- **404 Fallback Detection:** Automatically detects and sets 404.html as fallback
58+
- **404 Fallback Detection:** Automatically sets `404.html` as the manifest fallback when present, so deep links into a single-page app resolve instead of 404ing. Override with `--fallback-file <path>` — an SPA that only builds `index.html` can point at that instead.
5959
- **Network Support:** ArNS updates run against the Solana ARIO programs on `mainnet` or `devnet`, with an optional custom RPC URL
6060
- **Flexible Deployment:** Supports deploying a folder or a single file
6161
- **Modern CLI:** Built with oclif for a robust command-line experience

src/commands/deploy.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ export default class Deploy extends Command {
155155
'dedupe-cache-max-entries': effectiveCacheMaxEntries,
156156
'deploy-file': baseConfig['deploy-file'],
157157
'deploy-folder': baseConfig['deploy-folder'],
158+
'fallback-file': baseConfig['fallback-file'],
158159
'max-token-amount': advancedOptions?.maxTokenAmount || baseConfig['max-token-amount'],
159160
'no-dedupe': baseConfig['no-dedupe'],
160161
'on-demand': advancedOptions?.onDemand || baseConfig['on-demand'],

src/commands/upload.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export default class Upload extends Command {
7272
'dedupe-cache-max-entries': effectiveCacheMaxEntries,
7373
'deploy-file': baseConfig['deploy-file'],
7474
'deploy-folder': baseConfig['deploy-folder'],
75+
'fallback-file': baseConfig['fallback-file'],
7576
'max-token-amount': baseConfig['max-token-amount'],
7677
'on-demand': baseConfig['on-demand'],
7778
'sig-type': baseConfig['sig-type'],

src/constants/flags.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ export const globalFlags = {
108108
return target.type === 'folder' ? target.path : './dist'
109109
},
110110
}),
111+
fallbackFile: createFlagConfig<string | undefined>({
112+
flag: Flags.string({
113+
description:
114+
'Path (relative to the deploy folder) served for routes the manifest does not list. Defaults to 404.html when present.',
115+
required: false,
116+
}),
117+
}),
111118
// Advanced payment settings
112119
maxTokenAmount: createFlagConfig<string | undefined>({
113120
flag: Flags.string({
@@ -230,6 +237,7 @@ export const deployFlags = {
230237
'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries.flag,
231238
'deploy-file': globalFlags.deployFile.flag,
232239
'deploy-folder': globalFlags.deployFolder.flag,
240+
'fallback-file': globalFlags.fallbackFile.flag,
233241
'max-token-amount': globalFlags.maxTokenAmount.flag,
234242
'no-dedupe': globalFlags.noDedupe.flag,
235243
'on-demand': globalFlags.onDemand.flag,
@@ -276,6 +284,7 @@ export interface DeployConfig {
276284
'dedupe-cache-max-entries': number
277285
'deploy-file'?: string
278286
'deploy-folder': string
287+
'fallback-file'?: string
279288
'max-token-amount'?: string
280289
'no-dedupe': boolean
281290
'on-demand'?: string
@@ -301,6 +310,7 @@ export const deployFlagConfigs = {
301310
'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries,
302311
'deploy-file': globalFlags.deployFile,
303312
'deploy-folder': globalFlags.deployFolder,
313+
'fallback-file': globalFlags.fallbackFile,
304314
'max-token-amount': globalFlags.maxTokenAmount,
305315
'no-dedupe': globalFlags.noDedupe,
306316
'on-demand': globalFlags.onDemand,
@@ -321,6 +331,7 @@ export const uploadFlagConfigs = {
321331
'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries,
322332
'deploy-file': globalFlags.deployFile,
323333
'deploy-folder': globalFlags.deployFolder,
334+
'fallback-file': globalFlags.fallbackFile,
324335
'max-token-amount': globalFlags.maxTokenAmount,
325336
'no-dedupe': globalFlags.noDedupe,
326337
'on-demand': globalFlags.onDemand,

src/utils/__tests__/cache.test.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ describe('cache', () => {
239239
const files = getAllFiles(tempDir)
240240
expect(files).toHaveLength(2)
241241
expect(files).toContain('root.txt')
242-
expect(files).toContain(path.join('subdir', 'nested.txt'))
242+
expect(files).toContain('subdir/nested.txt')
243243
})
244244

245245
it('should handle deeply nested directories', () => {
@@ -249,7 +249,19 @@ describe('cache', () => {
249249

250250
const files = getAllFiles(tempDir)
251251
expect(files).toHaveLength(1)
252-
expect(files).toContain(path.join('a', 'b', 'c', 'deep.txt'))
252+
expect(files).toContain('a/b/c/deep.txt')
253+
})
254+
255+
it('always separates with / so manifest keys are portable', () => {
256+
const deepDir = path.join(tempDir, 'assets', 'img')
257+
fs.mkdirSync(deepDir, { recursive: true })
258+
fs.writeFileSync(path.join(deepDir, 'logo.svg'), '<svg />')
259+
260+
// These strings become arweave/paths manifest keys verbatim. A gateway
261+
// resolves `assets/img/logo.svg`; a backslash key would 404.
262+
const files = getAllFiles(tempDir)
263+
expect(files).toEqual(['assets/img/logo.svg'])
264+
expect(files.every((f) => !f.includes('\\'))).toBe(true)
253265
})
254266
})
255267
})

src/utils/cache.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,13 @@ export async function hashFile(filePath: string): Promise<string> {
6969
}
7070

7171
/**
72-
* Recursively get all files in a directory
73-
* Returns relative paths from the base directory
72+
* Recursively get all files in a directory.
73+
*
74+
* Returns paths relative to the base directory, always separated by `/`.
75+
* `path.relative` yields backslashes on Windows, and these strings become
76+
* manifest keys — a gateway looks up `assets/app.js`, so a manifest written
77+
* as `assets\app.js` 404s every nested asset. Normalizing here also keeps
78+
* the `dir/index.html` directory-index check working on every platform.
7479
*/
7580
export function getAllFiles(dirPath: string, basePath: string = dirPath): string[] {
7681
const files: string[] = []
@@ -83,7 +88,7 @@ export function getAllFiles(dirPath: string, basePath: string = dirPath): string
8388
files.push(...getAllFiles(fullPath, basePath))
8489
} else {
8590
// Store relative path for consistent hashing
86-
files.push(path.relative(basePath, fullPath))
91+
files.push(path.relative(basePath, fullPath).split(path.sep).join('/'))
8792
}
8893
}
8994

src/utils/uploader.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ export async function uploadFolder(
143143
options?: {
144144
cache?: TransactionCache
145145
concurrency?: number
146+
/**
147+
* Path, relative to the folder, whose transaction becomes the manifest's
148+
* `fallback` — what a gateway serves for a path the manifest does not
149+
* list. Defaults to `404.html` when present.
150+
*/
151+
fallbackFile?: string
146152
fundingMode?: OnDemandFunding
147153
throwOnFailure?: boolean
148154
},
@@ -157,6 +163,18 @@ export async function uploadFolder(
157163
throw new Error('Folder is empty, nothing to upload')
158164
}
159165

166+
/*
167+
* Validate before uploading anything: every check below this point happens
168+
* after files have been paid for, and a mistyped fallback should cost
169+
* nothing.
170+
*/
171+
if (options?.fallbackFile !== undefined && !relativePaths.includes(options.fallbackFile)) {
172+
throw new Error(
173+
`Fallback file not found in folder: ${options.fallbackFile}. ` +
174+
`It must be a path relative to the deploy folder, e.g. "404.html".`,
175+
)
176+
}
177+
160178
// Prepare file tasks with hashes (if caching is enabled)
161179
const tasks: FileUploadTask[] = await Promise.all(
162180
relativePaths.map(async (relativePath) => {
@@ -256,11 +274,30 @@ export async function uploadFolder(
256274
// Determine the index path (root index.html)
257275
const indexPath = relativePaths.includes('index.html') ? 'index.html' : undefined
258276

277+
/*
278+
* Determine the fallback — the transaction a gateway serves for any path the
279+
* manifest does not list.
280+
*
281+
* Without one, an `arweave/paths` manifest 404s every route that is not a
282+
* real file, which breaks deep links into any single-page app: the root
283+
* loads and `/settings` does not. An explicit `fallbackFile` wins; otherwise
284+
* `404.html` is used when the build emits one, matching the convention
285+
* static hosts already use.
286+
*
287+
* Note the shape: `fallback` takes an `{ id }`, not the `{ path }` that
288+
* `index` takes. The v0.2.0 spec differs between the two.
289+
*/
290+
const fallbackPath =
291+
options?.fallbackFile ?? (relativePaths.includes('404.html') ? '404.html' : undefined)
292+
293+
const fallbackId = fallbackPath ? manifestPaths[fallbackPath]?.id : undefined
294+
259295
// Build the manifest
260296
const manifest = {
261297
manifest: 'arweave/paths',
262298
version: '0.2.0',
263299
...(indexPath && { index: { path: indexPath } }),
300+
...(fallbackId && { fallback: { id: fallbackId } }),
264301
paths: manifestPaths,
265302
}
266303

src/workflows/upload-workflow.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export interface UploadWorkflowConfig {
2222
'dedupe-cache-max-entries': number
2323
'deploy-file'?: string
2424
'deploy-folder': string
25+
/** Relative path served for routes the manifest does not list. */
26+
'fallback-file'?: string
2527
'max-token-amount'?: string
2628
'on-demand'?: string
2729
'sig-type': string
@@ -196,6 +198,7 @@ export async function runUploadWorkflow(
196198
let cache = config['dedupe-cache-max-entries'] > 0 ? loadCache() : {}
197199
const uploadResult: FolderUploadResult = await uploadFolder(uploadClient, folderPath, {
198200
cache,
201+
fallbackFile: config['fallback-file'],
199202
fundingMode,
200203
throwOnFailure: true,
201204
})
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
5+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6+
7+
import type { UploadClient, UploadFileArgs } from '../../src/utils/upload-types.js'
8+
import { uploadFolder } from '../../src/utils/uploader.js'
9+
10+
/**
11+
* The manifest's `fallback` is what a gateway serves for a path the manifest
12+
* does not list. Without it an `arweave/paths` manifest 404s every route of a
13+
* single-page app that is not a real file — the root loads, `/settings` does
14+
* not — so these pin the shape as well as the presence.
15+
*/
16+
17+
interface ArweaveManifest {
18+
fallback?: { id: string }
19+
index?: { path: string }
20+
manifest: string
21+
paths: Record<string, { id: string }>
22+
version: string
23+
}
24+
25+
let folder: string
26+
27+
/** Deterministic ids so a manifest entry can be traced back to its file. */
28+
function idFor(index: number): string {
29+
return `tx${String(index).padStart(41, '0')}`
30+
}
31+
32+
/**
33+
* Records every upload and exposes the manifest, which is the last file sent
34+
* and the only one tagged as a manifest.
35+
*/
36+
function stubClient(): {
37+
client: UploadClient
38+
manifest: () => ArweaveManifest
39+
uploadCount: () => number
40+
} {
41+
let counter = 0
42+
let manifestJson: string | undefined
43+
44+
const client: UploadClient = {
45+
async uploadFile(args: UploadFileArgs) {
46+
const isManifest = args.dataItemOpts?.tags?.some(
47+
(t) => t.name === 'Content-Type' && t.value === 'application/x.arweave-manifest+json',
48+
)
49+
50+
if (isManifest && args.fileStreamFactory) {
51+
const stream = args.fileStreamFactory() as AsyncIterable<Buffer>
52+
const chunks: Buffer[] = []
53+
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
54+
manifestJson = Buffer.concat(chunks).toString('utf8')
55+
}
56+
57+
counter += 1
58+
return { id: idFor(counter) }
59+
},
60+
}
61+
62+
return {
63+
client,
64+
manifest() {
65+
if (!manifestJson) throw new Error('no manifest was uploaded')
66+
return JSON.parse(manifestJson) as ArweaveManifest
67+
},
68+
uploadCount: () => counter,
69+
}
70+
}
71+
72+
function write(name: string, body: string): void {
73+
const full = path.join(folder, name)
74+
fs.mkdirSync(path.dirname(full), { recursive: true })
75+
fs.writeFileSync(full, body)
76+
}
77+
78+
beforeEach(() => {
79+
folder = fs.mkdtempSync(path.join(os.tmpdir(), 'ario-deploy-fallback-'))
80+
})
81+
82+
afterEach(() => {
83+
fs.rmSync(folder, { force: true, recursive: true })
84+
})
85+
86+
describe('uploadFolder manifest fallback', () => {
87+
it('uses 404.html as the fallback when the build emits one', async () => {
88+
write('index.html', '<html>index</html>')
89+
write('404.html', '<html>fallback</html>')
90+
write('assets/app.js', 'console.log(1)')
91+
92+
const { client, manifest } = stubClient()
93+
await uploadFolder(client, folder)
94+
const m = manifest()
95+
96+
expect(m.fallback).toBeDefined()
97+
expect(m.fallback?.id).toBe(m.paths['404.html'].id)
98+
})
99+
100+
it('carries an id, not a path — the v0.2.0 spec differs from `index`', async () => {
101+
write('index.html', '<html>index</html>')
102+
write('404.html', '<html>fallback</html>')
103+
104+
const { client, manifest } = stubClient()
105+
await uploadFolder(client, folder)
106+
const m = manifest()
107+
108+
// `index` takes { path }; `fallback` takes { id }. Getting this wrong
109+
// produces a manifest a gateway silently ignores.
110+
expect(m.index).toEqual({ path: 'index.html' })
111+
expect(Object.keys(m.fallback ?? {})).toEqual(['id'])
112+
})
113+
114+
it('omits fallback entirely when there is no 404.html and no flag', async () => {
115+
write('index.html', '<html>index</html>')
116+
write('assets/app.js', 'console.log(1)')
117+
118+
const { client, manifest } = stubClient()
119+
await uploadFolder(client, folder)
120+
121+
expect(manifest().fallback).toBeUndefined()
122+
})
123+
124+
it('honours an explicit fallbackFile over 404.html', async () => {
125+
write('index.html', '<html>index</html>')
126+
write('404.html', '<html>fallback</html>')
127+
128+
const { client, manifest } = stubClient()
129+
await uploadFolder(client, folder, { fallbackFile: 'index.html' })
130+
const m = manifest()
131+
132+
expect(m.fallback?.id).toBe(m.paths['index.html'].id)
133+
expect(m.fallback?.id).not.toBe(m.paths['404.html'].id)
134+
})
135+
136+
it('lets a single-page app opt in without inventing a 404 file', async () => {
137+
write('index.html', '<html>index</html>')
138+
139+
const { client, manifest } = stubClient()
140+
await uploadFolder(client, folder, { fallbackFile: 'index.html' })
141+
142+
expect(manifest().fallback?.id).toBe(manifest().paths['index.html'].id)
143+
})
144+
145+
it('rejects a missing fallback before uploading, so a typo costs nothing', async () => {
146+
write('index.html', '<html>index</html>')
147+
write('assets/app.js', 'console.log(1)')
148+
149+
const { client, uploadCount } = stubClient()
150+
151+
// Every file is paid for on upload. Validating after the fact would bill
152+
// the whole deploy for a mistyped flag and then throw.
153+
await expect(uploadFolder(client, folder, { fallbackFile: 'missing.html' })).rejects.toThrow()
154+
expect(uploadCount()).toBe(0)
155+
})
156+
157+
it('fails loudly when the named fallback file is not in the folder', async () => {
158+
write('index.html', '<html>index</html>')
159+
160+
const { client } = stubClient()
161+
162+
// Silently skipping would ship a manifest whose deep links 404, which is
163+
// exactly the failure this option exists to prevent.
164+
await expect(uploadFolder(client, folder, { fallbackFile: 'missing.html' })).rejects.toThrow(
165+
/Fallback file not found in folder: missing\.html/,
166+
)
167+
})
168+
})

0 commit comments

Comments
 (0)