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
5 changes: 5 additions & 0 deletions .changeset/sdk-lazy-upload-deps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@audius/sdk': patch
---

Load `tus-js-client` and the Node `file-type` path only when an upload or file type check needs them, and fetch the storage node list on first use instead of when the SDK is created.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { EventEmitter } from 'events'

import optimizely from '@optimizely/optimizely-sdk'
import type optimizely from '@optimizely/optimizely-sdk'

import { ID } from '~/models'
import { Nullable } from '~/utils'
Expand Down
19 changes: 14 additions & 5 deletions packages/sdk/src/sdk/services/Storage/Storage.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import * as tus from 'tus-js-client'
import type { Upload } from 'tus-js-client'

import { productionConfig } from '../../config/production'
import fetch from '../../utils/fetch'
Expand All @@ -18,6 +18,12 @@ import type {
UploadFileParams
} from './types'

// tus-js-client is only needed once an upload starts
const loadTus = async () => {
const tus: any = await import('tus-js-client')
return ('Upload' in tus ? tus : tus.default) as typeof import('tus-js-client')
}

const MAX_TRACK_TRANSCODE_TIMEOUT = 3600000 // 1 hour
const MAX_TRACK_TRANSCODE_NO_PROGRESS_TIMEOUT = 1200000 // 20 minutes
const MAX_IMAGE_RESIZE_TIMEOUT_MS = 300000 // 5 minutes
Expand Down Expand Up @@ -46,7 +52,7 @@ export class Storage implements StorageService {
*/
uploadFile({ file, onProgress, metadata }: UploadFileParams) {
const ac = new AbortController()
let upload: tus.Upload | undefined
let upload: Upload | undefined
let uploadPromise: Promise<UploadResponse> | undefined
let cachedTotal = 0

Expand All @@ -56,13 +62,16 @@ export class Storage implements StorageService {
return uploadPromise
}
uploadPromise = new Promise<UploadResponse>((resolve, reject) => {
this.storageNodeSelector
.getSelectedNode()
.then((selectedNode) => {
Promise.all([this.storageNodeSelector.getSelectedNode(), loadTus()])
.then(([selectedNode, tus]) => {
if (!selectedNode) {
reject(new Error('No node available'))
return
}
if (ac.signal.aborted) {
reject(new Error('Upload aborted'))
return
}
// In Node, tus-js-client accepts Buffer or fs.ReadStream as the
// source. When the caller passes a NodeFile wrapper
// (`{ buffer, name, type }`) — which is what the zod
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,4 +143,40 @@ describe('StorageNodeSelector', () => {
expect(await storageNodeSelector.getSelectedNode()).toBe(null)
expect(await storageNodeSelector.triedSelectingAllNodes()).toBe(true)
})

it('fetches the node list once, on first selection', async () => {
let healthCheckCalls = 0
const storageNodeC = {
endpoint: 'https://node-c.audius.co',
delegateOwnerWallet: '0xc0ffee254729296a45a3885639AC7E10F9d54973'
}
server.use(
rest.get(`${discoveryNode}/health_check`, (_req, res, ctx) => {
healthCheckCalls++
const data: HealthCheckResponseData = {
network: { content_nodes: [storageNodeC] }
}
return res(ctx.status(200), ctx.json({ data }))
}),
rest.get(`${storageNodeC.endpoint}/health_check`, (_req, res, ctx) => {
return res(ctx.status(200), ctx.json({ data: { diskHasSpace: true } }))
})
)

const storageNodeSelector = new StorageNodeSelector({
bootstrapNodes: [storageNodeA, storageNodeB],
logger,
endpoint: discoveryNode
})
await new Promise((resolve) => setTimeout(resolve, 0))
expect(healthCheckCalls).toBe(0)

const [first, second] = await Promise.all([
storageNodeSelector.getSelectedNode(),
storageNodeSelector.getSelectedNode()
])
expect(first).toEqual(storageNodeC.endpoint)
expect(second).toEqual(storageNodeC.endpoint)
expect(healthCheckCalls).toBe(1)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class StorageNodeSelector implements StorageNodeSelectorService {
private orderedNodes?: string[] // endpoints (lowercase)
private selectedNode?: string | null
private selectionState: 'healthy_only' | 'failed_all'
private nodesPromise?: Promise<void>

constructor(config: StorageNodeSelectorConfig) {
this.config = mergeConfigWithDefaults(
Expand All @@ -32,8 +33,28 @@ export class StorageNodeSelector implements StorageNodeSelectorService {
)
this.nodes = this.config.bootstrapNodes ?? []
this.selectionState = 'healthy_only'
}

this.updateAvailableStorageNodes(this.config.endpoint)
/**
* Fetches the node list once, on first use rather than at construction,
* so clients that never upload don't pay for the request. Concurrent
* callers share the same request; a failed request is retried next time.
*/
private ensureAvailableStorageNodes() {
if (!this.nodesPromise) {
this.nodesPromise = this.updateAvailableStorageNodes(
this.config.endpoint
).then(
(ok) => {
if (!ok) this.nodesPromise = undefined
},
(e) => {
this.logger.warn('Failed to update available storage nodes', e)
this.nodesPromise = undefined
}
)
}
return this.nodesPromise
}

private async updateAvailableStorageNodes(endpoint: string) {
Expand All @@ -42,7 +63,7 @@ export class StorageNodeSelector implements StorageNodeSelectorService {
const healthCheckResponse = await fetch(healthCheckEndpoint)
if (!healthCheckResponse.ok) {
this.logger.warn('API health check did not respond successfully')
return
return false
}

const responseData: { data: HealthCheckResponseData } =
Expand All @@ -53,18 +74,20 @@ export class StorageNodeSelector implements StorageNodeSelectorService {
this.logger.warn(
'API health check did not contain any available content nodes'
)
return
return false
}

this.nodes = contentNodes
this.selectionState = 'healthy_only'
return true
}

public async getSelectedNode(forceReselect = false) {
if (this.selectedNode && !forceReselect) {
return this.selectedNode
}

await this.ensureAvailableStorageNodes()
return await this.select()
}

Expand All @@ -73,6 +96,8 @@ export class StorageNodeSelector implements StorageNodeSelectorService {
}

public getNodes(cid: string) {
// Synchronous, so this uses the current list and refreshes it for later
this.ensureAvailableStorageNodes()
return this.orderNodes(cid)
}

Expand Down
20 changes: 12 additions & 8 deletions packages/sdk/src/sdk/types/File.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import * as fileType from 'file-type'
import { z } from 'zod'

/**
Expand Down Expand Up @@ -64,6 +63,17 @@ const getFileType = async (file: CrossPlatformFile) => {
return { mime: file.type }
}

if (isNodeFile(file)) {
if (file.type) {
return { mime: file.type }
}
// Loaded on demand so browser and native bundles don't evaluate it
const fileTypeNode: any = await import('file-type')
const { fromBuffer } =
'fromBuffer' in fileTypeNode ? fileTypeNode : fileTypeNode.default
return await fromBuffer(file.buffer)
}

let fileTypeBrowser: any
if (typeof window !== 'undefined' && window) {
fileTypeBrowser = await import('file-type/browser')
Expand All @@ -72,13 +82,7 @@ const getFileType = async (file: CrossPlatformFile) => {
}
}

if (isNodeFile(file)) {
return file.type
? { mime: file.type }
: await fileType.fromBuffer(file.buffer)
} else {
return await fileTypeBrowser.fromBlob(file)
}
return await fileTypeBrowser.fromBlob(file)
}

export const ImageFile = CrossPlatformFileSchema.refine(
Expand Down
4 changes: 4 additions & 0 deletions packages/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<head>
<meta charset="utf-8" />

<!-- API (fetch is CORS, so crossorigin) and the Optimizely datafile -->
<link rel="preconnect" href="%API_URL%" crossorigin />
<link rel="preconnect" href="https://experiments.audius.co" />

<!-- Avenir Font -->
<link rel="preload" as="style" href="/fonts.css" />
<link
Expand Down
Loading
Loading