clone

push a directory of html from the terminal and get a four-character url back. a deno cli on cloudflare

feat/site-feedback
nzip / cli / lib / api.ts
api.ts 9.6K
1// Typed fetch client for the Worker API.23import type {4 ApiError,5 AppReservationInfo,6 CommitRequest,7 CommitResponse,8 FeedbackRequestResponse,9 Manifest,10 NotifyApprovalPreview,11 NotifyApprovalRequest,12 NotifyDeviceInfo,13 NotifyPairingWindow,14 NotifyRequest,15 NotifyResponse,16 PrepareResponse,17 SiteDetail,18 SiteFeedback,19 SiteInfo,20 SourceResponse,21 StatusResponse,22 Target,23 Ttl,24 VaultInfo,25 VaultLifecycle,26} from "@nzip/shared";27import { parseAddress, vaultSlotOf } from "@nzip/shared";28import { assertVaultAllowed, type Config } from "./config.ts";2930export class ApiClient {31 constructor(private config: Config, private signal?: AbortSignal) {}3233 private async request<T>(34 method: string,35 path: string,36 body?: BodyInit | Uint8Array,37 ): Promise<T> {38 const fetchBody: BodyInit | undefined = body instanceof Uint8Array39 ? body.buffer instanceof ArrayBuffer40 ? body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength)41 : body.slice().buffer42 : body;43 const res = await fetch(`${this.config.server}${path}`, {44 method,45 signal: this.signal,46 headers: {47 authorization: `Bearer ${this.config.token}`,48 ...(typeof body === "string" ? { "content-type": "application/json" } : {}),49 ...(body instanceof Uint8Array ? { "content-length": String(body.byteLength) } : {}),50 },51 body: fetchBody,52 });53 const text = await res.text();54 if (!res.ok) {55 let message = text;56 try {57 message = (JSON.parse(text) as ApiError).error;58 } catch { /* not json */ }59 throw new Error(`${res.status}: ${message}`);60 }61 return JSON.parse(text) as T;62 }6364 private async requestBytes(65 method: string,66 path: string,67 ): Promise<Uint8Array> {68 const res = await fetch(`${this.config.server}${path}`, {69 method,70 signal: this.signal,71 headers: { authorization: `Bearer ${this.config.token}` },72 });73 if (!res.ok) {74 const text = await res.text();75 let message = text;76 try {77 message = (JSON.parse(text) as ApiError).error;78 } catch { /* not json */ }79 throw new Error(`${res.status}: ${message}`);80 }81 return new Uint8Array(await res.arrayBuffer());82 }8384 status(): Promise<StatusResponse> {85 return this.request("GET", "/api/status");86 }8788 prepare(manifest: Manifest): Promise<PrepareResponse> {89 return this.request(90 "POST",91 "/api/push/prepare",92 JSON.stringify({ manifest }),93 );94 }9596 uploadBlob(hash: string, bytes: Uint8Array): Promise<{ ok: true }> {97 return this.request(98 "PUT",99 `/api/blob/${hash}`,100 bytes,101 );102 }103104 commit(req: CommitRequest): Promise<CommitResponse> {105 return this.request("POST", "/api/push/commit", JSON.stringify(req));106 }107108 listSites(vault?: string): Promise<SiteInfo[]> {109 const q = vault ? `?vault=${encodeURIComponent(vault)}` : "";110 return this.request("GET", `/api/sites${q}`);111 }112113 siteDetail(target: string): Promise<SiteDetail> {114 return this.request("GET", `/api/sites/${encodeURIComponent(target)}`);115 }116117 source(target: string): Promise<SourceResponse> {118 return this.request(119 "GET",120 `/api/sites/${encodeURIComponent(target)}/source`,121 );122 }123124 downloadSourceBlob(target: string, hash: string): Promise<Uint8Array> {125 return this.requestBytes(126 "GET",127 `/api/sites/${encodeURIComponent(target)}/source/${encodeURIComponent(hash)}`,128 );129 }130131 patchSite(132 target: string,133 patch: { ttl?: number | "forever"; password?: string | null; feedback?: boolean },134 ): Promise<SiteDetail> {135 return this.request<SiteDetail>(136 "PATCH",137 `/api/sites/${encodeURIComponent(target)}`,138 JSON.stringify(patch),139 ).then((site) => {140 if (patch.feedback !== undefined && site.feedback !== patch.feedback) {141 throw new Error(142 `the server ignored feedback=${patch.feedback}; upgrade the nzip Worker to 0.12.0 or later`,143 );144 }145 return site;146 });147 }148149 siteFeedback(target: string): Promise<SiteFeedback> {150 return this.request("GET", `/api/sites/${encodeURIComponent(target)}/feedback`);151 }152153 requestFeedback(154 target: string,155 message?: string,156 callbackUrl?: string,157 ): Promise<FeedbackRequestResponse> {158 return this.request<FeedbackRequestResponse>(159 "POST",160 `/api/sites/${encodeURIComponent(target)}/feedback/request`,161 JSON.stringify({162 ...(message === undefined ? {} : { message }),163 ...(callbackUrl === undefined ? {} : { callbackUrl }),164 }),165 ).then((round) => {166 if (callbackUrl !== undefined && round.callbackPending !== true) {167 throw new Error(168 "the server ignored callbackUrl; upgrade the nzip Worker to 0.12.0 or later",169 );170 }171 return round;172 });173 }174175 deleteSite(target: string): Promise<{ ok: true; address: string }> {176 return this.request("DELETE", `/api/sites/${encodeURIComponent(target)}`);177 }178179 revert(180 target: string,181 toSeq?: number,182 ): Promise<CommitResponse & { revertedTo: number }> {183 return this.request(184 "POST",185 `/api/sites/${encodeURIComponent(target)}/revert`,186 JSON.stringify(toSeq === undefined ? {} : { toSeq }),187 );188 }189190 listVaults(): Promise<VaultInfo[]> {191 return this.request("GET", "/api/vaults");192 }193194 createVault(195 name: string,196 slot?: number,197 description?: string,198 defaultTtl?: Ttl | null,199 defaultFor?: VaultLifecycle | null,200 policy?: { maxTtl?: number | null; requirePassword?: boolean; defaultPassword?: string | null },201 ): Promise<VaultInfo> {202 return this.request(203 "POST",204 "/api/vaults",205 JSON.stringify({ name, slot, description, defaultTtl, defaultFor, ...policy }),206 );207 }208209 updateVault(210 currentName: string,211 patch: {212 name?: string;213 description?: string | null;214 defaultTtl?: Ttl | null;215 defaultFor?: VaultLifecycle | null;216 maxTtl?: number | null;217 requirePassword?: boolean;218 defaultPassword?: string | null;219 },220 ): Promise<VaultInfo> {221 return this.request(222 "PATCH",223 `/api/vaults/${encodeURIComponent(currentName)}`,224 JSON.stringify(patch),225 );226 }227228 setDefaultVault(229 lifecycle: VaultLifecycle,230 name: string,231 ): Promise<{ defaultVaults: Record<VaultLifecycle, string | null>; vault: VaultInfo }> {232 return this.request(233 "PUT",234 `/api/default-vaults/${lifecycle}`,235 JSON.stringify({ name }),236 );237 }238239 initApp(vault: string, alias: string): Promise<AppReservationInfo> {240 return this.request(241 "POST",242 "/api/apps",243 JSON.stringify({ target: { vault, alias } }),244 );245 }246247 notify(event: NotifyRequest): Promise<NotifyResponse> {248 return this.request("POST", "/api/notify", JSON.stringify(event));249 }250251 openNotificationPairing(): Promise<NotifyPairingWindow> {252 return this.request("POST", "/api/notify/pairing", JSON.stringify({}));253 }254255 notificationDevices(): Promise<NotifyDeviceInfo[]> {256 return this.request("GET", "/api/notify/devices");257 }258259 notificationApprovalPreview(code: string): Promise<NotifyApprovalPreview> {260 return this.request(261 "GET",262 `/api/notify/approvals/${encodeURIComponent(code)}`,263 );264 }265266 approveNotificationDevice(267 approval: NotifyApprovalRequest,268 ): Promise<NotifyDeviceInfo> {269 return this.request(270 "POST",271 "/api/notify/approvals",272 JSON.stringify(approval),273 );274 }275276 revokeNotificationDevice(deviceId: string): Promise<{ ok: true; deviceId: string }> {277 return this.request(278 "DELETE",279 `/api/notify/devices/${encodeURIComponent(deviceId)}`,280 );281 }282}283284/**285 * Resolve a CLI target string to (API path target, commit body target).286 * A bare alias picks up the default vault from config.287 */288export function resolveCliTarget(289 raw: string,290 config: Config,291 vaults: Pick<VaultInfo, "slot" | "name">[] = [],292): string {293 if (/^[0-9a-f]{4}$/.test(raw)) {294 if (config.allowVaults) {295 const slot = vaultSlotOf(parseAddress(raw));296 const vault = vaults.find((candidate) => candidate.slot === slot);297 if (!vault) {298 throw new Error(299 `cannot validate address ${raw}: authenticated status did not identify vault slot 0x${300 slot.toString(16)301 }`,302 );303 }304 assertVaultAllowed(vault.name, config);305 }306 return raw;307 }308 if (raw.includes(":")) {309 const [vault] = raw.split(":");310 assertVaultAllowed(vault, config);311 return raw;312 }313 const vault = config.defaultVault;314 if (!vault) {315 throw new Error(316 `"${raw}" is a bare alias but no defaultVault is set — use vault:alias or set defaultVault in config`,317 );318 }319 assertVaultAllowed(vault, config);320 return `${vault}:${raw}`;321}322323export function commitTargetFor(324 raw: string | undefined,325 config: Config,326 vaults: Pick<VaultInfo, "slot" | "name">[] = [],327): Target {328 if (raw === undefined) {329 const vault = config.defaultVault;330 if (!vault) throw new Error("no target given and no defaultVault set");331 assertVaultAllowed(vault, config);332 return { vault };333 }334 if (/^[0-9a-f]{4}$/.test(raw)) {335 resolveCliTarget(raw, config, vaults);336 return { address: parseInt(raw, 16) };337 }338 const resolved = resolveCliTarget(raw, config, vaults);339 const [vault, alias] = resolved.split(":");340 return { vault, alias };341}342343/** Resolve a target, fetching vault-slot metadata only when a raw address needs guarding. */344export async function resolveCliTargetWithStatus(345 raw: string,346 config: Config,347 api: Pick<ApiClient, "status">,348): Promise<string> {349 const vaults = /^[0-9a-f]{4}$/.test(raw) && config.allowVaults ? (await api.status()).vaults : [];350 return resolveCliTarget(raw, config, vaults);351}