async-neocities
Version:
A library and bin to deploy to neocities
404 lines (349 loc) • 10.9 kB
JavaScript
/**
* @import { Dispatcher } from 'undici'
* @import { MessageResult, SiteFileList, SiteInfo, ApiKeyResponse, FileUpload } from './neocities.js'
* @import { AsyncNeocitiesDiff } from './folder-diff.js'
*/
import afw from 'async-folder-walker'
import os from 'node:os'
import { request } from 'undici'
import { pkg } from '../pkg.cjs'
import { alwaysIgnore } from './always-ignore.js'
import { createFileForm, createForm } from './create-form.js'
import { chunk } from './chunk.js'
import { neocitiesLocalDiff } from './folder-diff.js'
const NEOCITIES_URL = 'https://neocities.org'
/**
* Get an API for a neocities website.
*
* @param {object} params
* @param {string} params.siteName The name of the website on neocities.
* @param {string[]} params.ownerPassword The user password to the owning user of the siteName.
* @return {Promise<ApiKeyResponse>}
*/
export async function getApiKey ({
siteName,
ownerPassword
}) {
const requestUrl = new URL('/api/key', NEOCITIES_URL)
const response = await request(requestUrl, {
headers: {
...defaultHeaders(),
Authorization: `Basic ${btoa(siteName + ':' + ownerPassword)}`
}
})
await handleBadResponse(response)
/** @type {any} */
const rawResponse = await response.body.json()
const responseBody = /** @type {ApiKeyResponse} */ (rawResponse)
return responseBody
}
/**
* @typedef {{
* type: 'uploadResult'
* body: MessageResult,
* files: FileUpload[]
* }} UploadResult
*/
/**
* @typedef {{
* errors: Array<Error>,
* results: Array<UploadResult>}} UploadResults
*/
/**
* Upload files to a neocities website.
*
* @param {object} params
* @param {string} params.apiKey The api token tied to the site you want to delete files from.
* @param {FileUpload[]} params.files Array of file details. Name is the unix path desired on neocities. Path is the local path to the file on disk.
* @return {Promise<UploadResults>
* }>}
*/
export async function uploadSiteFiles ({
apiKey,
files
}) {
/** @type {Array<UploadResult>} */
const results = []
/** @type {Array<Error>} Array of errors encountered when uploading files */
const errors = []
const fileBatches = chunk(files, 20)
const requestUrl = new URL('/api/upload', NEOCITIES_URL)
for (const fileBatch of fileBatches) {
try {
const form = createFileForm(fileBatch)
const response = await request(requestUrl, {
method: 'POST',
body: form,
headers: {
...defaultAuthHeaders({ apiKey }),
...form.getHeaders()
},
})
await handleBadResponse(response, { fileBatch })
const rawResponse = await response.body.json()
const responseBody = /** @type {MessageResult} */ (rawResponse)
results.push({
type: 'uploadResult',
body: responseBody,
files: fileBatch
})
} catch (err) {
errors.push(new Error('async-neocities upload error', { cause: err }))
}
}
return {
results,
errors,
}
}
/**
* Preview a directory deploy to neocities. Performs the content diff without modifying anything.
* @param {object} options
* @param {string} options.directory The path to the directory preview deploy.
* @param {string} options.apiKey The API token of the neocities website to deploy to
* @param {boolean} [options.includeUnsupportedFiles=false] Set to true to bypass file type restrictions.
* @param {(path: string) => boolean} [options.protectedFileFilter] A filter function to filter out file you want to ignore.
* @param {Comparator<FileUpload>} [options.uploadSort] A sort function that lets you sort file upload order prior to uploading.
* @return {Promise<AsyncNeocitiesDiff>}
*/
export async function previewDeployToNeocities ({
directory,
apiKey,
includeUnsupportedFiles = false,
protectedFileFilter,
uploadSort
}) {
const [localListing, neocitiesFiles] = await Promise.all([
afw.allFiles(directory, {
shaper: f => f,
ignore: [
...alwaysIgnore
]
}),
listFilesForSite({ apiKey }).then(res => res.files)
])
const diff = await neocitiesLocalDiff({
neocitiesFiles,
localListing,
protectedFileFilter,
includeUnsupportedFiles
})
if (typeof uploadSort === 'function') {
diff.filesToUpload.sort(uploadSort)
}
return diff
}
/**
* @typedef {UploadResult | DeleteResult} DeployResult
*/
/**
* @typedef {{
* results: DeployResult[]
* errors: Error[],
* diff: AsyncNeocitiesDiff
* }} DeployResults
*/
/**
* A generic comparator function type.
*
* @template T
* @typedef {(a: T, b: T) => number} Comparator
*/
/**
* Deploy a directory to neocities
* @param {object} options
* @param {string} options.directory The path to the directory deploy.
* @param {string} options.apiKey The API token of the neocities website to deploy to
* @param {boolean} [options.cleanup=false] Set cleanup to true to delete orphaned file.
* @param {boolean} [options.includeUnsupportedFiles=false] Set to true to bypass file type restrictions.
* @param {(path: string) => boolean} [options.protectedFileFilter] A filter function to filter out file you want to ignore.
* @param {Comparator<FileUpload>} [options.uploadSort] A sort function that lets you sort file upload order prior to uploading.
* @return {Promise<DeployResults>}
*/
export async function deployToNeocities ({
directory,
apiKey,
cleanup = false,
includeUnsupportedFiles = false,
protectedFileFilter,
uploadSort
}) {
const diff = await previewDeployToNeocities({
directory,
apiKey,
includeUnsupportedFiles,
protectedFileFilter,
uploadSort
})
const { filesToUpload, filesToDelete } = diff
if (filesToUpload.length === 0 && (!cleanup || filesToDelete.length === 0)) {
return {
errors: [],
results: [],
diff
}
}
/** @type {DeployResult[]} */
const results = []
/** @type {Array<Error>} */
const errors = []
const uploadResults = await uploadSiteFiles({
apiKey,
files: filesToUpload
})
results.push(...uploadResults.results)
errors.push(...uploadResults.errors)
if (cleanup && filesToDelete.length > 0) {
try {
const cleanupResult = await deleteSiteFiles({
apiKey,
fileNames: filesToDelete
})
results.push(cleanupResult)
} catch (err) {
errors.push(new Error('async-neocities deploy cleanup error', { cause: err }))
}
}
return {
results,
errors,
diff,
}
}
/**
* @typedef {{
* type: 'deleteResult'
* body: MessageResult,
* fileNames: string[]
* }} DeleteResult
*/
/**
* Delete files from a neocities website.
*
* @param {object} params
* @param {string} params.apiKey The api token tied to the site you want to delete files from.
* @param {string[]} params.fileNames Array of file paths on the neocities website to delete.
* @return {Promise<DeleteResult>}
*/
export async function deleteSiteFiles ({
apiKey,
fileNames
}) {
const formEntries = fileNames.map(file => ({
name: 'filenames[]',
value: file
}))
const form = createForm(formEntries)
const requestUrl = new URL('/api/delete', NEOCITIES_URL)
const response = await request(requestUrl, {
method: 'POST',
headers: {
...defaultAuthHeaders({ apiKey }),
...form.getHeaders()
},
body: form
})
await handleBadResponse(response, { fileNames })
/** @type {any} */
const rawResponse = await response.body.json()
const responseBody = /** @type {MessageResult} */ (rawResponse)
return {
type: 'deleteResult',
body: responseBody,
fileNames
}
}
/**
* Return info about a neocities site.
* @param {object} options
* @param {string} options.apiKey The API token to request with
* @param {string} [options.path] Only list files for a given path
* @return {Promise<SiteFileList>} The fetched site info
*/
export async function listFilesForSite ({
apiKey,
path
}) {
const requestUrl = new URL('/api/list', NEOCITIES_URL)
if (path) requestUrl.searchParams.set('path', path)
const response = await request(requestUrl, {
method: 'GET',
headers: defaultAuthHeaders({ apiKey })
})
await handleBadResponse(response, { path })
/** @type {any} */
const rawResponse = await response.body.json()
const responseBody = /** @type {SiteFileList} */ (rawResponse)
return responseBody
}
/**
* Return info about a neocities site.
* @param {object} options
* @param {string} options.apiKey The API token to request with
* @param {string} [options.siteName] Retrieve info on a specific website. Returns info on the token context when omitted.
* @return {Promise<SiteInfo>} The fetched site info
*/
export async function getInfo ({
apiKey,
siteName
}) {
const requestUrl = new URL('/api/info', NEOCITIES_URL)
if (siteName) requestUrl.searchParams.set('sitename', siteName)
const response = await request(requestUrl, {
method: 'GET',
headers: defaultAuthHeaders({ apiKey })
})
await handleBadResponse(response, { siteName })
/** @type {any} */
const rawResponse = await response.body.json()
const responseBody = /** @type {SiteInfo} */ (rawResponse)
return responseBody
}
function defaultHeaders () {
return {
Accept: 'application/json',
'User-Agent': `async-neocities/${pkg.version} (${os.type()})`
}
}
/**
* @param {object} params
* @param {string} params.apiKey
*/
function defaultAuthHeaders ({ apiKey }) {
return {
...defaultHeaders(),
Authorization: `Bearer ${apiKey}`,
}
}
/**
* @param {Dispatcher.ResponseData} response
* @param {any} [extra]
*/
async function handleBadResponse (response, extra) {
if (response.statusCode > 299) {
const contentTypeHeaders = response.headers['Content-Type']
const contentType = Array.isArray(contentTypeHeaders) ? contentTypeHeaders[0] : contentTypeHeaders
const isJSON = contentType && contentType.match(/json/)
/** @type { any } */
const body = isJSON ? await response.body.json() : await response.body.text()
throw new AsyncNeocitiesHTTPError(response, body, extra)
}
}
class AsyncNeocitiesHTTPError extends Error {
/** @type { number } */ statusCode
/** @type {string | object } */ body
/** @type {any} */ extra
/**
* @param {Dispatcher.ResponseData} response A undici Response
* @param {string | object} body response body
* @param {any} [extra] any extra info to attach to the error
*/
constructor (response, body, extra) {
super('Unexpected response status code')
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
this.statusCode = response.statusCode
this.body = body
this.extra = extra
}
}