@@ -12,6 +12,7 @@ import { existsSync } from 'fs'
12
12
import mime from 'mime'
13
13
import prettyBytes from 'pretty-bytes'
14
14
import { setupDotenv } from 'c12'
15
+ import { ofetch } from 'ofetch'
15
16
import { $api , fetchUser , selectTeam , selectProject , projectPath , withTilde , fetchProject , linkProject , hashFile , gitInfo , getPackageJson , MAX_ASSET_SIZE } from '../utils/index.mjs'
16
17
import { createMigrationsTable , fetchRemoteMigrations , queryDatabase } from '../utils/database.mjs'
17
18
import login from './login.mjs'
@@ -142,49 +143,163 @@ export default defineCommand({
142
143
const fileKeys = await srcStorage . getKeys ( )
143
144
const filesToDeploy = fileKeys . filter ( fileKey => {
144
145
if ( fileKey . startsWith ( '.wrangler:' ) ) return false
146
+ if ( fileKey . startsWith ( '_worker.js:' ) ) return false
145
147
if ( fileKey . startsWith ( 'node_modules:' ) ) return false
146
148
if ( fileKey === 'wrangler.toml' ) return false
147
149
if ( fileKey === '.dev.vars' ) return false
148
150
if ( fileKey . startsWith ( 'database:migrations:' ) ) return false
149
151
return true
150
152
} )
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' )
159
153
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' )
164
169
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 )
172
180
}
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
+ )
176
193
177
194
const spinner = ora ( `Deploying ${ colors . blue ( linkedProject . slug ) } to ${ deployEnvColored } ...` ) . start ( )
178
195
setTimeout ( ( ) => spinner . color = 'magenta' , 2500 )
179
196
setTimeout ( ( ) => spinner . color = 'blue' , 5000 )
180
197
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
+ }
186
229
}
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 ) {
188
303
spinner . fail ( `Failed to deploy ${ colors . blue ( linkedProject . slug ) } to ${ deployEnvColored } .` )
189
304
// Error with workers size limit
190
305
if ( err . data ?. data ?. name === 'ZodError' ) {
@@ -196,7 +311,8 @@ export default defineCommand({
196
311
consola . error ( err . message . split ( ' - ' ) [ 1 ] || err . message )
197
312
}
198
313
process . exit ( 1 )
199
- } )
314
+ }
315
+
200
316
spinner . succeed ( `Deployed ${ colors . blue ( linkedProject . slug ) } to ${ deployEnvColored } ...` )
201
317
202
318
// Apply migrations
0 commit comments