Advanced JS: APIs
Abort Controller
The AbortController class in javascript is used to produce cancellation signals and control them, where aborting the signal aborts the operation attached to the signal. They have many use cases, from cancelling any async operation to removing event listeners.
Aborting fetch requests
We can use the AbortController class to abort fetch requests if they are taking too long.
The main steps are these:
- Instantiate an abort controller with
new AbortController() - Connect the abort controller to our fetch call by attaching the
signalproperty of the abort controller to thesignalproperty of the fetch options object. - Call
abort()on the abort controller after a certain amount of time. The previous connection through thesignalproperty will cause the fetch request to abort.
async function fetchWithTimeout(
url: string,
// RequestInit is the interface for the fetch options object
options: RequestInit = {},
timeout = -1
) {
// user has specified they want a timeout for fetch
if (timeout > 0) {
let controller = new AbortController();
// connect controller to our fetch request through the options object and on options.signal
options.signal = controller.signal;
setTimeout(() => {
// this aborts the controller and any connected fetch requests
controller.abort();
}, timeout);
}
// need to pass options into fetch so that we get signal connection to abort controller
return fetch(url, options);
}
// fetches google with a timeout of 1 second, aborting the request if it takes any longer
fetchWithTimeout("https://google.com", {}, 1000);
Here is a simpler example:
let abortController = new AbortController();
fetch('wikipedia.zip', { signal: abortController.signal })
.catch(() => console.log('aborted!'));
// Abort the fetch after 10ms
setTimeout(() => abortController.abort(), 10);
AbortSignal.any()
The AbortSignal.any(signals) method takes in an array of AbortSignal[] and returns a new signal that if any of the provided signals in the array get aborted, the enw signal also gets aborted.
The main use case for this is if you want to automatically abort something conditionally based on the abortion of another signal.
const { signal: firstSignal } = new AbortController();
fetch("https://example.com/", { signal: firstSignal });
const { signal: secondSignal } = new AbortController();
fetch("https://example.com/", { signal: secondSignal });
// Cancels if either `firstSignal` or `secondSignal` is aborted
const signal = AbortSignal.any([firstSignal, secondSignal]);
await fetch("https://example.com/slow", { signal });
Notification
creating notifications
The Notification class allows to us to create notifications and check the permissions. As soon as you instantiate an instance, a notification is created. We can create a notification like this:
new Notification("title", options);
optionsis an object with the following properties:
body: The body of the notification, String.icon: The URL of the icon to display, String.data: An object you set as a pyload of data to pass along with the notification
if (Notification.permission === "granted") {
// permission was already granted to display notification
}
async function displayNotification() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
new Notification("title", {
body: "body text",
});
}
}
displayNotification();
permissions
Notification.requestPermission(): Requests the user to turn on notifications. This returns a promise that resolves to the permission of the notification. This can be one of the following values:"granted": The user has granted permission."denied": The user has denied permission."default": The user has not yet made a decision.
Notification.permission: This is the permission of the notification. This can be one of the following values:"granted": The user has granted permission."denied": The user has denied permission."default": The user has not yet made a decision.
Notification object
We get back a notification object from the Notification class that lets us control behavior on that nofitication instance.
const notification = new Notification()
For example, we get access to these methods:
notification.close(): closes the notification.
We can listen for events that happen to the notification using the addEventListener() syntax:
notification.addEventListener("click", () => {
console.log("clicked");
});
notification.addEventListener("closed", () => {
console.log("closed");
});
Here are the events we can listen for:
click: This is fired when the user clicks on the notification.close: This is fired when the user closes the notification.show: This is fired when the user is shown the notification.error: This is fired if an error occurs.
Custom class
class WebNotifications {
constructor(public notification: Notification) {}
static async displayBasicNotificationAsync(title: string, body: string) {
if (!WebNotifications.permissionIsGranted) {
await WebNotifications.requestPermission();
}
// user denied permission or blocked.
if (!WebNotifications.permissionIsGranted) {
return null;
}
const notification = new Notification(title, {
body,
});
return new WebNotifications(notification);
}
static displayBasicNotification(title: string, body: string) {
// user denied permission or blocked.
if (!WebNotifications.permissionIsGranted) {
return null;
}
const notification = new Notification(title, {
body,
});
return new WebNotifications(notification);
}
static async displayNotificationAsync(cb: () => Notification) {
if (!WebNotifications.permissionIsGranted) {
await WebNotifications.requestPermission();
}
// user denied permission or blocked.
if (!WebNotifications.permissionIsGranted) {
return null;
}
const notification = cb();
return new WebNotifications(notification);
}
static displayNotification(cb: () => Notification) {
// user denied permission or blocked.
if (!WebNotifications.permissionIsGranted) {
return null;
}
const notification = cb();
return new WebNotifications(notification);
}
static async requestPermission() {
return Notification.requestPermission();
}
static getPermission() {
return Notification.permission;
}
static get permissionIsGranted() {
return Notification.permission === "granted";
}
close() {
this.notification.close();
}
onClick(cb: () => void) {
this.notification.onclick = cb;
}
onClose(cb: () => void) {
this.notification.onclose = cb;
}
onShow(cb: () => void) {
this.notification.onshow = cb;
}
onError(cb: () => void) {
this.notification.onerror = cb;
}
}
Performance
Basics
The performance object is built into the browser and provides a way to measure the performance of our code. We can use it to measure the time it takes for our code to run.
It has this concept of entries, where it automatically adds things loading into the DOM into its entries array and measures its performance, but we can also add our own entries too.
Setting up Marks
We can add our own entries by using the performance.mark(markName) method. This takes a string as an argument, which is the name of the mark. Then we would run some code, and then create another mark.
performance.mark("bubble sort start");
// run bubble sort
performance.mark("bubble sort end");
Then we can use the performance.measure() method to measure the time elapsed between the two marks. This takes three arguments:
performance.measure(measureName, startMark, endMark);
measureName: The name of the measure, String. This becomes an entry in the performance's entries.startMark: The name of the mark to start measuring from, String.endMark: The name of the mark to end measuring at, String.
To find the duration of the measure, we can use the performance.getEntriesByName() method, which takes the name of the entry as an argument and returns an array of entries. We can then get the duration of the measure by accessing the duration property of the first entry in the array.
const duration = performance.getEntriesByName(measureName)[0].duration;
The performance.getEntriesByName(measureName) method returns a PerformanceMeasure[] type, where each object has the following properties:
name: The name of the measure, String.entryType: The type of entry, String. This will always be"measure"is we set it ourselves, or"resource"if automatically loaded from DOM.startTime: The time the measure started, Number. This is measured in milliseconds since the page started loading.duration: The duration of the measure, Number. This is measured in milliseconds.
Entries
You can see all the entries on the performance object by using the performance.getEntries() method, which returns an array of all the entries.
There are other ways to get entries, as shown by these methods:
performance.getEntriesByType(type): This returns an array of entries of a certain type. The type can be one of the following values:"mark": Returns all the marks."measure": Returns all the measures."resource": Returns all the resources loaded into the DOM.
performance.getEntriesByName(name): This returns an array of entries with a certain name.
DOM performance
To see the loading time of all our images, we first get entries with the type "resource", and then look to the initiator type for "img":
// must wait for all images to load
window.addEventListener("load", () => {
performance
.getEntriesByType("resource")
.filter((entry) => entry.initiatorType === "img")
.forEach(({ name, duration }) => {
console.log(`The image at this URL: ${name} took ${duration}ms to load`);
});
});
Web Audio
Basics
We can create sounds with the web audio API by first creating something called an AudioContext. This is the main object that we will use to create and manipulate sounds. We can create an AudioContext like this:
const audioContext = new AudioContext();
Then the actual things that play our sounds are called oscillators, which are simple one-time use sound generators. We can create an oscillator like this:
const oscillator = audioContext.createOscillator();
We can then configure the oscillator to play sounds by configuring some of its values, which are determined by these properties:
oscillator.type: The type of sound to play. This can be one of the following values:sine: A sine wavesquare: A square wavesawtooth: A sawtooth wavetriangle: A triangle wave
oscillator.frequency.value: The frequency of the sound to play, Number. This is measured in hertz (Hz), which is the number of cycles per second. The human ear can hear sounds between 20 Hz and 20,000 Hz.
We can then connect the oscillator to the AudioContext's destination, which is the thing that actually plays the sound. We can do this like this:
oscillator.connect(audioContext.destination);
oscillator.start();
oscillator.start(): This starts the oscillator playing. It will play until we calloscillator.stop().oscillator.stop(): This stops the oscillator playing.
// 1. Create an AudioContext
const audioContext = new AudioContext();
// 2. Create an Oscillator
let oscillator = audioContext.createOscillator();
// 3. Configure the Oscillator
oscillator.type = "sawtooth";
oscillator.frequency.value = 440;
// 4. Connect the Oscillator to the AudioContext's destination
oscillator.connect(audioContext.destination);
// 5. Start the Oscillator
oscillator.start();
setTimeout(() => {
oscillator.stop();
}, 1000);
Audio Visualization Project
class AudioContextManager {
public audioContext: AudioContext;
private analyzerNode?: AnalyserNode;
constructor() {
this.audioContext = new AudioContext();
}
static async getStream(options?: { clean: boolean }) {
const stream = await navigator.mediaDevices.getUserMedia({
audio: options?.clean
? true
: {
autoGainControl: false,
noiseSuppression: false,
echoCancellation: false,
sampleRate: 44100,
},
});
return stream;
}
async initAudioContext(options?: { clean: boolean }) {
const stream = await AudioContextManager.getStream(options);
if (this.audioContext.state === "suspended") {
await this.audioContext.resume();
}
const source = this.audioContext.createMediaStreamSource(stream);
this.analyzerNode = new AnalyserNode(this.audioContext, {
fftSize: 256,
});
source.connect(this.audioContext.destination);
source.connect(this.analyzerNode);
}
drawVisualizer(canvas: HTMLCanvasElement) {
const canvasCtx = canvas.getContext("2d");
if (!canvasCtx) {
throw new Error("Canvas context not found");
}
const draw = () => {
if (this.analyzerNode) {
const bufferLength = this.analyzerNode.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
this.analyzerNode.getByteFrequencyData(dataArray);
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
dataArray.forEach((data) => {
const barHeight = data / 2;
canvasCtx.fillStyle = `rgb(${barHeight + 100},50,50)`;
canvasCtx.fillRect(
x,
canvas.height - barHeight / 2,
barWidth,
barHeight
);
x += barWidth + 1;
});
}
requestAnimationFrame(draw);
};
draw();
}
}
function createCanvas() {
const canvas = document.createElement("canvas");
canvas.width = window.innerWidth;
canvas.height = window.innerHeight * 0.75;
document.body.appendChild(canvas);
return canvas;
}
const canvas = createCanvas();
const audioContextManager = new AudioContextManager();
const btn = document.querySelector("button");
btn?.addEventListener("click", async () => {
await audioContextManager.initAudioContext();
audioContextManager.drawVisualizer(canvas);
});
window.addEventListener("resize", () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight * 0.75;
});
Other APIs
Eyedropper API
The Eyedropper API in chrome is experimental and only works on chrome 95 and above. It allows the user to pick any color from a webscreen.
Basic Use
// 1. create an abort controller
const abortController = new AbortController();
// 2. override default. On ESC press, stop color picker
window.addEventListener("keydown", (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.abortController.abort();
}
})
if (window.EyeDropper) {
// 3. create eyedropper instance
const eyeDropper = new window.EyeDropper();
// 4. get selected hex color code
const {sRGBHex} = await eyeDropper.open({
signal: this.abortController.signal,
});
}
Keep in mind that the async call can fail and throw an error for two reasons:
- Popup didn't close fast enough, so it gets mistaken for a user selection cancel. Solution is to close the popup window and use the abort controller.
- Eyedropper must be triggered by a user gesture, so you can only display it after a user press.
Class
You need to provide your own type definitions for the API since type support is limited.
interface ColorSelectionOptions {
signal?: AbortSignal;
}
interface ColorSelectionResult {
sRGBHex: string;
}
interface EyeDropper {
open: (options?: ColorSelectionOptions) => Promise<ColorSelectionResult>;
}
interface EyeDropperConstructor {
new (): EyeDropper;
}
interface Window {
EyeDropper?: EyeDropperConstructor | undefined;
}
export default class EyedropperManager {
private abortController = new AbortController();
private cb!: (event: KeyboardEvent) => void;
async getColor() {
this.cb = (event: KeyboardEvent) => {
if (event.key === "Escape") {
this.abortController.abort();
}
};
if (window.EyeDropper) {
const eyeDropper = new window.EyeDropper();
window.addEventListener("keydown", this.cb);
try {
const result = await eyeDropper.open({
signal: this.abortController.signal,
});
window.removeEventListener("keydown", this.cb);
return result.sRGBHex;
} catch (e) {
window.removeEventListener("keydown", this.cb);
console.warn("eyedropper error", e);
return null;
}
} else {
return null;
}
}
static hasAPI() {
return Boolean(window.EyeDropper);
}
}
FileSystem API
The new filesystem API allows you to open and directly read from and write to the user's file system.
Here are the typescript types required:
// Basic types
type FileSystemPermissionMode = "read" | "readwrite";
type FileSystemHandleKind = "file" | "directory";
interface FileSystemHandlePermissionDescriptor {
mode?: FileSystemPermissionMode;
}
// FileSystemHandle (shared between file and directory)
interface FileSystemHandle {
readonly kind: FileSystemHandleKind;
readonly name: string;
isSameEntry(other: FileSystemHandle): Promise<boolean>;
queryPermission(
descriptor?: FileSystemPermissionDescriptor
): Promise<PermissionState>;
requestPermission(
descriptor?: FileSystemPermissionDescriptor
): Promise<PermissionState>;
}
interface FileSystemPermissionDescriptor {
mode?: "read" | "readwrite";
}
// FileSystemFileHandle
interface FileSystemFileHandle extends FileSystemHandle {
readonly kind: "file";
getFile(): Promise<File>;
createWritable(
options?: FileSystemCreateWritableOptions
): Promise<FileSystemWritableFileStream>;
}
// FileSystemDirectoryHandle
interface FileSystemDirectoryHandle extends FileSystemHandle {
readonly kind: "directory";
getFileHandle(
name: string,
options?: GetFileHandleOptions
): Promise<FileSystemFileHandle>;
getDirectoryHandle(
name: string,
options?: GetDirectoryHandleOptions
): Promise<FileSystemDirectoryHandle>;
removeEntry(name: string, options?: RemoveEntryOptions): Promise<void>;
resolve(possibleDescendant: FileSystemHandle): Promise<string[] | null>;
entries(): AsyncIterableIterator<[string, FileSystemHandle]>;
keys(): AsyncIterableIterator<string>;
values(): AsyncIterableIterator<FileSystemHandle>;
[Symbol.asyncIterator](): AsyncIterableIterator<[string, FileSystemHandle]>;
}
// Writable stream for saving files
interface FileSystemWritableFileStream extends WritableStream {
write(data: BufferSource | Blob | string | WriteParams): Promise<void>;
seek(position: number): Promise<void>;
truncate(size: number): Promise<void>;
close(): Promise<void>;
}
interface WriteParams {
type: "write";
position?: number;
data: BufferSource | Blob | string;
}
// Options
interface FileSystemCreateWritableOptions {
keepExistingData?: boolean;
}
interface GetFileHandleOptions {
create?: boolean;
}
interface GetDirectoryHandleOptions {
create?: boolean;
}
interface RemoveEntryOptions {
recursive?: boolean;
}
// File picker options
interface FilePickerAcceptType {
description?: string;
accept: Record<string, string[]>;
}
interface OpenFilePickerOptions {
multiple?: boolean;
excludeAcceptAllOption?: boolean;
types?: FilePickerAcceptType[];
}
type StartInType =
| "desktop"
| "documents"
| "downloads"
| "pictures"
| "videos"
| "music"
| FileSystemHandle;
interface SaveFilePickerOptions {
suggestedName?: string;
types?: FilePickerAcceptType[];
excludeAcceptAllOption?: boolean;
startIn?: FileSystemHandle | string;
}
interface DirectoryPickerOptions {
id?: string;
mode?: FileSystemPermissionMode;
startIn?: FileSystemHandle | string;
}
// Global functions
declare function showOpenFilePicker(
options?: OpenFilePickerOptions
): Promise<FileSystemFileHandle[]>;
declare function showSaveFilePicker(
options?: SaveFilePickerOptions
): Promise<FileSystemFileHandle>;
declare function showDirectoryPicker(
options?: DirectoryPickerOptions
): Promise<FileSystemDirectoryHandle>;
Here is an example of prompting the user to open a file, and then getting the file data off the file handle.
// opens file you choose and reads
document.querySelector("#open-file").addEventListener("click", async () => {
const [fileHandle] = await window.showOpenFilePicker({
types: [
{
description: "Text files",
accept: {
"text/*": [".txt", ".md", ".html", ".css", ".json", ".csv"],
},
},
],
excludeAcceptAllOption: true,
multiple: false,
});
const file = await fileHandle.getFile();
console.log(file);
document.querySelector("textarea")!.textContent = await file.text();
});
// opens directory you choose and logs out all subfile and subfolder handles
document.querySelector("#open-dir").addEventListener("click", async () => {
const dirHandle = await window.showDirectoryPicker();
console.log(dirHandle);
for await (const entry of dirHandle.values()) {
console.log(entry);
}
});
document.querySelector("#save-as").addEventListener("click", async () => {
const fileHandle = await window.showSaveFilePicker({
types: [
{
description: "Text files",
accept: {
"text/*": [".txt", ".md", ".html", ".css", ".js", ".json"],
},
},
],
});
const writable = await fileHandle.createWritable();
await writable.write(document.querySelector("textarea")!.textContent);
await writable.close();
});
window.showOpenFilePicker(options): for opening a file and retrieving info about it. Returns a file handlewindow.showDirectoryPicker(options): for opening a folder and retrieving info about it. Returns a directory handle
file handles
With a file handle, you can do stuff like writing to the file, and reading from it:
async function writeData(
fileHandle: FileSystemFileHandle,
data: Blob | string
) {
// create a writable, write to it, close it.
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
directory handles
With directory handles, you can do stuff like creating file handles and directory handles nested within it, and iterate over all the file handles and directory handles in the folder.
async function example(dirHandle: FileSystemDirectoryHandle) {
// 1. create new file handle in directory
const testHandle = dirHandle.getFileHandle("test.txt", { create: true });
// 2. delete file handle (deletes files)
await dirHandle.removeEntry(testHandle)
// 3. create new folder handle in directory
const subdirHandle = dirHandle.getDirectoryHandle("subdir", { create: true });
}
You can read all file handles from a directory like so:
async function readDirectoryHandle(dirHandle: FileSystemDirectoryHandle) {
const values = await Array.fromAsync(dirHandle.values());
return values; // returns array of file handles
}
basic class
Here's a basic class:
type FileAcceptType = {
description: string;
accept: Record<string, string[]>; // MIME type to file extension
};
export class FileSystemManager {
// region READ
static async openSingleFile(types: FileAcceptType[]) {
const [fileHandle] = await window.showOpenFilePicker({
types,
excludeAcceptAllOption: true,
multiple: false,
});
return fileHandle;
}
static async openMultipleFiles(types: FileAcceptType[]) {
const fileHandles = await window.showOpenFilePicker({
types,
excludeAcceptAllOption: true,
multiple: true,
});
return fileHandles;
}
static async openDirectory({
mode = "read",
startIn,
}: {
mode?: "read" | "readwrite";
startIn?: StartInType;
}) {
const dirHandle = await window.showDirectoryPicker({
mode: mode,
startIn: startIn,
});
return dirHandle;
}
static async readDirectoryHandle(dirHandle: FileSystemDirectoryHandle) {
const values = await Array.fromAsync(dirHandle.values());
return values;
}
static getFileFromDirectory(
dirHandle: FileSystemDirectoryHandle,
filename: string
) {
return dirHandle.getFileHandle(filename, { create: false });
}
static async getFileDataFromHandle(
handle: FileSystemFileHandle,
options?: { type?: "blobUrl" | "file" | "arrayBuffer" }
): Promise<File>;
static async getFileDataFromHandle(
handle: FileSystemFileHandle,
options: { type: "blobUrl" }
): Promise<string>;
static async getFileDataFromHandle(
handle: FileSystemFileHandle,
options: { type: "arrayBuffer" }
): Promise<ArrayBuffer>;
static async getFileDataFromHandle(
handle: FileSystemFileHandle,
options?: {
type?: "blobUrl" | "file" | "arrayBuffer";
}
) {
const file = await handle.getFile();
if (options?.type === "blobUrl") {
return URL.createObjectURL(file);
}
if (options?.type === "arrayBuffer") {
return file.arrayBuffer();
} else {
return file;
}
}
// region CREATE
static createFileFromDirectory(
dirHandle: FileSystemDirectoryHandle,
filename: string
) {
return dirHandle.getFileHandle(filename, { create: true });
}
// region DELETE
static deleteFileFromDirectory(
dirHandle: FileSystemDirectoryHandle,
filename: string
) {
return dirHandle.removeEntry(filename);
}
static deleteFolderFromDirectory(
dirHandle: FileSystemDirectoryHandle,
folderName: string
) {
return dirHandle.removeEntry(folderName, {
recursive: true,
});
}
// region WRITE
static async saveTextFile(text: string) {
const fileHandle = await window.showSaveFilePicker({
types: [
{
description: "Text files",
accept: {
"text/*": [".txt", ".md", ".html", ".css", ".js", ".json"],
},
},
],
});
await this.writeData(fileHandle, text);
}
static FileTypes = {
getTextFileTypes: () => {
return {
description: "Text files",
accept: {
"text/*": [".txt", ".md", ".html", ".css", ".js", ".json"],
},
};
},
getVideoFileTypes: () => {
return {
description: "Video files",
accept: {
"video/*": [".mp4", ".avi", ".mkv", ".mov", ".webm"],
},
};
},
getImageFileTypes: () => {
return {
description: "Image files",
accept: {
"image/*": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp"],
},
};
},
};
static async saveFile(options: {
data: Blob | string;
types?: FileAcceptType[];
name?: string;
startIn?: StartInType;
}) {
const fileHandle = await window.showSaveFilePicker({
types: options.types,
suggestedName: options.name,
startIn: options.startIn,
});
await this.writeData(fileHandle, options.data);
}
private static async writeData(
fileHandle: FileSystemFileHandle,
data: Blob | string
) {
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
}
OPFS
Related to the filesystem API is OPFS (origin private file system) which allows the web to access and store local file data in web storage. It's basically a filesystem per origin that is unrelated to the user's filesystem, but you can copy over files from the local filesystem to the OPFS.
The Origin Private File System (part of the File System Access API spec) allows web apps to store persistent files that live within the origin's private sandbox. This is never visible to the user directly (i.e., not tied to the local filesystem UI). This is basically the same as web storage and has the same storage capacity as IndexedDB, meaning that it can be cleared at any time from the user.
You can see the amount of storage you have remaining with navigator.storage.estimate() method.
Think of it as the app's internal hard drive that lives in the browser's storage quota.
Here are the benefits of using OPFS:
- efficient storage for large files
- fast local performance with read/write operations
- offline capability
- secure
- No file picker or permission prompt required.
- web workers can access the files stored on OPFS for high-performance operations
NOTE
Notion uses OPFS with read/write access to a local sqlite file on your disk to have fast operations before uploading to a cloud database.
The OPFS API works the exact same way as the filesystem access API, except that you work with a special directory handle that points to the OPFS of the origin:
We use the navigator.storage.getDirectory() method to access this directory handle, and then we can use the same familiar FileSystem API methods we know to work with it.
const opfsRoot = await navigator.storage.getDirectory();
export class OPFS {
private root!: FileSystemDirectoryHandle;
constructor(root?: FileSystemDirectoryHandle) {
if (root) {
this.root = root;
}
}
async initOPFS() {
try {
this.root = await navigator.storage.getDirectory();
return true;
} catch (e) {
console.error("Error opening directory:", e);
return false;
}
}
public get directoryHandle() {
return this.root;
}
private validate(): this is { root: FileSystemDirectoryHandle } {
if (!this.root) {
throw new Error("Root directory not set");
}
return true;
}
async getDirectoryContents() {
this.validate();
return await FileSystemManager.readDirectoryHandle(this.root);
}
async createFileHandle(filename: string) {
this.validate();
return await FileSystemManager.createFileFromDirectory(this.root, filename);
}
async getFileHandle(filename: string) {
this.validate();
return await FileSystemManager.getFileFromDirectory(this.root, filename);
}
async deleteFile(filename: string) {
this.validate();
await FileSystemManager.deleteFileFromDirectory(this.root, filename);
}
async deleteFolder(folderName: string) {
this.validate();
await FileSystemManager.deleteFolderFromDirectory(this.root, folderName);
}
static async writeToFileHandle(
file: FileSystemFileHandle,
data: string | Blob | ArrayBuffer
) {
const writable = await file.createWritable();
await writable.write(data);
await writable.close();
}
}
All together
type FileAcceptType = {
description: string;
accept: Record<string, string[]>; // MIME type to file extension
};
export class FileSystemManager {
static async getFileSize(handle: FileSystemFileHandle) {
const file = await handle.getFile();
return file.size;
}
// region READ
static async openSingleFile(types: FileAcceptType[]) {
const [fileHandle] = await window.showOpenFilePicker({
types,
excludeAcceptAllOption: true,
multiple: false,
});
return fileHandle;
}
static async openMultipleFiles(types: FileAcceptType[]) {
const fileHandles = await window.showOpenFilePicker({
types,
excludeAcceptAllOption: true,
multiple: true,
});
return fileHandles;
}
static async openDirectory({
mode = "read",
startIn,
}: {
mode?: "read" | "readwrite";
startIn?: StartInType;
}) {
const dirHandle = await window.showDirectoryPicker({
mode: mode,
startIn: startIn,
});
return dirHandle;
}
static async readDirectoryHandle(dirHandle: FileSystemDirectoryHandle) {
const values = await Array.fromAsync(dirHandle.values());
return values;
}
static async getDirectoryContentNames(dirHandle: FileSystemDirectoryHandle) {
const keys = await Array.fromAsync(dirHandle.keys());
return keys;
}
static async getStorageInfo() {
const estimate = await navigator.storage.estimate();
if (!estimate.quota || !estimate.usage) {
throw new Error("Storage estimate not available");
}
return {
storagePercentageUsed: (estimate.usage / estimate.quota) * 100,
bytesUsed: estimate.usage,
bytesAvailable: estimate.quota,
};
}
/**
* Recursively walks through a directory handle and returns all files
* @param dirHandle The directory handle to walk through
* @param path The current path (used for recursion)
* @returns An array of objects containing file handles and their paths
*/
static async walk(
dirHandle: FileSystemDirectoryHandle,
path: string = ""
): Promise<Array<{ handle: FileSystemFileHandle; path: string }>> {
const results: Array<{ handle: FileSystemFileHandle; path: string }> = [];
const entries = await this.readDirectoryHandle(dirHandle);
for (const entry of entries) {
const entryPath = path ? `${path}/${entry.name}` : entry.name;
if (entry.kind === "file") {
results.push({
handle: entry as FileSystemFileHandle,
path: entryPath,
});
} else if (entry.kind === "directory") {
// Recursively walk through subdirectories
const subDirHandle = entry as FileSystemDirectoryHandle;
const subResults = await this.walk(subDirHandle, entryPath);
results.push(...subResults);
}
}
return results;
}
static getFileFromDirectory(
dirHandle: FileSystemDirectoryHandle,
filename: string
) {
return dirHandle.getFileHandle(filename, { create: false });
}
static async getFileDataFromHandle(
handle: FileSystemFileHandle,
options?: {
type?: "blobUrl" | "file" | "arrayBuffer";
}
): Promise<File | string | ArrayBuffer> {
const file = await handle.getFile();
if (options?.type === "blobUrl") {
return URL.createObjectURL(file);
}
if (options?.type === "arrayBuffer") {
return file.arrayBuffer();
}
// Default return type is File
return file;
}
// region CREATE
static createFileFromDirectory(
dirHandle: FileSystemDirectoryHandle,
filename: string
) {
return dirHandle.getFileHandle(filename, { create: true });
}
// region DELETE
static deleteFileFromDirectory(
dirHandle: FileSystemDirectoryHandle,
filename: string
) {
return dirHandle.removeEntry(filename);
}
static deleteFolderFromDirectory(
dirHandle: FileSystemDirectoryHandle,
folderName: string
) {
return dirHandle.removeEntry(folderName, {
recursive: true,
});
}
// region WRITE
static async saveTextFile(text: string) {
const fileHandle = await window.showSaveFilePicker({
types: [
{
description: "Text files",
accept: {
"text/*": [".txt", ".md", ".html", ".css", ".js", ".json"],
},
},
],
});
await this.writeData(fileHandle, text);
}
static FileTypes = {
getTextFileTypes: () => {
return {
description: "Text files",
accept: {
"text/*": [".txt", ".md", ".html", ".css", ".js", ".json"],
},
};
},
getVideoFileTypes: () => {
return {
description: "Video files",
accept: {
"video/*": [".mp4", ".avi", ".mkv", ".mov", ".webm"],
},
};
},
getImageFileTypes: () => {
return {
description: "Image files",
accept: {
"image/*": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".svg", ".webp"],
},
};
},
};
static async saveFile(options: {
data: Blob | string;
types?: FileAcceptType[];
name?: string;
startIn?: StartInType;
}) {
const fileHandle = await window.showSaveFilePicker({
types: options.types,
suggestedName: options.name,
startIn: options.startIn,
});
await this.writeData(fileHandle, options.data);
}
private static async writeData(
fileHandle: FileSystemFileHandle,
data: Blob | string
) {
const writable = await fileHandle.createWritable();
await writable.write(data);
await writable.close();
}
}
export class OPFS {
private root!: FileSystemDirectoryHandle;
constructor(root?: FileSystemDirectoryHandle) {
if (root) {
this.root = root;
}
}
async initOPFS() {
try {
this.root = await navigator.storage.getDirectory();
return true;
} catch (e) {
console.error("Error opening directory:", e);
return false;
}
}
public get directoryHandle() {
return this.root;
}
public get initialized() {
return !!this.root;
}
private validate(): this is { root: FileSystemDirectoryHandle } {
if (!this.root) {
throw new Error("Root directory not set");
}
return true;
}
async getDirectoryContents() {
this.validate();
return await FileSystemManager.readDirectoryHandle(this.root);
}
async getFilesAndFolders() {
this.validate();
const entries = await FileSystemManager.readDirectoryHandle(this.root);
const files = entries.filter(
(entry) => entry.kind === "file"
) as FileSystemFileHandle[];
const folders = entries.filter(
(entry) => entry.kind === "directory"
) as FileSystemDirectoryHandle[];
return {
files,
folders,
};
}
async createFileHandle(filename: string) {
this.validate();
return await FileSystemManager.createFileFromDirectory(this.root, filename);
}
async createDirectory(folderName: string) {
this.validate();
const dirHandle = await this.root.getDirectoryHandle(folderName, {
create: true,
});
return new OPFS(dirHandle);
}
async getDirectoryContentNames() {
this.validate();
return await FileSystemManager.getDirectoryContentNames(this.root);
}
async getFileHandle(filename: string) {
this.validate();
return await FileSystemManager.getFileFromDirectory(this.root, filename);
}
async deleteFile(filename: string) {
this.validate();
await FileSystemManager.deleteFileFromDirectory(this.root, filename);
}
async deleteFolder(folderName: string) {
this.validate();
await FileSystemManager.deleteFolderFromDirectory(this.root, folderName);
}
static async writeDataToFileHandle(
file: FileSystemFileHandle,
data: string | Blob | ArrayBuffer
) {
const writable = await file.createWritable();
await writable.write(data);
await writable.close();
}
}
export class DirectoryNavigationStack {
constructor(
private root: FileSystemDirectoryHandle,
private stack: FileSystemDirectoryHandle[] = []
) {}
public get isRoot() {
return this.stack.length === 0;
}
public get fsRoot() {
return this.root;
}
public get size() {
return this.stack.length;
}
public push(dirHandle: FileSystemDirectoryHandle) {
this.stack.push(dirHandle);
}
public pop() {
return this.stack.pop();
}
public get currentDirectory() {
return this.stack.at(-1) || this.root;
}
public get currentFolderPath() {
if (this.isRoot) {
return "/" + this.root.name;
}
return "/" + [this.root.name, ...this.stack.map((d) => d.name)].join("/");
}
public get parentFolderPath() {
if (this.isRoot) {
return "/" + this.root.name;
}
return (
"/" +
[this.root.name, ...this.stack.slice(0, -1).map((d) => d.name)].join("/")
);
}
}
Web Payments API
Here is how to use the web payments api:
- Setup a payment method, like google pay
- Setup products to buy
- Create a new
PaymentRequestobject instance - Initiate payment request by awaiting
paymentRequest.show(), which returns a payment response - Complete the payment request by awaiting
paymentResponse.complete("success"), which will return the payment success details, which includes card number and shipping address of the customer.
// 1. create payment methods
const paymentMethods: PaymentMethodData[] = [
{
supportedMethods: "https://google.com/pay",
data: {
environment: "TEST", // Use 'PRODUCTION' in a live environment
apiVersion: 2,
apiVersionMinor: 0,
allowedPaymentMethods: [
{
type: "CARD",
parameters: {
allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
allowedCardNetworks: [
"AMEX",
"DISCOVER",
"JCB",
"MASTERCARD",
"VISA",
],
},
tokenizationSpecification: {
type: "PAYMENT_GATEWAY",
parameters: {
gateway: "example", // Replace with your gateway
gatewayMerchantId: "exampleMerchantId", // Replace with your merchant ID
},
},
},
],
merchantInfo: {
merchantName: "Example Merchant",
merchantId: "01234567890123456789", // Replace with your Google Pay merchant ID
},
},
},
];
// 2. create products
const paymentDetails: PaymentDetailsInit = {
total: {
label: "Total",
amount: { currency: "USD", value: "3.00" },
},
displayItems: [
{ label: "Item 1", amount: { currency: "USD", value: "1.50" } },
{ label: "Item 2", amount: { currency: "USD", value: "1.50" } },
],
};
// 3. create request
const request = new PaymentRequest(paymentMethods, paymentDetails);
async function showRequest() {
try {
const canMakePayment = await request.canMakePayment();
// 4. initiate request
const paymentResponse = await request.show();
// 5. complete request
await paymentResponse.complete("success");
console.log("Payment successful");
} catch (error) {
console.error(error);
}
}
Anyway, here's the whole fucking class:
export class WebPaymentManager {
private paymentDetails: PaymentDetailsInit;
private paymentMethods?: PaymentMethodData[];
private paymentRequest?: PaymentRequest;
constructor(items: PaymentItem[]) {
this.paymentDetails = this._constructCart(items);
}
private _constructCart(items: PaymentItem[]) {
return {
total: {
label: "Total",
amount: {
currency: "USD",
value: items
.reduce((acc, item) => acc + Number(item.amount.value), 0)
.toString(),
},
},
displayItems: items,
} as PaymentDetailsInit;
}
constructCart(items: PaymentItem[]) {
this.paymentDetails = this._constructCart(items);
this.setupPayment();
}
async canMakePayment() {
if (!this.paymentRequest) {
throw new Error("Payment request not set");
}
return await this.paymentRequest.canMakePayment();
}
async makePayment() {
try {
if (!this.paymentRequest) {
throw new Error("Payment request not set");
}
const canMakePayment = await this.paymentRequest.canMakePayment();
if (!canMakePayment) {
throw new Error("Cannot make payment");
}
const paymentResponse = await this.paymentRequest.show();
await paymentResponse.complete("success");
return paymentResponse;
} catch (error) {
console.error(error);
return null;
}
}
setupPayment() {
if (!this.paymentMethods) {
throw new Error("Payment methods not set");
}
this.paymentRequest = new PaymentRequest(
this.paymentMethods!,
this.paymentDetails
);
}
setupPaymentMethod({
gateway,
gatewayMerchantId,
merchantName,
merchantId,
environment = "TEST",
}: {
environment?: "TEST" | "PRODUCTION";
gateway: string;
gatewayMerchantId: string;
merchantName: string;
merchantId: string;
}) {
this.paymentMethods = [
{
supportedMethods: "https://google.com/pay",
data: {
environment: environment, // Use 'PRODUCTION' in a live environment
apiVersion: 2,
apiVersionMinor: 0,
allowedPaymentMethods: [
{
type: "CARD",
parameters: {
allowedAuthMethods: ["PAN_ONLY", "CRYPTOGRAM_3DS"],
allowedCardNetworks: [
"AMEX",
"DISCOVER",
"JCB",
"MASTERCARD",
"VISA",
],
},
tokenizationSpecification: {
type: "PAYMENT_GATEWAY",
parameters: {
gateway: gateway || "", // Replace with your gateway
gatewayMerchantId: gatewayMerchantId || "", // Replace with your merchant ID
},
},
},
],
merchantInfo: {
merchantName: merchantName || "",
merchantId: merchantId || "", // Replace with your Google Pay merchant ID
},
},
},
];
}
}
And here's how you'd use it:
const paymentManager = new WebPaymentManager([
{ label: "Item 1", amount: { currency: "USD", value: "1.50" } },
{ label: "Item 2", amount: { currency: "USD", value: "1.50" } },
]);
paymentManager.setupPaymentMethod({
gateway: "example",
gatewayMerchantId: "exampleMerchantId",
merchantName: "Example Merchant",
merchantId: "01234567890123456789",
environment: "TEST",
});
paymentManager.setupPayment();
document.querySelector("#pay").addEventListener("click", async () => {
const details = await paymentManager.makePayment();
if (!details) {
console.error("Payment failed");
return;
}
console.log(details);
});
Navigator share API
The navigator.share(options) async method allows sharing media and urls like you can do on your phone. You pass in an object of options which configure the sharing behavior:
title: the share titletext: the body texturl: the url to sharefiles: an optional property ofFile[]to share. You can share files this way.
async function share() {
const file = new File(["Hello, world!"], "hello-world.txt", {
type: "text/plain",
});
await navigator.share({
title: "Web Share API",
text: "Hello World",
url: "https://developer.mozilla.org",
files: [file],
});
}
Before you share with navigator.share(), you can see if content is shareable in the first place and prevent errors with navigator.canShare():
const data = {
title: 'Item 1',
text: 'This is the first item',
url: 'https://example.com/item1',
};
const canShare = navigator.canShare(data);
canShare && navigator.share(data)
Anyway here's a class:
export class NavigatorShare {
static async share(data: {
title: string;
text: string;
url: string;
files?: File[];
}) {
try {
if (!this.canShare(data)) return false;
await navigator.share(data);
return true;
} catch (error) {
console.error("Error sharing", error);
return false;
}
}
static canShare(data: {
title: string;
text: string;
url: string;
files?: File[];
}) {
return navigator.canShare(data);
}
}
Geolocation
The navigator.geolocation.getCurrentPosition() method allows us to get the current position of the user. The navigator.geolocation.watchPosition() listens for a position change. They both take two arguments:
- Success method : a callback function to run if the position was successfully retrieved
- Error method : a callback function to run if there was an error retrieving the position
// getCurrentPosition gets the current position
navigator.geolocation.getCurrentPosition(displayGeoData, displayError);
// getCurrentPosition runs the passed in methods whenever the position changes
navigator.geolocation.watchPosition(displayGeoData, displayError);
const displayGeoData = (position) => {
const { latitude, longitude } = position.coords;
displayArea.textContent = `Latitude: ${latitude}, Longitude: ${longitude}`;
};
const displayError = (err) => {
displayArea.textContent = err.message;
};
Intersection Observer
THe intersection observer API is used to asnychronously run some functionality when observed elements are visible in the viewport. Here are some possible use cases:
- lazy loading images
- Running animations on scroll
The main steps are to create an observer and then to observe elements by adding them to the entries.
const observer = new IntersectionObserver(
// iterate through the entries
(entries) => {
entries.forEach((entry) => {
entry.isIntersecting
? console.log("element IS VISIBLE")
: console.log("element IS NOT VISIBLE");
});
}
);
// observe elements
observer.observe(element);
The basic syntax for creating an observer is as follows:
const observer = new IntersectionObserver(entriesCallback, options);
This observer will have the following methods:
observer.observe(element): This adds an element to the observer. This takes an element as an argument.observer.unobserve(element): This removes an element from the observer. This takes an element as an argument.
Each entry has the following properties:
isIntersecting: Whether the element is intersecting the viewport, Boolean.isVisible: Whether the element is visible in the viewport, Boolean.target: returns The HTML element that is being observed, Element.intersectionRatio: The percentage of the element that is visible in the viewport, Number. This is a number between 0 and 1, where 0 is not visible at all, and 1 is completely visible.boundingClientRect: The bounding client rectangle of the element, DOMRectReadOnly.intersectionRect: The intersection rectangle of the element, DOMRectReadOnly.
Threshold
threshold is the percentage of the element that must be visible in the viewport for it to be considered as intersecting. It is a number in between 0 and 1.
You cna also provide an array of values to trigger the callback multiple times, when different percentages of the element are visible.
const observer = new IntersectionObserver(
// iterate through the entries
(entries) => {
entries.forEach((entry) => {
entry.isIntersecting &&
console.log(`element is ${entry.intersectionRatio * 100}% visible`);
});
},
{
threshold: [0, 0.25, 0.5, 0.75, 1],
}
);
user media
async function getMedia() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
const videoElement = document.querySelector("#videoElement");
videoElement.srcObject = stream;
} catch (e) {
console.log("permission denied");
}
}
The async navigator.mediaDevices.getUserMedia() method returns a promise that resolves to a MediaStream object. This takes an object as an argument, which specifies the type of media to get. This object can have the following properties:
audio: Whether to get audio, Boolean.video: Whether to get video, Boolean.screen: Whether to get the screen, Boolean.displaySurface: The type of display surface to get. This can be one of the following values:"browser": The entire browser window."monitor": The entire screen."window": The entire window."application": The application window.
const stream = await navigator.mediaDevices.getUserMedia(options);
With this stream, you can then set this stream as the source of some video or audio element in your HTML.
Navigation
The navigation API is a new way to do navigation on the web instead of the history and popstate APIs.
To get type hints, run this command to install the types:
npm install -D @types/dom-navigation
function shouldNotIntercept(navigationEvent: NavigateEvent) {
return (
!navigationEvent.canIntercept ||
// If this is just a hashChange,
// just let the browser handle scrolling to the content.
navigationEvent.hashChange ||
// If this is a download,
// let the browser perform the download.
navigationEvent.downloadRequest ||
// If this is a form submission,
// let that go to the server.
navigationEvent.formData ||
// if this navigates to another origin, don't intercept
!navigationEvent.destination.url.startsWith(window.location.origin)
);
}
async function renderIndexPage() {} // methods to render HTML for page
async function renderCatsPage() {}
window.navigation.addEventListener("navigate", (navigateEvent) => {
// Exit early if this navigation shouldn't be intercepted.
if (shouldNotIntercept(navigateEvent)) return;
const url = new URL(navigateEvent.destination.url);
if (url.pathname === "/") {
navigateEvent.intercept({ handler: renderIndexPage });
} else if (url.pathname === "/cats/") {
navigateEvent.intercept({ handler: renderCatsPage });
}
});
The navigateEvent has these properties:
navigateEvent.canIntercept: whether or not the navigation can be interceptednavigateEvent.hashChange: True if only the hash changed (usually don't intercept these)navigateEvent.downloadRequest: True if triggered by a download linknavigateEvent.formData: Contains form data if this is a POST form submissionnavigateEvent.navigationType: One of "reload", "push", "replace", or "traverse"navigateEvent.signal: An AbortSignal for canceling navigationnavigateEvent.userInitiated: a boolean that returns true if the user initiated the navigation manually
The navigateEvent.destination is an object containing metadata about the navigation destination and has these properties:
navigateEvent.destination.url: the url of where the navigation was trying to gonavigateEvent.destination.key: the unique navigation key that represents the navigation, which can be used to access that specific navigation in history.navigateEvent.destination.sameDocument: a boolean that describes if the navigation is in the same document or not (same origin)
And it has these methods:
navigateEvent.preventDefault(): prevents the navigation from occurring. This will not work if the user prevents the forward or back buttons to escape the site.navigateEvent.intercept({handler: async () => void}): runs the specified async callback on page navigation. Basically use this to define your own code to replace the current page with a new page like how SPAs do it.navigateEvent.scroll(): scrolls or something.
Navigation methods
Use the navigation methods to navigate while also setting state.
navigation.navigate(url, options?): navigates to the specified urlnavigation.reload(options?): reloads the pagenavigation.back(): goes back one entrynavigation.forward(): goes forward one entrynavigation.traverseTo(key: string): navigates to a specific history entry.
// Navigate to a new URL
const result = navigation.navigate('/new-page', {
state: { from: 'button' },
history: 'replace', // or 'push' (default)
info: { animation: 'slide' } // passed to navigate event
});
// Wait for navigation to complete
await result.finished;
// Other navigation methods
navigation.back();
navigation.forward();
navigation.reload({ state: newState })
basic navigation
traveling to specific history entry
Here is an example of traveling to a specific history entry:
// On JS startup, get the key of the first loaded page
// so the user can always go back there.
const { key } = navigation.currentEntry;
backToHomeButton.onclick = () => navigation.traverseTo(key);
// Navigate away, but the button will always work.
await navigation.navigate("/another_url").finished;
Navigation current entry and state
navigation.currentEntry provides access to the current entry. This is an object which describes where the user is right now. This entry includes the current URL, metadata that can be used to identify this entry over time, and developer-provided state.
navigation.currentEntry is just a special type of NavigationEntry, just like how navigation.destination is also a navigation entry. They both have the same properties and methods.
navigation.currentEntry.url: the current urlnavigation.currentEntry.key: A unique representation of the current state and url
The most important thing about navigation.currentEntry is its ability to retrieve state. with the navigation.currentEntry.getState() method. However, this returned state is immutable and does not register changes to it.
To actually change state, you need to do so in the navigation methods.
// Set state during navigation
navigation.navigate('/page', { state: { user: 'john', theme: 'dark' } });
// or do this
navigation.reload({ state: { user: 'john', theme: 'dark' } })
// Update current entry state
navigation.updateCurrentEntry({ state: newState });
// Access state
const state = navigation.currentEntry.getState();
In the navigation event, you can also retrieve the state of the navigation's destination.
navigation.addEventListener('navigate', navigateEvent => {
console.log(navigateEvent.destination.getState());
});