diff --git a/src/commands/apps/bundles/create.ts b/src/commands/apps/bundles/create.ts index 7a08eaf..fd89d0a 100644 --- a/src/commands/apps/bundles/create.ts +++ b/src/commands/apps/bundles/create.ts @@ -1,17 +1,20 @@ import { defineCommand } from 'citty'; import consola from 'consola'; -import { prompt } from '../../../utils/prompt'; -import zip from '../../../utils/zip'; import FormData from 'form-data'; -import { createReadStream } from 'node:fs'; -import authorizationService from '../../../services/authorization-service'; -import appsService from '../../../services/apps'; +import { createReadStream } from 'fs'; +import appBundleFilesService from '../../../services/app-bundle-files'; import appBundlesService from '../../../services/app-bundles'; +import appsService from '../../../services/apps'; +import authorizationService from '../../../services/authorization-service'; +import { AppBundleFileDto } from '../../../types/app-bundle-file'; +import { createBufferFromPath, createBufferFromReadStream } from '../../../utils/buffer'; import { getMessageFromUnknownError } from '../../../utils/error'; +import { fileExistsAtPath, getFilesInDirectoryAndSubdirectories, isDirectory } from '../../../utils/file'; import { createHash } from '../../../utils/hash'; -import { createBuffer, createBufferFromPath } from '../../../utils/buffer'; +import { generateManifestJson } from '../../../utils/manifest'; +import { prompt } from '../../../utils/prompt'; import { createSignature } from '../../../utils/signature'; -import { fileExistsAtPath } from '../../../utils/file'; +import zip from '../../../utils/zip'; export default defineCommand({ meta: { @@ -30,6 +33,10 @@ export default defineCommand({ type: 'string', description: 'App ID to deploy to.', }, + artifactType: { + type: 'string', + description: 'The type of artifact to deploy. Must be either `manifest` or `zip`. The default is `zip`.', + }, channel: { type: 'string', description: 'Channel to associate the bundle with.', @@ -65,11 +72,20 @@ export default defineCommand({ return; } - const { androidMax, androidMin, privateKey, rollout, iosMax, iosMin } = ctx.args; - let appId = ctx.args.appId; - let channelName = ctx.args.channel; - let path = ctx.args.path; - let url = ctx.args.url; + let androidMax = ctx.args.androidMax as string | undefined; + let androidMin = ctx.args.androidMin as string | undefined; + let appId = ctx.args.appId as string | undefined; + let artifactType = + ctx.args.artifactType === 'manifest' || ctx.args.artifactType === 'zip' + ? ctx.args.artifactType + : ('zip' as 'manifest' | 'zip'); + let channelName = ctx.args.channel as string | undefined; + let iosMax = ctx.args.iosMax as string | undefined; + let iosMin = ctx.args.iosMin as string | undefined; + let path = ctx.args.path as string | undefined; + let privateKey = ctx.args.privateKey as string | undefined; + let rollout = ctx.args.rollout as string | undefined; + let url = ctx.args.url as string | undefined; if (!path && !url) { path = await prompt('Enter the path to the app bundle:', { type: 'text', @@ -79,6 +95,19 @@ export default defineCommand({ return; } } + if (artifactType === 'manifest' && path) { + const pathIsDirectory = isDirectory(path); + if (!pathIsDirectory) { + consola.error('The path must be a folder when creating a bundle with an artifact type of `manifest`.'); + return; + } + } + // Check if the path exists + const pathExists = await fileExistsAtPath(path!); + if (!pathExists) { + consola.error(`The path does not exist.`); + return; + } if (!appId) { const apps = await appsService.findAll(); if (apps.length === 0) { @@ -90,6 +119,10 @@ export default defineCommand({ type: 'select', options: apps.map((app) => ({ label: app.name, value: app.id })), }); + if (!appId) { + consola.error('You must select an app to deploy to.'); + return; + } if (!channelName) { const promptChannel = await prompt('Do you want to deploy to a specific channel?', { type: 'select', @@ -99,10 +132,14 @@ export default defineCommand({ channelName = await prompt('Enter the channel name:', { type: 'text', }); + if (!channelName) { + consola.error('The channel name must be at least one character long.'); + return; + } } } } - let privateKeyBuffer; + let privateKeyBuffer: Buffer | undefined; if (privateKey) { if (privateKey.endsWith('.pem')) { const fileExists = await fileExistsAtPath(privateKey); @@ -120,25 +157,7 @@ export default defineCommand({ // Create form data const formData = new FormData(); - if (path) { - let fileBuffer; - if (zip.isZipped(path)) { - const readStream = createReadStream(path); - fileBuffer = await createBuffer(readStream); - } else { - consola.start('Zipping folder...'); - fileBuffer = await zip.zipFolder(path); - } - consola.start('Generating checksum...'); - const hash = await createHash(fileBuffer); - formData.append('file', fileBuffer, { filename: 'bundle.zip' }); - formData.append('checksum', hash); - if (privateKeyBuffer) { - consola.start('Signing bundle...'); - const signature = await createSignature(privateKeyBuffer, fileBuffer); - formData.append('signature', signature); - } - } + formData.append('artifactType', artifactType || 'zip'); if (url) { formData.append('url', url); } @@ -165,19 +184,145 @@ export default defineCommand({ if (iosMin) { formData.append('minIosAppVersionCode', iosMin); } - if (path) { - consola.start('Uploading...'); - } else { - consola.start('Creating...'); - } - // Upload the bundle + let appBundleId: string | undefined; try { - const response = await appBundlesService.create({ appId: appId, formData: formData }); + // Create the app bundle + consola.start('Creating bundle...'); + const response = await appBundlesService.create({ + appId, + artifactType, + channelName, + url, + maxAndroidAppVersionCode: androidMax, + maxIosAppVersionCode: iosMax, + minAndroidAppVersionCode: androidMin, + minIosAppVersionCode: androidMin, + }); + appBundleId = response.id; + if (path) { + let appBundleFileId: string | undefined; + // Upload the app bundle files + if (artifactType === 'manifest') { + await uploadFiles({ appId, appBundleId: response.id, path, privateKeyBuffer }); + } else { + const result = await uploadZip({ appId, appBundleId: response.id, path, privateKeyBuffer }); + appBundleFileId = result.appBundleFileId; + } + // Update the app bundle + consola.start('Updating bundle...'); + await appBundlesService.update({ appBundleFileId, appId, artifactStatus: 'ready', appBundleId: response.id }); + } consola.success('Bundle successfully created.'); consola.info(`Bundle ID: ${response.id}`); } catch (error) { + if (appBundleId) { + await appBundlesService.delete({ appId, appBundleId }).catch(() => { + // No-op + }); + } const message = getMessageFromUnknownError(error); consola.error(message); } }, }); + +const uploadFile = async (options: { + appId: string; + appBundleId: string; + fileBuffer: Buffer; + fileName: string; + href?: string; + privateKeyBuffer: Buffer | undefined; +}): Promise => { + // Generate checksum + const hash = await createHash(options.fileBuffer); + // Sign the bundle + let signature: string | undefined; + if (options.privateKeyBuffer) { + signature = await createSignature(options.privateKeyBuffer, options.fileBuffer); + } + // Create the multipart upload + return appBundleFilesService.create({ + appId: options.appId, + appBundleId: options.appBundleId, + checksum: hash, + fileBuffer: options.fileBuffer, + fileName: options.fileName, + href: options.href, + signature, + }); +}; + +const uploadFiles = async (options: { + appId: string; + appBundleId: string; + path: string; + privateKeyBuffer: Buffer | undefined; +}) => { + // Generate the manifest file + await generateManifestJson(options.path); + // Get all files in the directory + const files = await getFilesInDirectoryAndSubdirectories(options.path); + // Iterate over each file + const MAX_CONCURRENT_UPLOADS = 20; + let fileIndex = 0; + + const uploadNextFile = async () => { + if (fileIndex >= files.length) { + return; + } + + const file = files[fileIndex] as { path: string; name: string }; + fileIndex++; + + consola.start(`Uploading file (${fileIndex}/${files.length})...`); + const fileBuffer = await createBufferFromPath(file.path); + const fileName = file.name; + const href = file.path.replace(options.path + '/', ''); + + await uploadFile({ + appId: options.appId, + appBundleId: options.appBundleId, + fileBuffer, + fileName, + href, + privateKeyBuffer: options.privateKeyBuffer, + }); + await uploadNextFile(); + }; + + const uploadPromises = Array(MAX_CONCURRENT_UPLOADS); + for (let i = 0; i < MAX_CONCURRENT_UPLOADS; i++) { + uploadPromises[i] = uploadNextFile(); + } + await Promise.all(uploadPromises); +}; + +const uploadZip = async (options: { + appId: string; + appBundleId: string; + path: string; + privateKeyBuffer: Buffer | undefined; +}): Promise<{ appBundleFileId: string }> => { + // Read the zip file + let fileBuffer; + if (zip.isZipped(options.path)) { + const readStream = createReadStream(options.path); + fileBuffer = await createBufferFromReadStream(readStream); + } else { + consola.start('Zipping folder...'); + fileBuffer = await zip.zipFolder(options.path); + } + // Upload the zip file + consola.start('Uploading file...'); + const result = await uploadFile({ + appId: options.appId, + appBundleId: options.appBundleId, + fileBuffer, + fileName: 'bundle.zip', + privateKeyBuffer: options.privateKeyBuffer, + }); + return { + appBundleFileId: result.id, + }; +}; diff --git a/src/commands/apps/bundles/delete.ts b/src/commands/apps/bundles/delete.ts index 091d276..5789c92 100644 --- a/src/commands/apps/bundles/delete.ts +++ b/src/commands/apps/bundles/delete.ts @@ -1,9 +1,9 @@ import { defineCommand } from 'citty'; import consola from 'consola'; -import appsService from '../../../services/apps'; -import { prompt } from '../../../utils/prompt'; import appBundlesService from '../../../services/app-bundles'; +import appsService from '../../../services/apps'; import { getMessageFromUnknownError } from '../../../utils/error'; +import { prompt } from '../../../utils/prompt'; export default defineCommand({ meta: { @@ -53,7 +53,7 @@ export default defineCommand({ try { await appBundlesService.delete({ appId, - bundleId, + appBundleId: bundleId, }); consola.success('Bundle deleted successfully.'); } catch (error) { diff --git a/src/commands/apps/bundles/update.ts b/src/commands/apps/bundles/update.ts index 6a222f5..24fe836 100644 --- a/src/commands/apps/bundles/update.ts +++ b/src/commands/apps/bundles/update.ts @@ -1,10 +1,10 @@ import { defineCommand } from 'citty'; import consola from 'consola'; -import { prompt } from '../../../utils/prompt'; -import authorizationService from '../../../services/authorization-service'; -import appsService from '../../../services/apps'; import appBundlesService from '../../../services/app-bundles'; +import appsService from '../../../services/apps'; +import authorizationService from '../../../services/authorization-service'; import { getMessageFromUnknownError } from '../../../utils/error'; +import { prompt } from '../../../utils/prompt'; export default defineCommand({ meta: { @@ -73,7 +73,7 @@ export default defineCommand({ const rolloutAsNumber = parseFloat(rollout); await appBundlesService.update({ appId, - bundleId, + appBundleId: bundleId, maxAndroidAppVersionCode: androidMax, maxIosAppVersionCode: iosMax, minAndroidAppVersionCode: androidMin, diff --git a/src/commands/manifests/generate.ts b/src/commands/manifests/generate.ts new file mode 100644 index 0000000..b493c9a --- /dev/null +++ b/src/commands/manifests/generate.ts @@ -0,0 +1,41 @@ +import { defineCommand } from 'citty'; +import consola from 'consola'; +import { fileExistsAtPath } from '../../utils/file'; +import { generateManifestJson } from '../../utils/manifest'; +import { prompt } from '../../utils/prompt'; + +export default defineCommand({ + meta: { + description: 'Generate a manifest file.', + }, + args: { + path: { + type: 'string', + description: 'Path to the web assets folder (e.g. `www` or `dist`).', + }, + }, + run: async (ctx) => { + let path = ctx.args.path as string | undefined; + + if (!path) { + path = await prompt('Enter the path to the web assets folder:', { + type: 'text', + }); + if (!path) { + consola.error('You must provide a path to the web assets folder.'); + return; + } + } + + // Check if the path exists + const pathExists = await fileExistsAtPath(path); + if (!pathExists) { + consola.error(`The path does not exist.`); + return; + } + // Generate the manifest file + await generateManifestJson(path); + + consola.success('Manifest file generated.'); + }, +}); diff --git a/src/config/consts.ts b/src/config/consts.ts index 2f19ef5..caf9130 100644 --- a/src/config/consts.ts +++ b/src/config/consts.ts @@ -1,2 +1,3 @@ export const API_URL = 'https://api.cloud.capawesome.io/v1'; -// export const API_URL = 'http://api.cloud.capawesome.local/v1' +// export const API_URL = 'http://api.cloud.capawesome.local/v1'; +export const MANIFEST_JSON_FILE_NAME = 'capawesome-live-update-manifest.json'; // Do NOT change this! diff --git a/src/index.ts b/src/index.ts index 6da0c82..b7c762e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ const main = defineCommand({ 'apps:channels:create': import('./commands/apps/channels/create').then((mod) => mod.default), 'apps:channels:delete': import('./commands/apps/channels/delete').then((mod) => mod.default), 'apps:devices:delete': import('./commands/apps/devices/delete').then((mod) => mod.default), + 'manifests:generate': import('./commands/manifests/generate').then((mod) => mod.default), }, }); diff --git a/src/services/app-bundle-files.ts b/src/services/app-bundle-files.ts new file mode 100644 index 0000000..3e8cc0a --- /dev/null +++ b/src/services/app-bundle-files.ts @@ -0,0 +1,43 @@ +import FormData from 'form-data'; +import { AppBundleFileDto, CreateAppBundleFileDto } from '../types/app-bundle-file'; +import httpClient, { HttpClient } from '../utils/http-client'; +import authorizationService from './authorization-service'; + +export interface AppBundleFilesService { + create(dto: CreateAppBundleFileDto): Promise; +} + +class AppBundleFilesServiceImpl implements AppBundleFilesService { + private readonly httpClient: HttpClient; + + constructor(httpClient: HttpClient) { + this.httpClient = httpClient; + } + + async create(dto: CreateAppBundleFileDto): Promise { + const formData = new FormData(); + formData.append('checksum', dto.checksum); + formData.append('file', dto.fileBuffer, { filename: dto.fileName }); + if (dto.href) { + formData.append('href', dto.href); + } + if (dto.signature) { + formData.append('signature', dto.signature); + } + const response = await this.httpClient.post( + `/apps/${dto.appId}/bundles/${dto.appBundleId}/files`, + formData, + { + headers: { + Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, + ...formData.getHeaders(), + }, + }, + ); + return response.data; + } +} + +const appBundleFilesService: AppBundleFilesService = new AppBundleFilesServiceImpl(httpClient); + +export default appBundleFilesService; diff --git a/src/services/app-bundles.ts b/src/services/app-bundles.ts index 1d13ed8..94f22d5 100644 --- a/src/services/app-bundles.ts +++ b/src/services/app-bundles.ts @@ -1,11 +1,12 @@ -import { AppBundleDto, CreateAppBundleDto, DeleteAppBundleDto, UpadteAppBundleDto } from '../types'; +import FormData from 'form-data'; +import { AppBundleDto, CreateAppBundleDto, DeleteAppBundleDto, UpdateAppBundleDto } from '../types'; import httpClient, { HttpClient } from '../utils/http-client'; import authorizationService from './authorization-service'; export interface AppBundlesService { create(dto: CreateAppBundleDto): Promise; delete(dto: DeleteAppBundleDto): Promise; - update(dto: UpadteAppBundleDto): Promise; + update(dto: UpdateAppBundleDto): Promise; } class AppBundlesServiceImpl implements AppBundlesService { @@ -15,18 +16,41 @@ class AppBundlesServiceImpl implements AppBundlesService { this.httpClient = httpClient; } - async create(data: CreateAppBundleDto): Promise { - const response = await this.httpClient.post(`/apps/${data.appId}/bundles`, data.formData, { + async create(dto: CreateAppBundleDto): Promise { + const formData = new FormData(); + formData.append('artifactType', dto.artifactType); + if (dto.channelName) { + formData.append('channelName', dto.channelName); + } + if (dto.url) { + formData.append('url', dto.url); + } + if (dto.maxAndroidAppVersionCode) { + formData.append('maxAndroidAppVersionCode', dto.maxAndroidAppVersionCode); + } + if (dto.maxIosAppVersionCode) { + formData.append('maxIosAppVersionCode', dto.maxIosAppVersionCode); + } + if (dto.minAndroidAppVersionCode) { + formData.append('minAndroidAppVersionCode', dto.minAndroidAppVersionCode); + } + if (dto.minIosAppVersionCode) { + formData.append('minIosAppVersionCode', dto.minIosAppVersionCode); + } + if (dto.rolloutPercentage) { + formData.append('rolloutPercentage', dto.rolloutPercentage.toString()); + } + const response = await this.httpClient.post(`/apps/${dto.appId}/bundles`, formData, { headers: { Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, - ...data.formData.getHeaders(), + ...formData.getHeaders(), }, }); return response.data; } - async update(data: UpadteAppBundleDto): Promise { - const response = await this.httpClient.patch(`/apps/${data.appId}/bundles/${data.bundleId}`, data, { + async update(dto: UpdateAppBundleDto): Promise { + const response = await this.httpClient.patch(`/apps/${dto.appId}/bundles/${dto.appBundleId}`, dto, { headers: { Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, }, @@ -34,8 +58,8 @@ class AppBundlesServiceImpl implements AppBundlesService { return response.data; } - async delete(data: DeleteAppBundleDto): Promise { - await this.httpClient.delete(`/apps/${data.appId}/bundles/${data.bundleId}`, { + async delete(dto: DeleteAppBundleDto): Promise { + await this.httpClient.delete(`/apps/${dto.appId}/bundles/${dto.appBundleId}`, { headers: { Authorization: `Bearer ${authorizationService.getCurrentAuthorizationToken()}`, }, diff --git a/src/types/app-bundle-file.ts b/src/types/app-bundle-file.ts new file mode 100644 index 0000000..26338dc --- /dev/null +++ b/src/types/app-bundle-file.ts @@ -0,0 +1,13 @@ +export interface AppBundleFileDto { + id: string; +} + +export interface CreateAppBundleFileDto { + appId: string; + appBundleId: string; + checksum: string; + fileBuffer: Buffer; + fileName: string; + href?: string; + signature?: string; +} diff --git a/src/types/app-bundle.ts b/src/types/app-bundle.ts index fd37f82..0df1245 100644 --- a/src/types/app-bundle.ts +++ b/src/types/app-bundle.ts @@ -1,22 +1,34 @@ -import FormData from 'form-data'; - export interface AppBundleDto { id: string; } export interface CreateAppBundleDto { appId: string; - formData: FormData; + artifactType: 'manifest' | 'zip'; + channelName?: string; + url?: string; + maxAndroidAppVersionCode?: string; + maxIosAppVersionCode?: string; + minAndroidAppVersionCode?: string; + minIosAppVersionCode?: string; + rolloutPercentage?: number; } export interface DeleteAppBundleDto { + appBundleId: string; appId: string; - bundleId: string; } -export interface UpadteAppBundleDto { +export interface MultipartUploadDto { + key: string; + uploadId: string; +} + +export interface UpdateAppBundleDto { + appBundleFileId?: string; + appBundleId: string; appId: string; - bundleId: string; + artifactStatus?: 'pending' | 'ready'; maxAndroidAppVersionCode?: string; maxIosAppVersionCode?: string; minAndroidAppVersionCode?: string; diff --git a/src/types/index.ts b/src/types/index.ts index 466e264..7bc406a 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ export * from './app'; export * from './app-bundle'; -export * from './app-device'; export * from './app-channel'; +export * from './app-device'; export * from './user'; diff --git a/src/utils/buffer.ts b/src/utils/buffer.ts index 0abc1ad..d76fcfc 100644 --- a/src/utils/buffer.ts +++ b/src/utils/buffer.ts @@ -1,6 +1,27 @@ import { ReadStream } from 'fs'; -export const createBuffer = async (data: ReadStream): Promise => { +export const createBufferFromBlob = async (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + resolve(Buffer.from(reader.result)); + } else { + reject(new Error('Failed to convert Blob to Buffer.')); + } + }; + reader.onerror = reject; + reader.readAsArrayBuffer(blob); + }); +}; + +export const createBufferFromPath = async (path: string): Promise => { + const fs = await import('fs'); + const stream = fs.createReadStream(path); + return createBufferFromReadStream(stream); +}; + +export const createBufferFromReadStream = async (data: ReadStream): Promise => { const chunks: Buffer[] = []; return new Promise((resolve, reject) => { data.on('readable', () => { @@ -15,9 +36,3 @@ export const createBuffer = async (data: ReadStream): Promise => { data.on('error', reject); }); }; - -export const createBufferFromPath = async (path: string): Promise => { - const fs = await import('fs'); - const stream = fs.createReadStream(path); - return createBuffer(stream); -}; diff --git a/src/utils/error.ts b/src/utils/error.ts index 900af32..d3c1fbb 100644 --- a/src/utils/error.ts +++ b/src/utils/error.ts @@ -18,6 +18,8 @@ const getErrorMessageFromAxiosError = (error: AxiosError): string => { message = (error.response?.data as any)?.message; } else if ((error.response?.data as any)?.error?.issues[0]?.message) { message = (error.response?.data as any).error.issues[0].message; + } else if (typeof error.response?.data === 'string') { + message = error.response.data; } return message; }; diff --git a/src/utils/file.ts b/src/utils/file.ts index 4f441a0..c929cc1 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -1,3 +1,25 @@ +export const getFilesInDirectoryAndSubdirectories = async (path: string): Promise<{ path: string; name: string }[]> => { + const fs = await import('fs'); + const pathModule = await import('path'); + const files: { path: string; name: string }[] = []; + const walk = async (directory: string) => { + const dirEntries = await fs.promises.readdir(directory, { withFileTypes: true }).catch(() => []); + for (const dirEntry of dirEntries) { + const fullPath = pathModule.join(directory, dirEntry.name); + if (dirEntry.isDirectory()) { + await walk(fullPath); + } else { + files.push({ + name: dirEntry.name, + path: fullPath, + }); + } + } + }; + await walk(path); + return files; +}; + export const fileExistsAtPath = async (path: string): Promise => { const fs = await import('fs'); return new Promise((resolve) => { @@ -6,3 +28,25 @@ export const fileExistsAtPath = async (path: string): Promise => { }); }); }; + +export const isDirectory = async (path: string): Promise => { + const fs = await import('fs'); + return new Promise((resolve) => { + fs.lstat(path, (err, stats) => { + resolve(stats.isDirectory()); + }); + }); +}; + +export const writeFile = async (path: string, data: string) => { + const fs = await import('fs'); + return new Promise((resolve, reject) => { + fs.writeFile(path, data, (err) => { + if (err) { + reject(err); + } else { + resolve(undefined); + } + }); + }); +}; diff --git a/src/utils/http-client.ts b/src/utils/http-client.ts index 3364844..06cfbc2 100644 --- a/src/utils/http-client.ts +++ b/src/utils/http-client.ts @@ -6,6 +6,7 @@ export interface HttpClient { get(url: string, config?: AxiosRequestConfig | undefined): Promise>; patch(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise>; post(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise>; + put(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise>; } class HttpClientImpl implements HttpClient { @@ -14,20 +15,25 @@ class HttpClientImpl implements HttpClient { return axios.delete(urlWithHost, config); } - async get(url: string, config?: AxiosRequestConfig | undefined): Promise> { + get(url: string, config?: AxiosRequestConfig | undefined): Promise> { const urlWithHost = url.startsWith('http') ? url : API_URL + url; return axios.get(urlWithHost, config); } - async patch(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise> { + patch(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise> { const urlWithHost = url.startsWith('http') ? url : API_URL + url; return axios.patch(urlWithHost, data, config); } - async post(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise> { + post(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise> { const urlWithHost = url.startsWith('http') ? url : API_URL + url; return axios.post(urlWithHost, data, config); } + + put(url: string, data?: any, config?: AxiosRequestConfig | undefined): Promise> { + const urlWithHost = url.startsWith('http') ? url : API_URL + url; + return axios.put(urlWithHost, data, config); + } } let httpClient: HttpClient = new HttpClientImpl(); diff --git a/src/utils/manifest.ts b/src/utils/manifest.ts new file mode 100644 index 0000000..ea1241c --- /dev/null +++ b/src/utils/manifest.ts @@ -0,0 +1,36 @@ +import { MANIFEST_JSON_FILE_NAME } from '../config'; +import { createBufferFromPath } from './buffer'; +import { getFilesInDirectoryAndSubdirectories, writeFile } from './file'; +import { createHash } from './hash'; + +export const generateManifestJson = async (path: string) => { + const manifestItems: ManifestItem[] = []; + // Get all files + const files = await getFilesInDirectoryAndSubdirectories(path); + // Iterate over each file + for (const [index, file] of files.entries()) { + const fileBuffer = await createBufferFromPath(file.path); + const checksum = await createHash(fileBuffer); + const sizeInBytes = fileBuffer.byteLength; + manifestItems.push({ + checksum, + href: file.path.replace(path + '/', ''), + sizeInBytes, + }); + } + // Write the manifest file + writeFile(`${path}/${MANIFEST_JSON_FILE_NAME}`, JSON.stringify(manifestItems, null, 2)); +}; + +interface ManifestItem { + checksum: string; + /** + * @example 'assets/icons/favicon.ico' + * @example 'main.38a97264.js' + */ + href: string; + /** + * @example 4826 + */ + sizeInBytes: number; +}