Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Your app is now permanently live at `https://myapp.ar.io`.
- **Optional ArNS Updates:** Updates ArNS records via ANT with new transaction IDs
- **Automated Workflow:** Integrates with GitHub Actions for continuous deployment
- **Git Hash Tagging:** In CI (GitHub Actions), tags uploaded data items with the deploying commit SHA
- **404 Fallback Detection:** Automatically detects and sets 404.html as fallback
- **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.
- **Network Support:** ArNS updates run against the Solana ARIO programs on `mainnet` or `devnet`, with an optional custom RPC URL
- **Flexible Deployment:** Supports deploying a folder or a single file
- **Modern CLI:** Built with oclif for a robust command-line experience
Expand Down
1 change: 1 addition & 0 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ export default class Deploy extends Command {
'dedupe-cache-max-entries': effectiveCacheMaxEntries,
'deploy-file': baseConfig['deploy-file'],
'deploy-folder': baseConfig['deploy-folder'],
'fallback-file': baseConfig['fallback-file'],
'max-token-amount': advancedOptions?.maxTokenAmount || baseConfig['max-token-amount'],
'no-dedupe': baseConfig['no-dedupe'],
'on-demand': advancedOptions?.onDemand || baseConfig['on-demand'],
Expand Down
1 change: 1 addition & 0 deletions src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export default class Upload extends Command {
'dedupe-cache-max-entries': effectiveCacheMaxEntries,
'deploy-file': baseConfig['deploy-file'],
'deploy-folder': baseConfig['deploy-folder'],
'fallback-file': baseConfig['fallback-file'],
'max-token-amount': baseConfig['max-token-amount'],
'on-demand': baseConfig['on-demand'],
'sig-type': baseConfig['sig-type'],
Expand Down
11 changes: 11 additions & 0 deletions src/constants/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,13 @@ export const globalFlags = {
return target.type === 'folder' ? target.path : './dist'
},
}),
fallbackFile: createFlagConfig<string | undefined>({
flag: Flags.string({
description:
'Path (relative to the deploy folder) served for routes the manifest does not list. Defaults to 404.html when present.',
required: false,
}),
}),
// Advanced payment settings
maxTokenAmount: createFlagConfig<string | undefined>({
flag: Flags.string({
Expand Down Expand Up @@ -230,6 +237,7 @@ export const deployFlags = {
'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries.flag,
'deploy-file': globalFlags.deployFile.flag,
'deploy-folder': globalFlags.deployFolder.flag,
'fallback-file': globalFlags.fallbackFile.flag,
'max-token-amount': globalFlags.maxTokenAmount.flag,
'no-dedupe': globalFlags.noDedupe.flag,
'on-demand': globalFlags.onDemand.flag,
Expand Down Expand Up @@ -276,6 +284,7 @@ export interface DeployConfig {
'dedupe-cache-max-entries': number
'deploy-file'?: string
'deploy-folder': string
'fallback-file'?: string
'max-token-amount'?: string
'no-dedupe': boolean
'on-demand'?: string
Expand All @@ -301,6 +310,7 @@ export const deployFlagConfigs = {
'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries,
'deploy-file': globalFlags.deployFile,
'deploy-folder': globalFlags.deployFolder,
'fallback-file': globalFlags.fallbackFile,
'max-token-amount': globalFlags.maxTokenAmount,
'no-dedupe': globalFlags.noDedupe,
'on-demand': globalFlags.onDemand,
Expand All @@ -321,6 +331,7 @@ export const uploadFlagConfigs = {
'dedupe-cache-max-entries': globalFlags.dedupeCacheMaxEntries,
'deploy-file': globalFlags.deployFile,
'deploy-folder': globalFlags.deployFolder,
'fallback-file': globalFlags.fallbackFile,
'max-token-amount': globalFlags.maxTokenAmount,
'no-dedupe': globalFlags.noDedupe,
'on-demand': globalFlags.onDemand,
Expand Down
16 changes: 14 additions & 2 deletions src/utils/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ describe('cache', () => {
const files = getAllFiles(tempDir)
expect(files).toHaveLength(2)
expect(files).toContain('root.txt')
expect(files).toContain(path.join('subdir', 'nested.txt'))
expect(files).toContain('subdir/nested.txt')
})

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

const files = getAllFiles(tempDir)
expect(files).toHaveLength(1)
expect(files).toContain(path.join('a', 'b', 'c', 'deep.txt'))
expect(files).toContain('a/b/c/deep.txt')
})

it('always separates with / so manifest keys are portable', () => {
const deepDir = path.join(tempDir, 'assets', 'img')
fs.mkdirSync(deepDir, { recursive: true })
fs.writeFileSync(path.join(deepDir, 'logo.svg'), '<svg />')

// These strings become arweave/paths manifest keys verbatim. A gateway
// resolves `assets/img/logo.svg`; a backslash key would 404.
const files = getAllFiles(tempDir)
expect(files).toEqual(['assets/img/logo.svg'])
expect(files.every((f) => !f.includes('\\'))).toBe(true)
})
})
})
11 changes: 8 additions & 3 deletions src/utils/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,13 @@ export async function hashFile(filePath: string): Promise<string> {
}

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

Expand Down
37 changes: 37 additions & 0 deletions src/utils/uploader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ export async function uploadFolder(
options?: {
cache?: TransactionCache
concurrency?: number
/**
* Path, relative to the folder, whose transaction becomes the manifest's
* `fallback` — what a gateway serves for a path the manifest does not
* list. Defaults to `404.html` when present.
*/
fallbackFile?: string
fundingMode?: OnDemandFunding
throwOnFailure?: boolean
},
Expand All @@ -157,6 +163,18 @@ export async function uploadFolder(
throw new Error('Folder is empty, nothing to upload')
}

/*
* Validate before uploading anything: every check below this point happens
* after files have been paid for, and a mistyped fallback should cost
* nothing.
*/
if (options?.fallbackFile !== undefined && !relativePaths.includes(options.fallbackFile)) {
throw new Error(
`Fallback file not found in folder: ${options.fallbackFile}. ` +
`It must be a path relative to the deploy folder, e.g. "404.html".`,
)
}

// Prepare file tasks with hashes (if caching is enabled)
const tasks: FileUploadTask[] = await Promise.all(
relativePaths.map(async (relativePath) => {
Expand Down Expand Up @@ -256,11 +274,30 @@ export async function uploadFolder(
// Determine the index path (root index.html)
const indexPath = relativePaths.includes('index.html') ? 'index.html' : undefined

/*
* Determine the fallback — the transaction a gateway serves for any path the
* manifest does not list.
*
* Without one, an `arweave/paths` manifest 404s every route that is not a
* real file, which breaks deep links into any single-page app: the root
* loads and `/settings` does not. An explicit `fallbackFile` wins; otherwise
* `404.html` is used when the build emits one, matching the convention
* static hosts already use.
*
* Note the shape: `fallback` takes an `{ id }`, not the `{ path }` that
* `index` takes. The v0.2.0 spec differs between the two.
*/
const fallbackPath =
options?.fallbackFile ?? (relativePaths.includes('404.html') ? '404.html' : undefined)

const fallbackId = fallbackPath ? manifestPaths[fallbackPath]?.id : undefined

// Build the manifest
const manifest = {
manifest: 'arweave/paths',
version: '0.2.0',
...(indexPath && { index: { path: indexPath } }),
...(fallbackId && { fallback: { id: fallbackId } }),
paths: manifestPaths,
}

Expand Down
3 changes: 3 additions & 0 deletions src/workflows/upload-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface UploadWorkflowConfig {
'dedupe-cache-max-entries': number
'deploy-file'?: string
'deploy-folder': string
/** Relative path served for routes the manifest does not list. */
'fallback-file'?: string
'max-token-amount'?: string
'on-demand'?: string
'sig-type': string
Expand Down Expand Up @@ -196,6 +198,7 @@ export async function runUploadWorkflow(
let cache = config['dedupe-cache-max-entries'] > 0 ? loadCache() : {}
const uploadResult: FolderUploadResult = await uploadFolder(uploadClient, folderPath, {
cache,
fallbackFile: config['fallback-file'],
fundingMode,
throwOnFailure: true,
})
Expand Down
168 changes: 168 additions & 0 deletions tests/unit/uploader-manifest-fallback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import { afterEach, beforeEach, describe, expect, it } from 'vitest'

import type { UploadClient, UploadFileArgs } from '../../src/utils/upload-types.js'
import { uploadFolder } from '../../src/utils/uploader.js'

/**
* The manifest's `fallback` is what a gateway serves for a path the manifest
* does not list. Without it an `arweave/paths` manifest 404s every route of a
* single-page app that is not a real file — the root loads, `/settings` does
* not — so these pin the shape as well as the presence.
*/

interface ArweaveManifest {
fallback?: { id: string }
index?: { path: string }
manifest: string
paths: Record<string, { id: string }>
version: string
}

let folder: string

/** Deterministic ids so a manifest entry can be traced back to its file. */
function idFor(index: number): string {
return `tx${String(index).padStart(41, '0')}`
}

/**
* Records every upload and exposes the manifest, which is the last file sent
* and the only one tagged as a manifest.
*/
function stubClient(): {
client: UploadClient
manifest: () => ArweaveManifest
uploadCount: () => number
} {
let counter = 0
let manifestJson: string | undefined

const client: UploadClient = {
async uploadFile(args: UploadFileArgs) {
const isManifest = args.dataItemOpts?.tags?.some(
(t) => t.name === 'Content-Type' && t.value === 'application/x.arweave-manifest+json',
)

if (isManifest && args.fileStreamFactory) {
const stream = args.fileStreamFactory() as AsyncIterable<Buffer>
const chunks: Buffer[] = []
for await (const chunk of stream) chunks.push(Buffer.from(chunk))
manifestJson = Buffer.concat(chunks).toString('utf8')
}

counter += 1
return { id: idFor(counter) }
},
}

return {
client,
manifest() {
if (!manifestJson) throw new Error('no manifest was uploaded')
return JSON.parse(manifestJson) as ArweaveManifest
},
uploadCount: () => counter,
}
}

function write(name: string, body: string): void {
const full = path.join(folder, name)
fs.mkdirSync(path.dirname(full), { recursive: true })
fs.writeFileSync(full, body)
}

beforeEach(() => {
folder = fs.mkdtempSync(path.join(os.tmpdir(), 'ario-deploy-fallback-'))
})

afterEach(() => {
fs.rmSync(folder, { force: true, recursive: true })
})

describe('uploadFolder manifest fallback', () => {
it('uses 404.html as the fallback when the build emits one', async () => {
write('index.html', '<html>index</html>')
write('404.html', '<html>fallback</html>')
write('assets/app.js', 'console.log(1)')

const { client, manifest } = stubClient()
await uploadFolder(client, folder)
const m = manifest()

expect(m.fallback).toBeDefined()
expect(m.fallback?.id).toBe(m.paths['404.html'].id)
})

it('carries an id, not a path — the v0.2.0 spec differs from `index`', async () => {
write('index.html', '<html>index</html>')
write('404.html', '<html>fallback</html>')

const { client, manifest } = stubClient()
await uploadFolder(client, folder)
const m = manifest()

// `index` takes { path }; `fallback` takes { id }. Getting this wrong
// produces a manifest a gateway silently ignores.
expect(m.index).toEqual({ path: 'index.html' })
expect(Object.keys(m.fallback ?? {})).toEqual(['id'])
})

it('omits fallback entirely when there is no 404.html and no flag', async () => {
write('index.html', '<html>index</html>')
write('assets/app.js', 'console.log(1)')

const { client, manifest } = stubClient()
await uploadFolder(client, folder)

expect(manifest().fallback).toBeUndefined()
})

it('honours an explicit fallbackFile over 404.html', async () => {
write('index.html', '<html>index</html>')
write('404.html', '<html>fallback</html>')

const { client, manifest } = stubClient()
await uploadFolder(client, folder, { fallbackFile: 'index.html' })
const m = manifest()

expect(m.fallback?.id).toBe(m.paths['index.html'].id)
expect(m.fallback?.id).not.toBe(m.paths['404.html'].id)
})

it('lets a single-page app opt in without inventing a 404 file', async () => {
write('index.html', '<html>index</html>')

const { client, manifest } = stubClient()
await uploadFolder(client, folder, { fallbackFile: 'index.html' })

expect(manifest().fallback?.id).toBe(manifest().paths['index.html'].id)
})

it('rejects a missing fallback before uploading, so a typo costs nothing', async () => {
write('index.html', '<html>index</html>')
write('assets/app.js', 'console.log(1)')

const { client, uploadCount } = stubClient()

// Every file is paid for on upload. Validating after the fact would bill
// the whole deploy for a mistyped flag and then throw.
await expect(uploadFolder(client, folder, { fallbackFile: 'missing.html' })).rejects.toThrow()
expect(uploadCount()).toBe(0)
})

it('fails loudly when the named fallback file is not in the folder', async () => {
write('index.html', '<html>index</html>')

const { client } = stubClient()

// Silently skipping would ship a manifest whose deep links 404, which is
// exactly the failure this option exists to prevent.
await expect(uploadFolder(client, folder, { fallbackFile: 'missing.html' })).rejects.toThrow(
/Fallback file not found in folder: missing\.html/,
)
})
})
Loading