Skip to content

Commit ce27f2b

Browse files
committed
feat: support uploading large projects
1 parent 0c23adf commit ce27f2b

File tree

4 files changed

+150
-43
lines changed

4 files changed

+150
-43
lines changed

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@
2525
},
2626
"dependencies": {
2727
"@clack/prompts": "^0.9.0",
28-
"blake3-wasm": "^2.1.5",
2928
"c12": "^2.0.1",
3029
"ci-info": "^4.1.0",
3130
"citty": "^0.1.6",

pnpm-lock.yaml

Lines changed: 0 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/deploy.mjs

Lines changed: 145 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { existsSync } from 'fs'
1212
import mime from 'mime'
1313
import prettyBytes from 'pretty-bytes'
1414
import { setupDotenv } from 'c12'
15+
import { ofetch } from 'ofetch'
1516
import { $api, fetchUser, selectTeam, selectProject, projectPath, withTilde, fetchProject, linkProject, hashFile, gitInfo, getPackageJson, MAX_ASSET_SIZE } from '../utils/index.mjs'
1617
import { createMigrationsTable, fetchRemoteMigrations, queryDatabase } from '../utils/database.mjs'
1718
import login from './login.mjs'
@@ -142,49 +143,163 @@ export default defineCommand({
142143
const fileKeys = await srcStorage.getKeys()
143144
const filesToDeploy = fileKeys.filter(fileKey => {
144145
if (fileKey.startsWith('.wrangler:')) return false
146+
if (fileKey.startsWith('_worker.js:')) return false
145147
if (fileKey.startsWith('node_modules:')) return false
146148
if (fileKey === 'wrangler.toml') return false
147149
if (fileKey === '.dev.vars') return false
148150
if (fileKey.startsWith('database:migrations:')) return false
149151
return true
150152
})
151-
if (!filesToDeploy.find(key => key === 'hub.config.json')) {
152-
consola.error('`dist/hub.config.json` is missing, please make that `@nuxthub/core` is enabled in your `nuxt.config.ts`.')
153-
process.exit(1)
154-
}
155-
const files = await Promise.all(filesToDeploy.map(async (fileKey) => {
156-
const data = await srcStorage.getItemRaw(fileKey)
157-
const filepath = fileKey.replace(/:/g, '/')
158-
const fileContentBase64 = data.toString('base64')
159153

160-
if (data.size > MAX_ASSET_SIZE) {
161-
console.error(`NuxtHub deploy only supports files up to ${prettyBytes(MAX_ASSET_SIZE, { binary: true })} in size\n${withTilde(filepath)} is ${prettyBytes(data.size, { binary: true })} in size`)
162-
process.exit(1)
163-
}
154+
const SPECIAL_FILES = [
155+
'_redirects',
156+
'_headers',
157+
'_routes.json',
158+
'nitro.json',
159+
'hub.config.json'
160+
]
161+
162+
const specialFilesMetadata = await Promise.all(
163+
filesToDeploy.map(async (fileKey) => {
164+
const filepath = fileKey.replace(/:/g, '/')
165+
const isSpecialFile = SPECIAL_FILES.includes(filepath) || filepath.startsWith('_worker.js/')
166+
167+
const data = await srcStorage.getItemRaw(fileKey)
168+
const fileContentBase64 = data.toString('base64')
164169

165-
return {
166-
path: joinURL('/', filepath),
167-
key: hashFile(filepath, fileContentBase64),
168-
value: fileContentBase64,
169-
base64: true,
170-
metadata: {
171-
contentType: mime.getType(filepath) || 'application/octet-stream'
170+
if (!isSpecialFile) {
171+
return {
172+
path: joinURL('/', filepath),
173+
key: hashFile(filepath, fileContentBase64)
174+
}
175+
}
176+
177+
if (data.size > MAX_ASSET_SIZE) {
178+
console.error(`NuxtHub deploy only supports files up to ${prettyBytes(MAX_ASSET_SIZE, { binary: true })} in size\n${withTilde(filepath)} is ${prettyBytes(data.size, { binary: true })} in size`)
179+
process.exit(1)
172180
}
173-
}
174-
}))
175-
// TODO: make a tar with nanotar by the amazing Pooya Parsa (@pi0)
181+
182+
return {
183+
path: joinURL('/', filepath),
184+
key: hashFile(filepath, fileContentBase64),
185+
value: fileContentBase64,
186+
base64: true,
187+
metadata: {
188+
contentType: mime.getType(filepath) || 'application/octet-stream'
189+
}
190+
}
191+
})
192+
)
176193

177194
const spinner = ora(`Deploying ${colors.blue(linkedProject.slug)} to ${deployEnvColored}...`).start()
178195
setTimeout(() => spinner.color = 'magenta', 2500)
179196
setTimeout(() => spinner.color = 'blue', 5000)
180197
setTimeout(() => spinner.color = 'yellow', 7500)
181-
const deployment = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/deploy`, {
182-
method: 'POST',
183-
body: {
184-
git,
185-
files
198+
199+
let deployment
200+
try {
201+
const deploymentInfo = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/deploy`, {
202+
method: 'POST',
203+
headers: { 'X-NuxtHub-Api-Version': '2025-01-08' },
204+
body: {
205+
git,
206+
files: specialFilesMetadata,
207+
}
208+
})
209+
const { missingHashes, cloudflareUploadJwt, deploymentKey } = deploymentInfo
210+
211+
const getFileContent = async (fileKey) => {
212+
const data = await srcStorage.getItemRaw(fileKey)
213+
const filepath = fileKey.replace(/:/g, '/')
214+
const fileContentBase64 = data.toString('base64')
215+
216+
if (data.size > MAX_ASSET_SIZE) {
217+
throw new Error(`File ${withTilde(filepath)} exceeds size limit of ${prettyBytes(MAX_ASSET_SIZE, { binary: true })}`)
218+
}
219+
220+
return {
221+
path: joinURL('/', filepath),
222+
key: hashFile(filepath, fileContentBase64),
223+
value: fileContentBase64,
224+
base64: true,
225+
metadata: {
226+
contentType: mime.getType(filepath) || 'application/octet-stream'
227+
}
228+
}
186229
}
187-
}).catch((err) => {
230+
231+
const filesToUpload = filesToDeploy.filter(fileKey => {
232+
const filepath = fileKey.replace(/:/g, '/')
233+
const existingFile = specialFilesMetadata.find(f => f.path === joinURL('/', filepath))
234+
return missingHashes.includes(existingFile.key)
235+
})
236+
237+
// Create chunks based on base64 size
238+
const MAX_CHUNK_SIZE = 50 * 1024 * 1024 // 50MiB chunk size (in bytes)
239+
240+
const createChunks = async (files) => {
241+
const chunks = []
242+
let currentChunk = []
243+
let currentSize = 0
244+
245+
for (const fileKey of files) {
246+
const fileContent = await getFileContent(fileKey)
247+
const fileSize = Buffer.byteLength(fileContent.value, 'base64')
248+
249+
// If single file is bigger than chunk size, it gets its own chunk
250+
if (fileSize > MAX_CHUNK_SIZE) {
251+
// If we have accumulated files, push them as a chunk first
252+
if (currentChunk.length > 0) {
253+
chunks.push(currentChunk)
254+
currentChunk = []
255+
currentSize = 0
256+
}
257+
// Push large file as its own chunk
258+
chunks.push([fileContent])
259+
continue
260+
}
261+
262+
if (currentSize + fileSize > MAX_CHUNK_SIZE && currentChunk.length > 0) {
263+
chunks.push(currentChunk)
264+
currentChunk = []
265+
currentSize = 0
266+
}
267+
268+
currentChunk.push(fileContent)
269+
currentSize += fileSize
270+
}
271+
272+
if (currentChunk.length > 0) {
273+
chunks.push(currentChunk)
274+
}
275+
276+
return chunks
277+
}
278+
279+
// Upload assets to Cloudflare with max concurrent uploads
280+
const CONCURRENT_UPLOADS = 3
281+
const chunks = await createChunks(filesToUpload)
282+
283+
for (let i = 0; i < chunks.length; i += CONCURRENT_UPLOADS) {
284+
const chunkGroup = chunks.slice(i, i + CONCURRENT_UPLOADS)
285+
await Promise.all(chunkGroup.map(async (files) => {
286+
return ofetch('/pages/assets/upload', {
287+
baseURL: 'https://api.cloudflare.com/client/v4/',
288+
method: 'POST',
289+
headers: {
290+
Authorization: `Bearer ${cloudflareUploadJwt}`
291+
},
292+
body: files
293+
})
294+
}))
295+
}
296+
297+
deployment = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/deploy/done`, {
298+
method: 'POST',
299+
headers: { 'X-NuxtHub-Api-Version': '2025-01-08' },
300+
body: { deploymentKey },
301+
})
302+
} catch (err) {
188303
spinner.fail(`Failed to deploy ${colors.blue(linkedProject.slug)} to ${deployEnvColored}.`)
189304
// Error with workers size limit
190305
if (err.data?.data?.name === 'ZodError') {
@@ -196,7 +311,8 @@ export default defineCommand({
196311
consola.error(err.message.split(' - ')[1] || err.message)
197312
}
198313
process.exit(1)
199-
})
314+
}
315+
200316
spinner.succeed(`Deployed ${colors.blue(linkedProject.slug)} to ${deployEnvColored}...`)
201317

202318
// Apply migrations

src/utils/deploy.mjs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import { createHash } from 'node:crypto'
12
import { extname } from 'pathe'
2-
import { hash as blake3hash } from 'blake3-wasm'
33

4-
// https://github.com/cloudflare/workers-sdk/blob/main/packages/wrangler/src/pages/hash.ts#L5
5-
export function hashFile (filepath, base64) {
4+
export function hashFile(filepath, base64) {
65
const extension = extname(filepath).substring(1)
76

8-
return blake3hash(base64 + extension)
9-
.toString('hex')
7+
return createHash('sha1')
8+
.update(base64 + extension)
9+
.digest('hex')
1010
.slice(0, 32)
1111
}

0 commit comments

Comments
 (0)