ente/web/packages/gallery/utils/native-stream.ts
Manav Rathi 142a1bd684
Move
2024-11-21 09:40:58 +05:30

195 lines
6.6 KiB
TypeScript

/**
* @file Streaming IPC communication with the Node.js layer of our desktop app.
*
* NOTE: These functions only work when we're running in our desktop app.
*
* See: [Note: IPC streams].
*/
import type { Electron, ElectronMLWorker, ZipItem } from "@/base/types/ipc";
/**
* Stream the given file or zip entry from the user's local file system.
*
* This only works when we're running in our desktop app since it uses the
* "stream://" protocol handler exposed by our custom code in the Node.js layer.
* See: [Note: IPC streams].
*
* To avoid accidentally invoking it in a non-desktop app context, it requires
* the {@link Electron} (or a functionally similar) object as a parameter (even
* though it doesn't need or use it).
*
* @param pathOrZipItem Either the path on the file on the user's local file
* system whose contents we want to stream. Or a tuple containing the path to a
* zip file and the name of the entry within it.
*
* @return A ({@link Response}, size, lastModifiedMs) triple.
*
* * The response contains the contents of the file. In particular, the `body`
* {@link ReadableStream} property of this response can be used to read the
* files contents in a streaming manner.
*
* * The size is the size of the file that we'll be reading from disk.
*
* * The lastModifiedMs value is the last modified time of the file that we're
* reading, expressed as epoch milliseconds.
*/
export const readStream = async (
_: Electron | ElectronMLWorker,
pathOrZipItem: string | ZipItem,
): Promise<{ response: Response; size: number; lastModifiedMs: number }> => {
let url: URL;
if (typeof pathOrZipItem == "string") {
const params = new URLSearchParams({ path: pathOrZipItem });
url = new URL(`stream://read?${params.toString()}`);
} else {
const [zipPath, entryName] = pathOrZipItem;
const params = new URLSearchParams({ zipPath, entryName });
url = new URL(`stream://read-zip?${params.toString()}`);
}
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to read stream from ${url.href}: HTTP ${res.status}`,
);
const size = readNumericHeader(res, "Content-Length");
const lastModifiedMs = readNumericHeader(res, "X-Last-Modified-Ms");
return { response: res, size, lastModifiedMs };
};
const readNumericHeader = (res: Response, key: string) => {
const valueText = res.headers.get(key);
const value = valueText === null ? NaN : +valueText;
if (isNaN(value))
throw new Error(
`Expected a numeric ${key} when reading a stream response, instead got ${valueText}`,
);
return value;
};
/**
* Write the given stream to a file on the local machine.
*
* This only works when we're running in our desktop app since it uses the
* "stream://" protocol handler exposed by our custom code in the Node.js layer.
* See: [Note: IPC streams].
*
* To avoid accidentally invoking it in a non-desktop app context, it requires
* the {@link Electron} object as a parameter (even though it doesn't use it).
*
* @param path The path on the local machine where to write the file to.
*
* @param stream The stream which should be written into the file.
*/
export const writeStream = async (
_: Electron,
path: string,
stream: ReadableStream,
) => {
const params = new URLSearchParams({ path });
const url = new URL(`stream://write?${params.toString()}`);
// The duplex parameter needs to be set to 'half' when streaming requests.
//
// Currently browsers, and specifically in our case, since this code runs
// only within our desktop (Electron) app, Chromium, don't support 'full'
// duplex mode (i.e. streaming both the request and the response).
// https://developer.chrome.com/docs/capabilities/web-apis/fetch-streaming-requests
const req = new Request(url, {
// GET can't have a body
method: "POST",
body: stream,
// @ts-expect-error TypeScript's libdom.d.ts does not include the
// "duplex" parameter, e.g. see
// https://github.com/node-fetch/node-fetch/issues/1769.
duplex: "half",
});
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to write stream to ${url.href}: HTTP ${res.status}`,
);
};
/**
* Variant of {@link writeStream} tailored for video conversion.
*
* @param blob The video to convert.
*
* @returns a token that can then be passed to {@link readConvertToMP4Stream} to
* read back the converted video. See: [Note: Convert to MP4].
*/
export const writeConvertToMP4Stream = async (_: Electron, blob: Blob) => {
const url = "stream://convert-to-mp4";
const req = new Request(url, {
method: "POST",
body: blob,
// @ts-expect-error TypeScript's libdom.d.ts does not include the
// "duplex" parameter, e.g. see
// https://github.com/node-fetch/node-fetch/issues/1769.
duplex: "half",
});
const res = await fetch(req);
if (!res.ok)
throw new Error(`Failed to write stream to ${url}: HTTP ${res.status}`);
const token = res.text();
return token;
};
/**
* Variant of {@link readStream} tailored for video conversion.
*
* @param token A token obtained from {@link writeConvertToMP4Stream}.
*
* @returns the contents of the converted video. See: [Note: Convert to MP4].
*/
export const readConvertToMP4Stream = async (
_: Electron,
token: string,
): Promise<Blob> => {
const params = new URLSearchParams({ token });
const url = new URL(`stream://convert-to-mp4?${params.toString()}`);
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to read stream from ${url.href}: HTTP ${res.status}`,
);
return res.blob();
};
/**
* Sibling of {@link readConvertToMP4Stream} to let the native side know when we
* are done reading the response, and they can dispose any temporary resources
* it was using.
*
* @param token A token obtained from {@link writeConvertToMP4Stream}.
*/
export const readConvertToMP4Done = async (
_: Electron,
token: string,
): Promise<void> => {
// The value for `done` is arbitrary, only its presence matters.
const params = new URLSearchParams({ token, done: "1" });
const url = new URL(`stream://convert-to-mp4?${params.toString()}`);
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to close stream at ${url.href}: HTTP ${res.status}`,
);
};