diff --git a/src/lib/download-request.ts b/src/lib/download-request.ts index d3e7a02..6bdb112 100644 --- a/src/lib/download-request.ts +++ b/src/lib/download-request.ts @@ -100,14 +100,14 @@ export class DownloadRequest extends TypedEventTarget< mapShareUrls, stream, }: { - mapShareUrls: string[] + mapShareUrls: readonly [string, ...string[]] stream: WritableStream remotePublicKey: Uint8Array keyPair: { publicKey: Uint8Array; secretKey: Uint8Array } }) { const downloadUrls = mapShareUrls.map( (baseUrl) => new URL('download', addTrailingSlash(baseUrl)), - ) + ) as unknown as [URL, ...URL[]] // grrrr TS const response = await secretStreamFetch(downloadUrls, { dispatcher: this.#dispatcher, }) diff --git a/src/lib/map-share.ts b/src/lib/map-share.ts index 77709e4..a795dc9 100644 --- a/src/lib/map-share.ts +++ b/src/lib/map-share.ts @@ -15,7 +15,7 @@ export type MapShareOptions = MapInfo & { * are supported because the server might have multiple network interfaces * with different IP addresses */ - baseUrls: string[] + baseUrls: readonly [string, ...string[]] /** The device ID of the receiver */ receiverDeviceId: string } @@ -36,7 +36,7 @@ export class MapShare extends TypedEventTarget< shareId, mapShareUrls: baseUrls.map( (baseUrl) => new URL(`${shareId}`, addTrailingSlash(baseUrl)).href, - ), + ) as unknown as readonly [string, ...string[]], // grrrr TS receiverDeviceId, mapShareCreatedAt: Date.now(), status: 'pending', diff --git a/src/lib/secret-stream-fetch.ts b/src/lib/secret-stream-fetch.ts index d65fc9b..3c8c775 100644 --- a/src/lib/secret-stream-fetch.ts +++ b/src/lib/secret-stream-fetch.ts @@ -1,16 +1,17 @@ import { fetch as secretStreamFetchOrig } from 'secret-stream-http' import { errors } from './errors.js' +import { isArrayReadonly } from './utils.js' /** * A wrapper around secret-stream-http's fetch that tries multiple URLs until one works. * This is useful when the server has multiple IPs for different network interfaces. */ export async function secretStreamFetch( - urls: string | URL | Array, + urls: string | URL | readonly [string | URL, ...Array], options: Parameters[1], ) { - if (!Array.isArray(urls)) { + if (!isArrayReadonly(urls)) { urls = [urls] } let response: Response | undefined diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 5e33603..070dc71 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -108,3 +108,11 @@ function isNonEmptyArray(arr: T[]): arr is [T, ...T[]] { export function addTrailingSlash(url: string): string { return url.endsWith('/') ? url : url + '/' } + +// Typescript's Array.isArray definition does not work as a type guard for +// readonly arrays, so we need to define our own type guard for that +export function isArrayReadonly( + value: U | readonly T[], +): value is readonly T[] { + return Array.isArray(value) +} diff --git a/src/routes/map-shares.ts b/src/routes/map-shares.ts index a0e592f..a0f81cb 100644 --- a/src/routes/map-shares.ts +++ b/src/routes/map-shares.ts @@ -227,7 +227,10 @@ export function MapSharesRouter( /** * Get the base URLs for downloads for all non-internal IPv4 addresses of the machine */ -function getRemoteBaseUrls(requestUrl: string, remotePort: number): string[] { +function getRemoteBaseUrls( + requestUrl: string, + remotePort: number, +): readonly [string, ...string[]] { requestUrl = addTrailingSlash(requestUrl) const interfaces = os.networkInterfaces() const baseUrls: string[] = [] @@ -242,5 +245,12 @@ function getRemoteBaseUrls(requestUrl: string, remotePort: number): string[] { } } } + if (!arrayAtLeastOne(baseUrls)) { + throw new Error('No non-internal IPv4 addresses found') + } return baseUrls } + +function arrayAtLeastOne(arr: readonly T[]): arr is readonly [T, ...T[]] { + return arr.length >= 1 +} diff --git a/src/types.ts b/src/types.ts index 5be0a1a..a91f00e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -73,11 +73,13 @@ export type DownloadStateUpdate = Extract< { status: 'downloading' | 'completed' | 'error' | 'canceled' | 'aborted' } > -export const MapShareUrls = T.Array(T.String({ format: 'uri' }), { - minItems: 1, - description: - 'List of map share URLs (for each network interface of the sharer)', -}) +export const MapShareUrls = T.Unsafe( + T.Array(T.String({ format: 'uri' }), { + minItems: 1, + description: + 'List of map share URLs (for each network interface of the sharer)', + }), +) export const ShareId = T.String({ minLength: 1, description: 'The ID of the map share',