Skip to content

Commit ad9eafc

Browse files
RihanArfanatinuxfarnabaz
authored
feat: add support for worker type (#56)
Co-authored-by: Sébastien Chopin <[email protected]> Co-authored-by: Farnabaz <[email protected]>
1 parent 6be284a commit ad9eafc

File tree

6 files changed

+171
-32
lines changed

6 files changed

+171
-32
lines changed

src/commands/deploy.mjs

+72-26
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { join, resolve, relative } from 'pathe'
88
import { execa } from 'execa'
99
import { setupDotenv } from 'c12'
1010
import { $api, fetchUser, selectTeam, selectProject, projectPath, fetchProject, linkProject, gitInfo } from '../utils/index.mjs'
11-
import { getStorage, getPathsToDeploy, getFile, uploadAssetsToCloudflare, isMetaPath, isServerPath, getPublicFiles } from '../utils/deploy.mjs'
11+
import { getStorage, getPathsToDeploy, getFile, uploadAssetsToCloudflare, uploadWorkersAssetsToCloudflare, isMetaPath, isWorkerMetaPath, isServerPath, isWorkerServerPath, getPublicFiles, getWorkerPublicFiles } from '../utils/deploy.mjs'
1212
import { createMigrationsTable, fetchRemoteMigrations, queryDatabase } from '../utils/database.mjs'
1313
import login from './login.mjs'
1414
import ensure from './ensure.mjs'
@@ -105,6 +105,16 @@ export default defineCommand({
105105
consola.success(`Connected to ${colors.blueBright(linkedProject.teamSlug)} team.`)
106106
consola.success(`Linked to ${colors.blueBright(linkedProject.slug)} project.`)
107107

108+
if (linkedProject.type === 'worker' && deployEnv === 'preview') {
109+
consola.warn('Currently NuxtHub on Workers (BETA) does not support preview environments.')
110+
const shouldDeploy = await confirm({
111+
message: `Deploy ${colors.blueBright(projectPath())} to production instead?`
112+
})
113+
if (!shouldDeploy || isCancel(shouldDeploy)) {
114+
return consola.log('Cancelled.')
115+
}
116+
}
117+
108118
// #region Build
109119
if (args.build) {
110120
consola.info('Building the Nuxt project...')
@@ -135,6 +145,11 @@ export default defineCommand({
135145
const fileKeys = await storage.getKeys()
136146
const pathsToDeploy = getPathsToDeploy(fileKeys)
137147
const config = await storage.getItem('hub.config.json')
148+
if (!config.nitroPreset && linkedProject.type === 'worker') {
149+
consola.error('Please upgrade `@nuxthub/core` to the latest version to deploy to a worker project.')
150+
process.exit(1)
151+
}
152+
const isWorkerPreset = ['cloudflare_module', 'cloudflare_durable', 'cloudflare-module', 'cloudflare-durable'].includes(config.nitroPreset)
138153
const { format: formatNumber } = new Intl.NumberFormat('en-US')
139154

140155
let spinner = ora(`Preparing ${colors.blueBright(linkedProject.slug)} deployment for ${deployEnvColored}...`).start()
@@ -145,40 +160,64 @@ export default defineCommand({
145160
spinnerColorIndex = (spinnerColorIndex + 1) % spinnerColors.length
146161
}, 2500)
147162

148-
let deploymentKey, serverFiles, metaFiles
163+
let deploymentKey, serverFiles, metaFiles, completionToken
149164
try {
150-
const publicFiles = await getPublicFiles(storage, pathsToDeploy)
151-
152-
const deploymentInfo = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/prepare`, {
165+
let url = `/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/prepare`
166+
let publicFiles, publicManifest
167+
168+
if (isWorkerPreset) {
169+
url = `/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/worker/prepare`
170+
publicFiles = await getWorkerPublicFiles(storage, pathsToDeploy)
171+
/**
172+
* { "/index.html": { hash: "hash", size: 30 }
173+
*/
174+
publicManifest = publicFiles.reduce((acc, file) => {
175+
acc[file.path] = {
176+
hash: file.hash,
177+
size: file.size
178+
}
179+
return acc
180+
}, {})
181+
} else {
182+
publicFiles = await getPublicFiles(storage, pathsToDeploy)
183+
/**
184+
* { "/index.html": "hash" }
185+
*/
186+
publicManifest = publicFiles.reduce((acc, file) => {
187+
acc[file.path] = file.hash
188+
return acc
189+
}, {})
190+
}
191+
// Get deployment info by preparing the deployment
192+
const deploymentInfo = await $api(url, {
153193
method: 'POST',
154194
body: {
155195
config,
156-
/**
157-
* Public manifest is a map of file paths to their unique hash (SHA256 sliced to 32 characters).
158-
* @example
159-
* {
160-
* "/index.html": "hash",
161-
* "/assets/image.png": "hash"
162-
* }
163-
*/
164-
publicManifest: publicFiles.reduce((acc, file) => {
165-
acc[file.path] = file.hash
166-
return acc
167-
}, {})
196+
publicManifest
168197
}
169198
})
170199
spinner.succeed(`${colors.blueBright(linkedProject.slug)} ready to deploy.`)
171-
const { missingPublicHashes, cloudflareUploadJwt } = deploymentInfo
172200
deploymentKey = deploymentInfo.deploymentKey
201+
202+
const { cloudflareUploadJwt, buckets, accountId } = deploymentInfo
203+
// missingPublicHash is sent for pages & buckets for worker
204+
let missingPublicHashes = deploymentInfo.missingPublicHashes || buckets.flat()
173205
const publicFilesToUpload = publicFiles.filter(file => missingPublicHashes.includes(file.hash))
174206

175207
if (publicFilesToUpload.length) {
176208
const totalSizeToUpload = publicFilesToUpload.reduce((acc, file) => acc + file.size, 0)
177209
spinner = ora(`Uploading ${colors.blueBright(formatNumber(publicFilesToUpload.length))} new static assets (${colors.blueBright(prettyBytes(totalSizeToUpload))})...`).start()
178-
await uploadAssetsToCloudflare(publicFilesToUpload, cloudflareUploadJwt, ({ progressSize, totalSize }) => {
179-
const percentage = Math.round((progressSize / totalSize) * 100)
180-
spinner.text = `${percentage}% uploaded (${prettyBytes(progressSize)}/${prettyBytes(totalSize)})...`
181-
})
210+
if (linkedProject.type === 'pages') {
211+
await uploadAssetsToCloudflare(publicFilesToUpload, cloudflareUploadJwt, ({ progressSize, totalSize }) => {
212+
const percentage = Math.round((progressSize / totalSize) * 100)
213+
spinner.text = `${percentage}% uploaded (${prettyBytes(progressSize)}/${prettyBytes(totalSize)})...`
214+
})
215+
} else {
216+
completionToken = await uploadWorkersAssetsToCloudflare(accountId, publicFilesToUpload, cloudflareUploadJwt, ({ progressSize, totalSize }) => {
217+
const percentage = Math.round((progressSize / totalSize) * 100)
218+
spinner.text = `${percentage}% uploaded (${prettyBytes(progressSize)}/${prettyBytes(totalSize)})...`
219+
})
220+
}
182221
spinner.succeed(`${colors.blueBright(formatNumber(publicFilesToUpload.length))} new static assets uploaded (${colors.blueBright(prettyBytes(totalSizeToUpload))})`)
183222
}
184223

@@ -188,8 +227,14 @@ export default defineCommand({
188227
consola.info(`${colors.blueBright(formatNumber(publicFiles.length))} static assets (${colors.blueBright(prettyBytes(totalSize))} / ${colors.blueBright(prettyBytes(totalGzipSize))} gzip)`)
189228
}
190229

191-
metaFiles = await Promise.all(pathsToDeploy.filter(isMetaPath).map(p => getFile(storage, p, 'base64')))
192-
serverFiles = await Promise.all(pathsToDeploy.filter(isServerPath).map(p => getFile(storage, p, 'base64')))
230+
metaFiles = await Promise.all(pathsToDeploy.filter(isWorkerPreset ? isWorkerMetaPath : isMetaPath).map(p => getFile(storage, p, 'base64')))
231+
serverFiles = await Promise.all(pathsToDeploy.filter(isWorkerPreset ? isWorkerServerPath : isServerPath).map(p => getFile(storage, p, 'base64')))
232+
if (isWorkerPreset) {
233+
serverFiles = serverFiles.map(file => ({
234+
...file,
235+
path: file.path.replace('/server/', '/')
236+
}))
237+
}
193238
const serverFilesSize = serverFiles.reduce((acc, file) => acc + file.size, 0)
194239
const serverFilesGzipSize = serverFiles.reduce((acc, file) => acc + file.gzipSize, 0)
195240
consola.info(`${colors.blueBright(formatNumber(serverFiles.length))} server files (${colors.blueBright(prettyBytes(serverFilesSize))} / ${colors.blueBright(prettyBytes(serverFilesGzipSize))} gzip)...`)
@@ -284,13 +329,14 @@ export default defineCommand({
284329

285330
// #region Complete deployment
286331
spinner = ora(`Deploying ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}...`).start()
287-
const deployment = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/complete`, {
332+
const deployment = await $api(`/teams/${linkedProject.teamSlug}/projects/${linkedProject.slug}/${deployEnv}/deploy/${isWorkerPreset ? 'worker/complete' : 'complete'}`, {
288333
method: 'POST',
289334
body: {
290335
deploymentKey,
291336
git,
292337
serverFiles,
293-
metaFiles
338+
metaFiles,
339+
completionToken
294340
},
295341
}).catch((err) => {
296342
spinner.fail(`Failed to deploy ${colors.blueBright(linkedProject.slug)} to ${deployEnvColored}.`)

src/commands/preview.mjs

+13-2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export default defineCommand({
1919
required: false,
2020
default: '.'
2121
},
22+
'log-level': {
23+
type: 'string',
24+
description: 'The log level to use.',
25+
required: false,
26+
default: 'log',
27+
valueHint: 'debug, info, log, warn, error, none'
28+
}
2229
},
2330
async run({ args }) {
2431
const cmdCwd = process.cwd()
@@ -75,13 +82,17 @@ export default defineCommand({
7582
}
7683
throw err
7784
}
85+
const wranglerArgs = []
86+
if (args['log-level']) {
87+
wranglerArgs.push(`--log-level=${args['log-level']}`)
88+
}
7889
if (nitroConfig.preset === 'cloudflare-pages') {
7990
consola.info(`Starting \`wrangler pages dev .\` command...`)
80-
await execa(options)`wrangler pages dev .`
91+
await execa(options)`wrangler pages dev . ${wranglerArgs}`
8192
.catch(cmdError)
8293
} else {
8394
consola.info(`Starting \`wrangler dev\` command...`)
84-
await execa(options)`wrangler dev`
95+
await execa(options)`wrangler dev ${wranglerArgs}`
8596
.catch(cmdError)
8697
}
8798

src/internal.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
export { getStorage, getPathsToDeploy, getPublicFiles, uploadAssetsToCloudflare, isMetaPath, getFile, isServerPath } from './utils/deploy.mjs';
1+
export { getStorage, getPathsToDeploy, getFile, getPublicFiles, getWorkerPublicFiles, uploadAssetsToCloudflare, uploadWorkersAssetsToCloudflare, isMetaPath, isWorkerMetaPath, isServerPath, isWorkerServerPath, } from './utils/deploy.mjs';
22
export { CreateDatabaseMigrationsTableQuery, ListDatabaseMigrationsQuery } from './utils/database.mjs';
33
export { generateWrangler } from './utils/wrangler.mjs';

src/utils/data.mjs

+10
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,15 @@ export async function selectProject(team) {
131131
})
132132
if (isCancel(projectName)) return null
133133
projectName = projectName || defaultProjectName
134+
const projectType = await select({
135+
message: 'Select your project type',
136+
initialValue: 'pages',
137+
options: [
138+
{ label: 'Cloudflare Pages', value: 'pages' },
139+
{ label: 'Cloudflare Workers (beta)', value: 'worker' },
140+
]
141+
})
142+
if (isCancel(projectType)) return null
134143
const projectLocation = await select({
135144
message: 'Select a region for the storage',
136145
initialValue: 'weur',
@@ -159,6 +168,7 @@ export async function selectProject(team) {
159168
method: 'POST',
160169
body: {
161170
name: projectName,
171+
type: projectType,
162172
location: projectLocation,
163173
productionBranch: productionBranch || defaultProductionBranch
164174
}

src/utils/deploy.mjs

+74-2
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,18 @@ export const META_PATHS = [
126126
'/nitro.json',
127127
'/hub.config.json',
128128
'/wrangler.toml',
129+
'/package-lock.json',
130+
'/package.json'
129131
]
130132

131133
export const isMetaPath = (path) => META_PATHS.includes(path)
132134
export const isServerPath = (path) => path.startsWith('/_worker.js/')
133135
export const isPublicPath = (path) => !isMetaPath(path) && !isServerPath(path)
134136

137+
export const isWorkerMetaPath = (path) => META_PATHS.includes(path)
138+
export const isWorkerPublicPath = (path) => path.startsWith('/public/')
139+
export const isWorkerServerPath = (path) => path.startsWith('/server/')
140+
135141
/**
136142
* Get all public files with their metadata
137143
* @param {import('unstorage').Storage} storage - Storage instance
@@ -143,9 +149,18 @@ export async function getPublicFiles(storage, paths) {
143149
paths.filter(isPublicPath).map(p => getFile(storage, p, 'base64'))
144150
)
145151
}
152+
export async function getWorkerPublicFiles(storage, paths) {
153+
const files = await Promise.all(
154+
paths.filter(isWorkerPublicPath).map(p => getFile(storage, p, 'base64'))
155+
)
156+
return files.map((file) => ({
157+
...file,
158+
path: file.path.replace('/public/', '/')
159+
}))
160+
}
146161

147162
/**
148-
* Upload assets to Cloudflare with concurrent uploads
163+
* Upload assets to Cloudflare Pages with concurrent uploads
149164
* @param {Array<{ path: string, data: string, hash: string, contentType: string }>} files - Files to upload
150165
* @param {string} cloudflareUploadJwt - Cloudflare upload JWT
151166
* @param {Function} onProgress - Callback function to update progress
@@ -200,4 +215,61 @@ export async function uploadAssetsToCloudflare(files, cloudflareUploadJwt, onPro
200215
}
201216
}
202217

203-
// async function uploadToCloudflare(body, cloudflareUploadJwt) {
218+
219+
/**
220+
* Upload assets to Cloudflare Workers with concurrent uploads
221+
* @param {Array<string<string>} buckets - Buckets of hashes to upload
222+
* @param {Array<{ path: string, data: string, hash: string, contentType: string }>} files - Files to upload
223+
* @param {string} cloudflareUploadJwt - Cloudflare upload JWT
224+
* @param {Function} onProgress - Callback function to update progress
225+
*/
226+
export async function uploadWorkersAssetsToCloudflare(accountId, files, cloudflareUploadJwt, onProgress) {
227+
const chunks = await createChunks(files)
228+
if (!chunks.length) {
229+
return
230+
}
231+
232+
let filesUploaded = 0
233+
let progressSize = 0
234+
let completionToken
235+
const totalSize = files.reduce((acc, file) => acc + file.size, 0)
236+
for (let i = 0; i < chunks.length; i += CONCURRENT_UPLOADS) {
237+
const chunkGroup = chunks.slice(i, i + CONCURRENT_UPLOADS)
238+
239+
await Promise.all(chunkGroup.map(async (filesInChunk) => {
240+
const form = new FormData()
241+
for (const file of filesInChunk) {
242+
form.append(file.hash, new File([file.data], file.hash, { type: file.contentType}), file.hash)
243+
}
244+
return ofetch(`/accounts/${accountId}/workers/assets/upload?base64=true`, {
245+
baseURL: 'https://api.cloudflare.com/client/v4/',
246+
method: 'POST',
247+
headers: {
248+
Authorization: `Bearer ${cloudflareUploadJwt}`
249+
},
250+
retry: MAX_UPLOAD_ATTEMPTS,
251+
retryDelay: UPLOAD_RETRY_DELAY,
252+
body: form
253+
})
254+
.then((data) => {
255+
if (data && data.result?.jwt) {
256+
completionToken = data.result.jwt
257+
}
258+
if (typeof onProgress === 'function') {
259+
filesUploaded += filesInChunk.length
260+
progressSize += filesInChunk.reduce((acc, file) => acc + file.size, 0)
261+
onProgress({ progress: filesUploaded, progressSize, total: files.length, totalSize })
262+
}
263+
})
264+
.catch((err) => {
265+
if (err.data) {
266+
throw new Error(`Error while uploading assets to Cloudflare: ${JSON.stringify(err.data)} - ${err.message}`)
267+
}
268+
else {
269+
throw new Error(`Error while uploading assets to Cloudflare: ${err.message.split(' - ')[1] || err.message}`)
270+
}
271+
})
272+
}))
273+
}
274+
return completionToken
275+
}

src/utils/wrangler.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ export function generateWrangler(hub, { preset } = {}) {
1313
if (preset === 'cloudflare-durable') {
1414
wrangler.durable_objects ||= {}
1515
wrangler.durable_objects.bindings = [{ name: '$DurableObject', class_name: '$DurableObject' }]
16-
wrangler.migrations = [{ tag: 'v1', new_classes: ['$DurableObject'] }]
16+
wrangler.migrations = [{ tag: 'v1', new_sqlite_classes: ['$DurableObject'] }]
1717
}
1818
}
1919

0 commit comments

Comments
 (0)