summaryrefslogtreecommitdiff
path: root/shared/utils/src
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /shared/utils/src
init commit
Diffstat (limited to 'shared/utils/src')
-rw-r--r--shared/utils/src/get-pwa-display-mode.ts39
-rw-r--r--shared/utils/src/history.ts168
-rw-r--r--shared/utils/src/is-pojo.ts20
-rw-r--r--shared/utils/src/launch/launch-client.ts109
-rw-r--r--shared/utils/src/launch/scheme.ts339
-rw-r--r--shared/utils/src/lru-map.ts60
-rw-r--r--shared/utils/src/object-from-entries.ts18
-rw-r--r--shared/utils/src/optional.ts22
-rw-r--r--shared/utils/src/platform.ts249
-rw-r--r--shared/utils/src/try-scroll.ts65
-rw-r--r--shared/utils/src/url.ts90
-rw-r--r--shared/utils/src/uuid.ts22
12 files changed, 1201 insertions, 0 deletions
diff --git a/shared/utils/src/get-pwa-display-mode.ts b/shared/utils/src/get-pwa-display-mode.ts
new file mode 100644
index 0000000..506c80d
--- /dev/null
+++ b/shared/utils/src/get-pwa-display-mode.ts
@@ -0,0 +1,39 @@
+export enum PWADisplayMode {
+ TWA = 'twa',
+ BROWSER = 'browser',
+ STANDALONE = 'standalone',
+ MINIMAL = 'minimal-ui',
+ FULLSCREEN = 'fullscreen',
+ OVERLAY = 'window-controls-overlay',
+ UNKNOWN = 'unknown',
+}
+
+/**
+ * For PWA, reads the "display" value from the manifest.json and returns the proper value if it matches.
+ * Inspired by the sample snippet here: https://web.dev/learn/pwa/detection
+ */
+export const getPWADisplayMode = (): PWADisplayMode => {
+ switch (true) {
+ case document.referrer.startsWith('android-app://'):
+ return PWADisplayMode.TWA;
+
+ case window.matchMedia('(display-mode: browser)').matches:
+ return PWADisplayMode.BROWSER;
+
+ case window.matchMedia('(display-mode: standalone)').matches:
+ return PWADisplayMode.STANDALONE;
+
+ case window.matchMedia('(display-mode: minimal-ui)').matches:
+ return PWADisplayMode.MINIMAL;
+
+ case window.matchMedia('(display-mode: fullscreen)').matches:
+ return PWADisplayMode.FULLSCREEN;
+
+ case window.matchMedia('(display-mode: window-controls-overlay)')
+ .matches:
+ return PWADisplayMode.OVERLAY;
+
+ default:
+ return PWADisplayMode.UNKNOWN;
+ }
+};
diff --git a/shared/utils/src/history.ts b/shared/utils/src/history.ts
new file mode 100644
index 0000000..498f7d1
--- /dev/null
+++ b/shared/utils/src/history.ts
@@ -0,0 +1,168 @@
+import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
+import { LruMap } from './lru-map';
+import type { ScrollableElement } from './try-scroll';
+import { tryScroll } from './try-scroll';
+import { removeHost } from './url';
+import { generateUuid } from './uuid';
+
+export interface Options {
+ getScrollablePageElement(): ScrollableElement | null;
+}
+
+type Id = string;
+const HISTORY_SIZE_LIMIT = 10;
+
+interface WithScrollPosition<State> {
+ scrollY: number;
+ state: State;
+}
+/**
+ * We are using a currentStateId on this class to always store the state id instead of saving
+ * it on the window.history.state because there seems to be a bug in Safari where it is mutating
+ * the window.history.state to null after our Sign In flow which includes multiple iframes
+ * and multiple internal state changes inside the iframes. We can move back to window.history.state storing the id
+ * if the Safari Issue is fixed in future.
+ */
+export class History<State> {
+ private readonly log: Logger;
+ private readonly states: LruMap<Id, WithScrollPosition<State>>;
+ private readonly getScrollablePageElement: () => ScrollableElement | null;
+ private currentStateId: string | undefined;
+
+ constructor(
+ loggerFactory: LoggerFactory,
+ options: Options,
+ sizeLimit: number = HISTORY_SIZE_LIMIT,
+ ) {
+ this.log = loggerFactory.loggerFor('History');
+ this.states = new LruMap(sizeLimit);
+ this.getScrollablePageElement = options.getScrollablePageElement;
+ }
+
+ // Update page data but keep scroll position
+ updateState(update: (state?: State) => State): void {
+ if (!this.currentStateId) {
+ this.log.warn(
+ 'failed: encountered a null currentStateId inside updateState',
+ );
+ return;
+ }
+
+ const currentState = this.states.get(this.currentStateId);
+ const newState = update(currentState?.state);
+ this.log.info('updateState', newState, this.currentStateId);
+ this.states.set(this.currentStateId, {
+ ...(currentState as WithScrollPosition<State>),
+ state: newState,
+ });
+ }
+
+ replaceState(state: State, url: string | null): void {
+ const id = generateId();
+ this.log.info('replaceState', state, url, id);
+ window.history.replaceState({ id }, '', this.removeHost(url));
+ this.currentStateId = id;
+ this.states.set(id, { state, scrollY: 0 });
+ this.scrollTop = 0;
+ }
+
+ pushState(state: State, url: string | null): void {
+ const id = generateId();
+ this.log.info('pushState', state, url, id);
+ window.history.pushState({ id }, '', this.removeHost(url));
+ this.currentStateId = id;
+ this.states.set(id, { state, scrollY: 0 });
+ this.scrollTop = 0;
+ }
+
+ beforeTransition(): void {
+ const { state } = window.history;
+
+ if (!state) {
+ return;
+ }
+
+ const oldState = this.states.get(state.id);
+ if (!oldState) {
+ this.log.info(
+ 'current history state evicted from LRU, not saving scroll position',
+ );
+ return;
+ }
+
+ const { scrollTop } = this;
+
+ this.states.set(state.id, {
+ ...oldState,
+ scrollY: scrollTop,
+ });
+
+ this.log.info('saving scroll position', scrollTop);
+ }
+
+ private removeHost(url: string | null): string | undefined {
+ if (!url) {
+ this.log.warn('received null URL');
+ return;
+ }
+
+ // TODO: rdar://77982655 (Investigate router improvements): host mismatch?
+ return removeHost(url);
+ }
+
+ onPopState(
+ listener: (url: string, state: State | undefined) => void,
+ ): void {
+ window.addEventListener('popstate', (event: PopStateEvent): void => {
+ this.currentStateId = event.state?.id;
+
+ if (!this.currentStateId) {
+ this.log.warn(
+ 'encountered a null event.state.id in onPopState event: ',
+ window.location.href,
+ );
+ }
+
+ this.log.info('popstate', this.states, this.currentStateId);
+ const state = this.currentStateId
+ ? this.states.get(this.currentStateId)
+ : undefined;
+ listener(window.location.href, state?.state);
+
+ if (!state) {
+ return;
+ }
+
+ const { scrollY } = state;
+
+ this.log.info('restoring scroll to', scrollY);
+
+ tryScroll(this.log, () => this.getScrollablePageElement(), scrollY);
+ });
+ }
+
+ private get scrollTop(): number {
+ return this.getScrollablePageElement()?.scrollTop || 0;
+ }
+
+ private set scrollTop(scrollTop: number) {
+ const element = this.getScrollablePageElement();
+ if (element) {
+ element.scrollTop = scrollTop;
+ }
+ }
+
+ // TODO: rdar://77982655 (Investigate router improvements): offPopState?
+}
+
+/**
+ * Generate a (unique) id for storing in window.history.state.
+ *
+ * @return the generated ID
+ */
+function generateId(): Id {
+ // The use of something random (and not say, an incrementing counter) is important
+ // here. These states can survive refreshes so the IDs used must be globally unique
+ // (and not just unique to the current page load).
+ return generateUuid();
+}
diff --git a/shared/utils/src/is-pojo.ts b/shared/utils/src/is-pojo.ts
new file mode 100644
index 0000000..4363454
--- /dev/null
+++ b/shared/utils/src/is-pojo.ts
@@ -0,0 +1,20 @@
+/**
+ * Determine if {@linkcode arg} is a Plain Old JavaScript Object.
+ *
+ * @see https://masteringjs.io/tutorials/fundamentals/pojo
+ *
+ * @param arg to test
+ * @returns true if {@linkcode arg} is a POJO
+ */
+export function isPOJO(arg: unknown): arg is Record<string, unknown> {
+ if (!arg || typeof arg !== 'object') {
+ return false;
+ }
+
+ const proto = Object.getPrototypeOf(arg);
+ if (!proto) {
+ return true; // `Object.create(null)`
+ }
+
+ return proto === Object.prototype;
+}
diff --git a/shared/utils/src/launch/launch-client.ts b/shared/utils/src/launch/launch-client.ts
new file mode 100644
index 0000000..24378d6
--- /dev/null
+++ b/shared/utils/src/launch/launch-client.ts
@@ -0,0 +1,109 @@
+import { createClientLink } from './scheme';
+import type { Platform } from '../platform';
+
+/**
+ * Navigator for older Microsoft (MS) browsers like Internet Explorer.
+ */
+type MSNavigator = Navigator & {
+ msLaunchUri: (
+ href: string | URL,
+ successCallback: () => void,
+ failureCallback: () => void,
+ ) => void;
+};
+
+/**
+ * Check if the given value is an MSNavigator.
+ */
+function isMSNavigator(value: Partial<MSNavigator>): value is MSNavigator {
+ return typeof value?.msLaunchUri === 'function';
+}
+
+/**
+ * Callback for client launches.
+ */
+export type LaunchCallback = (result: {
+ link: URL;
+ success: boolean;
+}) => void | Promise<void>;
+
+/**
+ * Attempt to launch the native client for the given Web URL.
+ */
+export function launchClient(
+ url: string | URL,
+ platform: Platform,
+ callback: LaunchCallback = () => {},
+): void {
+ const { window, browser, os } = platform;
+
+ /** URL for opening the native application */
+ const link = createClientLink(url, { platform });
+
+ // macOS Safari
+ if (os.isMacOS && browser.isSafari) {
+ launchOnMacOS(link, platform, callback);
+ }
+ // Proprietary msLaunchUri method (IE 10+ on Windows 8+)
+ else if (isMSNavigator(platform.navigator)) {
+ platform.navigator.msLaunchUri(
+ String(link),
+ () => callback({ link, success: true }),
+ () => callback({ link, success: false }),
+ );
+ }
+ // Other platforms
+ else {
+ try {
+ // on iOS, Windows and Android simply opening the href works
+ window!.top!.window.location.href = String(link);
+ callback({ link, success: true });
+ } catch (e) {
+ // we know this is NOT installed
+ callback({ link, success: false });
+ }
+ }
+}
+
+function launchOnMacOS(
+ link: URL,
+ platform: Platform,
+ callback: LaunchCallback,
+): void {
+ const { window } = platform;
+
+ if (typeof window === 'undefined') {
+ callback({ link, success: false });
+ return;
+ }
+
+ /** Timer for blur fallback */
+ let timer: number;
+
+ /** IFrame reference for opening the client link */
+ let iframe: HTMLIFrameElement | undefined;
+
+ /** Cleanup function run after the client launch has been initiated */
+ function finalize() {
+ clearTimeout(timer);
+ window!.removeEventListener('blur', finalize);
+ if (iframe !== undefined) {
+ window!.document.body.removeChild(iframe);
+ }
+
+ callback({ link, success: true });
+ }
+
+ // Add an iFrame window to the current document to open the URL
+ iframe = window.document.createElement('iframe');
+ iframe.id = 'launch-client-opener';
+ iframe.style.display = 'none';
+ window.document.body.appendChild(iframe);
+
+ // Redirect the iFrame to the client link to trigger it to open
+ iframe.contentWindow!.location.href = String(link);
+
+ // Wait a tiny amount of time for the client launch to appear
+ window.addEventListener('blur', finalize);
+ timer = setTimeout(finalize, 50) as unknown as number;
+}
diff --git a/shared/utils/src/launch/scheme.ts b/shared/utils/src/launch/scheme.ts
new file mode 100644
index 0000000..1b548c4
--- /dev/null
+++ b/shared/utils/src/launch/scheme.ts
@@ -0,0 +1,339 @@
+import { removeScheme } from '..';
+import { Platform } from '../platform';
+
+/**
+ * Check if the URL hostname matches the given value.
+ */
+const matchesHostName = (url: URL, hostName: string) =>
+ url.hostname === hostName;
+
+/**
+ * Check if the URL `?app=xyz` search param matches the given value.
+ */
+const matchesAppName = (url: URL, appName: string) =>
+ url.searchParams.get('app') === appName;
+
+/**
+ * Check if the URL `?mt=n` search param matches any of the given values.
+ */
+const matchesMediaType = (url: URL, mediaTypes: string[]) => {
+ const mt = url.searchParams.get('mt');
+ return mt ? mediaTypes.includes(mt) : false;
+};
+
+/**
+ * Check if the URL pathname matches the given pattern.
+ */
+const matchesPathName = (url: URL, pattern: RegExp | string) =>
+ new RegExp(pattern).test(url.pathname);
+
+/**
+ * Check if the URL is for Audiobooks
+ */
+const isAudiobookURL = (url: URL): boolean =>
+ matchesAppName(url, 'audiobook') ||
+ matchesMediaType(url, ['3']) ||
+ matchesPathName(url, /\/(audiobook\/|viewAudiobook)/i);
+
+/**
+ * Check if the URL is for Books.
+ */
+const isBooksURL = (url: URL): boolean =>
+ !isAudiobookURL(url) &&
+ (matchesHostName(url, 'books.apple.com') ||
+ matchesAppName(url, 'books') ||
+ matchesMediaType(url, ['11', '13']) ||
+ matchesPathName(url, '/book/'));
+
+/**
+ * Check if the URL is for Commerce.
+ */
+const isCommerceURL = (url: URL): boolean =>
+ matchesHostName(url, 'finance-app.itunes.apple.com') ||
+ matchesPathName(url, '/account/');
+
+/**
+ * Check if the URL is for a macOS App.
+ */
+const isMacAppURL = (url: URL): boolean =>
+ matchesAppName(url, 'mac-app') ||
+ matchesMediaType(url, ['12']) ||
+ matchesPathName(url, '/mac-app/');
+
+/**
+ * Check if the URL is an AppStore Story.
+ */
+const isStoryURL = (url: URL): boolean =>
+ matchesAppName(url, 'story') || matchesPathName(url, '/story/');
+
+/**
+ * Check if the URL is for Messages.
+ */
+const isMessagesURL = (url: URL): boolean => matchesAppName(url, 'messages');
+
+/**
+ * Check if the URL is for Music.
+ */
+const isMusicURL = (url: URL): boolean =>
+ matchesHostName(url, 'music.apple.com') ||
+ matchesAppName(url, 'music') ||
+ matchesPathName(
+ url,
+ /\/(album|artist|playlist|station|curator|music-video)\//i,
+ );
+
+/**
+ * Check if the URL is for Podcasts.
+ */
+const isPodcastsURL = (url: URL): boolean =>
+ matchesHostName(url, 'podcasts.apple.com') ||
+ matchesAppName(url, 'podcasts') ||
+ matchesMediaType(url, ['2']) ||
+ matchesPathName(url, '/podcast/');
+
+/**
+ * Check if the URL is for TV.
+ */
+const isTVURL = (url: URL): boolean =>
+ matchesHostName(url, 'tv.apple.com') ||
+ matchesPathName(
+ url,
+ /\/(episode|movie|movie-collection|show|season|sporting-event|person)\//i,
+ );
+
+/**
+ * Check if the URL is for the Watch.
+ */
+const isWatchURL = (url: URL): boolean => matchesAppName(url, 'watch');
+
+/**
+ * Check if the URL is developer.apple.com related.
+ */
+const isDeveloperURL = (url: URL): boolean =>
+ matchesAppName(url, 'developer') || matchesPathName(url, '/developer/');
+
+/**
+ * Check if the URL is for an app.
+ */
+const isAppsURL = (url: URL): boolean =>
+ matchesMediaType(url, ['8']) && !isMessagesURL(url) && !isWatchURL(url);
+
+/**
+ * Function for identifying application schemes from web URLs.
+ */
+type SchemeIdentifier = (url: URL, platform: Platform) => boolean;
+
+/**
+ * List of schemes and functions to identify them based on a URL and Platform details.
+ *
+ * These schemes are derived from [Jingle Properties](https://github.pie.apple.com/amp-dev/Jingle/blob/6392929afb8540ac488315647992c3f46a9cc82f/MZConfig/Properties/apps/MZInit2/common.properties#L993).
+ *
+ * ```java
+ * // <rdar://problem/66551318> iOS Bag: Move mobile-url-handlers to a property defined list
+ * MZInit.iOS.acceptedUrlHandlers=("applenews", "applenewss", "applestore", "applestore-sec", "bridge", "com.apple.tv", "disneymoviesanywhere",\
+ * "http", "https", "itms", "itmss", "itms-apps", "itms-appss", "itms-books", "itms-bookss", "itms-gc", "itms-gcs", "itms-itunesu",\
+ * "itms-itunesus", "itms-podcast", "itms-podcasts", "itms-ui", "its-music", "its-musics", "its-news", "its-newss", "its-videos",\
+ * "its-videoss", "itsradio", "livenation", "mailto", "message", "moviesanywhere", "music", "musics", "prefs", "shoebox")
+ * ```
+ */
+const identifiers: [string, SchemeIdentifier, ...SchemeIdentifier[]][] = [
+ [
+ 'itms-apps',
+ (url, platform) =>
+ platform.os.isIOS &&
+ (isCommerceURL(url) ||
+ isAppsURL(url) ||
+ isStoryURL(url) ||
+ isDeveloperURL(url)),
+ ],
+
+ // Watch app on mobile
+ [
+ 'itms-watch',
+ (url, platform) => platform.browser.isMobile && isWatchURL(url),
+ ],
+
+ // Messages app on mobile
+ [
+ 'itms-messages',
+ function (url: URL, platform: Platform) {
+ return platform.browser.isMobile && isMessagesURL(url);
+ },
+ ],
+
+ [
+ 'itms-books',
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isAudiobookURL(url),
+ (url, _platform) => isBooksURL(url),
+ ],
+
+ // Music on Android
+ [
+ 'apple-music',
+ (url, platform) => platform.os.isAndroid && isMusicURL(url),
+ ],
+
+ // Music on iOS/macOS
+ [
+ 'music',
+ (url, platform) => platform.os.isIOS && isMusicURL(url),
+ (url, platform) => {
+ return (
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isMusicURL(url)
+ );
+ },
+ ],
+
+ // Podcasts on iOS
+ [
+ 'itms-podcasts',
+ (url, platform) => platform.os.isIOS && isPodcastsURL(url),
+ ],
+
+ // Podcasts on macOS
+ [
+ 'podcasts',
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isPodcastsURL(url),
+ ],
+
+ // TV on iOS
+ [
+ 'com.apple.tv',
+ (url, platform) =>
+ platform.os.isIOS && platform.os.gte('10.2') && isTVURL(url),
+ ],
+
+ // TV on macOS
+ [
+ 'videos',
+ (url: URL, platform: Platform) =>
+ platform.os.isMacOS && platform.os.gte('10.15') && isTVURL(url),
+ ],
+
+ [
+ 'macappstore',
+ (url, _platform) => isMacAppURL(url),
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.15') &&
+ isCommerceURL(url),
+
+ // Story and developer pages should launch Mac App Store on Mojave(10.14)+
+ // <rdar://problem/46461633> Story page with ls=1 QP should attempt to open Mac App Store on Mojave +
+ // rdar://81291713 (Star: https://apps.apple.com/developer/id463855590?ls=1 launches Music App)
+ (url, platform) =>
+ platform.os.isMacOS &&
+ platform.os.gte('10.14') &&
+ (isStoryURL(url) || isDeveloperURL(url)),
+ ],
+
+ // Catch All
+ ['itms', (_url, _platform) => true],
+];
+
+/**
+ * Get the Scheme for attempting to open a platform native application.
+ *
+ * @see {@link https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Syntax}
+ */
+export function detectClientScheme(
+ url: string | URL,
+ options?: { platform?: Platform },
+): string {
+ url = new URL(url);
+
+ // Assume that any URLs that don't have the http(s) scheme already have the
+ // correct scheme assigned.
+ if (/https?/i.test(url.protocol)) {
+ const platform = options?.platform ?? Platform.detect();
+
+ for (const [scheme, ...fns] of identifiers) {
+ for (const fn of fns) {
+ if (fn(url, platform)) {
+ return scheme;
+ }
+ }
+ }
+ }
+
+ // At this point something should have matched. If not just return the original
+ // scheme and have the browser or system handle it.
+ return normalizeScheme(url.protocol);
+}
+
+/**
+ * Check if the given URL has an Apple specific Scheme.
+ *
+ * @example
+ * ```javascript
+ * hasAppleClientScheme('music://music.apple.com/browse') // => true
+ * hasAppleClientScheme('https://music.apple.com/browse') // => false
+ * ```
+ */
+export function hasAppleClientScheme(
+ url: URL | string,
+ _options?: { platform?: Platform },
+) {
+ const pattern =
+ /^(?:itms(?:-.*)?|macappstore|podcast|video|(?:apple-)?music)s?(:|$)/im;
+ return pattern.test(new URL(url).protocol);
+}
+
+/**
+ * Create a link for attempting to open a platform native application based on a web URL.
+ *
+ * @example
+ * ```javascript
+ * createClientLink('https://music.apple.com/browse');
+ * // => 'music://music.apple.com/browse'
+ * ```
+ */
+export function createClientLink(
+ url: string | URL,
+ options?: { platform?: Platform },
+): URL {
+ const link = new URL(url);
+
+ // Removes any development prefixes in order to correctly identify the scheme
+ link.host = link.host.replace(
+ /^(?:[^-]+[-.])?([^.]+)\.apple\.com/,
+ '$1.apple.com',
+ );
+
+ // Remove any port designation, this should not be present in application links
+ link.port = '';
+
+ const scheme = detectClientScheme(link, {
+ platform: options?.platform,
+ });
+
+ // If the identified scheme is already assigned we want to leave the URL unmodified
+ if (scheme === normalizeScheme(link.protocol)) {
+ return new URL(url);
+ }
+
+ return new URL(scheme + '://' + removeScheme(link));
+}
+
+/**
+ * Normalize a scheme value by removing any separators from it.
+ *
+ * @example
+ * ```javascript
+ * normalizeScheme('music') // => 'music'
+ * normalizeScheme('TV') // => 'tv'
+ * normalizeScheme('https:') // => 'https'
+ * normalizeScheme('https://') // => 'https'
+ * ```
+ */
+function normalizeScheme(value: string): string {
+ return value.replace(/[:]+$/, '').toLowerCase();
+}
diff --git a/shared/utils/src/lru-map.ts b/shared/utils/src/lru-map.ts
new file mode 100644
index 0000000..79eb41c
--- /dev/null
+++ b/shared/utils/src/lru-map.ts
@@ -0,0 +1,60 @@
+/**
+ * LRU Map implementation storing key/values up to a provided size limit. Beyond that
+ * size limit, the least recently used entry is evicted.
+ *
+ * @see https://github.pie.apple.com/isao/lru-map
+ */
+export class LruMap<K, V> extends Map<K, V> {
+ private sizeLimit: number;
+
+ constructor(sizeLimit: number) {
+ super();
+ this.setSizeLimit(sizeLimit);
+ // Needed to convince TS that this is set (it's actually handled by setSizeLimit)
+ this.sizeLimit = sizeLimit;
+ }
+
+ /**
+ * Retrieve a value from the map with a given key.
+ * @param key The key for the entry
+ * @return value The entry's value (or undefined if non existent)
+ */
+ get(key: K): V | undefined {
+ let value: V | undefined;
+
+ if (this.has(key)) {
+ value = super.get(key);
+
+ // Map entries are always in insertion order. So
+ // readding, pushes this entry to the top of the LRU.
+ this.delete(key);
+ super.set(key, value!);
+ }
+
+ return value;
+ }
+
+ set(key: K, value: V): this {
+ super.set(key, value);
+ this.prune();
+ return this;
+ }
+
+ setSizeLimit(newSizeLimit: number): void {
+ if (newSizeLimit < 0 || !isFinite(newSizeLimit)) {
+ throw new Error(
+ `setSizeLimit expects finite positive number, got: ${newSizeLimit}`,
+ );
+ }
+
+ this.sizeLimit = newSizeLimit;
+ this.prune();
+ }
+
+ private prune(): void {
+ while (this.size > this.sizeLimit) {
+ const leastRecentlyUsedKey = this.keys().next().value;
+ this.delete(leastRecentlyUsedKey);
+ }
+ }
+}
diff --git a/shared/utils/src/object-from-entries.ts b/shared/utils/src/object-from-entries.ts
new file mode 100644
index 0000000..80d3cdb
--- /dev/null
+++ b/shared/utils/src/object-from-entries.ts
@@ -0,0 +1,18 @@
+// TODO: rdar://78109780 (Update to Node 16)
+/**
+ * Create an object from an iterable of key/value pairs.
+ *
+ * @param entries The key value pairs (ex. [['a', 1], ['b', 2]])
+ * @return The created object
+ */
+export function fromEntries<V>(entries: Iterable<readonly [PropertyKey, V]>): {
+ [k: string]: V;
+} {
+ const result: Record<PropertyKey, V> = {};
+
+ for (const [key, value] of entries) {
+ result[key] = value;
+ }
+
+ return result;
+}
diff --git a/shared/utils/src/optional.ts b/shared/utils/src/optional.ts
new file mode 100644
index 0000000..7058803
--- /dev/null
+++ b/shared/utils/src/optional.ts
@@ -0,0 +1,22 @@
+export type Optional<T> = T | None;
+export type None = null | undefined;
+
+/**
+ * Determine if an optional value is present.
+ *
+ * @param optional value
+ * @return true if present, false otherwise
+ */
+export function isSome<T>(optional: Optional<T>): optional is T {
+ return optional !== null && optional !== undefined;
+}
+
+/**
+ * Determine if an optional value is not present.
+ *
+ * @param optional value
+ * @return true if not present, false otherwise
+ */
+export function isNone<T>(optional: Optional<T>): optional is None {
+ return optional === null || optional === undefined;
+}
diff --git a/shared/utils/src/platform.ts b/shared/utils/src/platform.ts
new file mode 100644
index 0000000..15644e5
--- /dev/null
+++ b/shared/utils/src/platform.ts
@@ -0,0 +1,249 @@
+import {
+ parseUserAgent,
+ flagsExtension,
+ compareExtension,
+} from '@amp/runtime-detect';
+import { launchClient, type LaunchCallback } from './launch/launch-client';
+
+type NavigatorLike = {
+ userAgent: string;
+ maxTouchPoints?: number;
+};
+
+/**
+ * Detect a Platform descriptor from the browsers user agent.
+ */
+function detectDescriptor(options?: {
+ window?: Window;
+ navigator?: NavigatorLike;
+}) {
+ const defaultNavigator: NavigatorLike =
+ typeof options?.window?.navigator !== 'undefined'
+ ? options.window.navigator
+ : {
+ userAgent: '',
+ maxTouchPoints: 0,
+ };
+
+ return parseUserAgent(options?.navigator ?? defaultNavigator, {
+ extensions: [flagsExtension, compareExtension],
+ });
+}
+
+export type PlatformDescriptor = ReturnType<typeof detectDescriptor>;
+
+export class Platform {
+ static detect(
+ this: typeof Platform,
+ options?: { window?: Window; navigator?: NavigatorLike },
+ ) {
+ const window = options?.window ?? globalThis?.window;
+ return new this({
+ window: window,
+ descriptor: detectDescriptor({
+ window: window,
+ navigator: options?.navigator,
+ }),
+ });
+ }
+
+ /**
+ * Descriptor from detecting platform data.
+ */
+ readonly descriptor: PlatformDescriptor;
+
+ /**
+ * Navigator value used to create the platform descriptor.
+ */
+ readonly navigator: NavigatorLike;
+
+ /**
+ * Reference to the platform Window object. This might be `undefined` in some
+ * environments.
+ */
+ readonly window: Window | undefined;
+
+ /**
+ * User Agent string the platform descriptor was parsed from.
+ */
+ readonly ua: string;
+
+ /**
+ * Browser descriptor for the Platform.
+ */
+ readonly browser: PlatformDescriptor['browser'];
+
+ /**
+ * Browser Engine descriptor for the Platform.
+ */
+ readonly engine: PlatformDescriptor['engine'];
+
+ /**
+ * Operating System descriptor for the Platform.
+ */
+ readonly os: PlatformDescriptor['os'];
+
+ constructor(config: {
+ descriptor: PlatformDescriptor;
+ window?: Window;
+ navigator?: NavigatorLike;
+ }) {
+ const { descriptor } = config;
+ this.descriptor = descriptor;
+ this.navigator = config.navigator ?? descriptor.navigator;
+ this.window = config.window;
+
+ this.ua = descriptor.ua;
+ this.browser = descriptor.browser;
+ this.engine = descriptor.engine;
+ this.os = descriptor.os;
+ }
+
+ /**
+ * Check if Apple native applications can be opened on the Platform.
+ */
+ canOpenNative(): boolean {
+ return this.ismacOS() || this.isiOS();
+ }
+
+ /**
+ * Check if the Platform is running a mobile browser.
+ */
+ isMobile(): boolean {
+ return this.browser.isMobile;
+ }
+
+ /**
+ * Check if the Platform registers as running the Android operating system.
+ */
+ isAndroid(): boolean {
+ return this.os.isAndroid;
+ }
+
+ /**
+ * Check if the Platform registers as running the iOS operating system.
+ */
+ isiOS(): boolean {
+ return this.os.isIOS;
+ }
+
+ /**
+ * Check if the Platform registers as running the iPadOS operating system.
+ */
+ isiPadOS(): boolean {
+ return this.os.isIPadOS;
+ }
+
+ /**
+ * Check if the Platform registers as running the macOS operating system.
+ */
+ ismacOS(): boolean {
+ return this.os.isMacOS;
+ }
+
+ /**
+ * Check if the Platform registers as running the Windows operating system.
+ */
+ isWindows(): boolean {
+ return this.os.isWindows;
+ }
+
+ /**
+ * Check if the Platform registers as running a Linux operating system.
+ */
+ isLinux(): boolean {
+ return this.os.isLinux;
+ }
+
+ /**
+ * Check if the Platform is running the Apple Safari browser.
+ */
+ isSafari(): boolean {
+ return this.browser.isSafari;
+ }
+
+ /**
+ * Check if the Platform is running the Google Chrome browser.
+ */
+ isChrome(): boolean {
+ return this.browser.isChrome;
+ }
+
+ /**
+ * Check if the Platform is running the Mozilla Firefox browser.
+ */
+ isFirefox(): boolean {
+ return this.browser.isFirefox;
+ }
+
+ /**
+ * Check if the Platform is running the Microsoft Edge browser.
+ */
+ isEdge(): boolean {
+ return this.browser.isEdge;
+ }
+
+ /**
+ * Get name for the Platform browser.
+ * @deprecated Use `platform.browser.name` directly
+ */
+ clientName(): string {
+ return this.browser.name[0].toUpperCase() + this.browser.name.slice(1);
+ }
+
+ /**
+ * Get the Platform browser major version number.
+ * @deprecated Use `platform.browser.major` directly
+ */
+ majorVersion(): number {
+ return this.browser.major ?? 0;
+ }
+
+ /**
+ * Get the Platform browser minor version number.
+ * @deprecated Use `platform.browser.minor` directly
+ */
+ minorVersion(): number {
+ return this.browser.minor ?? 0;
+ }
+
+ /**
+ * Get the name for the Platform operating system.
+ * @deprecated Use `platform.os.name` directly
+ */
+ osName(): string {
+ return this.os.name;
+ }
+
+ /**
+ * Attempt to launch a native client for the given web URL.
+ *
+ * The callback is called with a report if the attempt was successful.
+ *
+ * @example
+ * ```javascript
+ * platform.launchClient(
+ * 'https://music.apple.com/browse',
+ * function ({ link, success }) {
+ * if (success) {
+ * console.log(`Opened client with ${link}`);
+ * } else {
+ * console.log(`Failed to open client with ${link}`);
+ * }
+ * }
+ * );
+ * ```
+ */
+ launchClient(url: string, callback?: LaunchCallback): void {
+ launchClient(url, this, callback);
+ }
+
+ /**
+ * Check if the platform has full support for playing encrypted HLS content.
+ */
+ hasEncryptedPlaybackSupport(): boolean {
+ return !this.os.isIOS || this.os.gte('17.5');
+ }
+}
+
+export const platform = Platform.detect();
diff --git a/shared/utils/src/try-scroll.ts b/shared/utils/src/try-scroll.ts
new file mode 100644
index 0000000..1e6b0d2
--- /dev/null
+++ b/shared/utils/src/try-scroll.ts
@@ -0,0 +1,65 @@
+import type { Logger } from '@amp/web-apps-logger';
+export interface ScrollableElement {
+ scrollTop: number;
+ scrollHeight: number;
+ offsetHeight: number;
+}
+
+// Global is okay here as this only runs in the browser
+let nextTry: number | null = null;
+
+export function tryScroll(
+ log: Logger,
+ getScrollablePageElement: Function,
+ scrollY: number,
+): void {
+ let tries = 0;
+
+ if (nextTry !== null) {
+ window.cancelAnimationFrame(nextTry);
+ }
+
+ nextTry = window.requestAnimationFrame(function doNextTry() {
+ // At 16ms per frame, this is 1600ms
+ // See: https://github.com/DockYard/ember-router-scroll/blob/2f17728f/addon/services/router-scroll.js#L56
+ if (++tries >= 100) {
+ log.warn("wasn't able to restore scroll within 100 frames");
+ nextTry = null;
+ return;
+ }
+
+ let element = getScrollablePageElement();
+ if (!element) {
+ log.warn(
+ 'could not restore scroll: the scrollable element is missing',
+ );
+ return;
+ }
+ const { scrollHeight, offsetHeight } = element;
+
+ // Only scroll once we're able to get a full screen of content when
+ // scrollTop is set to scrollY
+ //
+ // +16 is a bit of a fudge factor to count for imperfections in
+ // features like lazy loading. If the scroll position to restore is
+ // the very bottom of the page, then scrollY + offsetHeight must be
+ // exactly scrollHeight. But if lazy loading components (for example)
+ // cause the page to grow by a few pixels, then this will never hold.
+ // Thus, we fudge by a few pixels to be more forgiving in this scenario.
+ const canScroll = scrollY + offsetHeight <= scrollHeight + 16;
+
+ if (!canScroll) {
+ log.info('page is not tall enough for scroll yet', {
+ scrollHeight,
+ offsetHeight,
+ });
+
+ nextTry = window.requestAnimationFrame(doNextTry);
+ return;
+ }
+
+ element.scrollTop = scrollY;
+ log.info('scroll restored to', scrollY);
+ nextTry = null;
+ });
+}
diff --git a/shared/utils/src/url.ts b/shared/utils/src/url.ts
new file mode 100644
index 0000000..f15792d
--- /dev/null
+++ b/shared/utils/src/url.ts
@@ -0,0 +1,90 @@
+/**
+ * Remove the scheme and separators from the given URL.
+ *
+ * @example
+ * ```javascript
+ * removeScheme('https://music.apple.com/browse') // => 'music.apple.com/browse'
+ * removeScheme('apple-music://music.apple.com/browse') // => 'music.apple.com/browse'
+ * removeScheme('music.apple.com/browse') // => 'music.apple.com/browse'
+ * ```
+ */
+export function removeScheme(
+ url: string | URL | null | undefined,
+): string | undefined {
+ if (url === null || url === undefined) {
+ return undefined;
+ }
+
+ return String(url).replace(/^((?:[^:]*:[/]{0,2})|(?::?\/\/))/i, '');
+}
+
+/**
+ * Strip scheme and host (hostname + port) from a URL, leaving just the path, query
+ * params, and hash.
+ *
+ * @param {string} url The URL possibly containing a host
+ * @returns {string} hostlessUrl The url without its host
+ */
+export function removeHost(
+ url: string | URL | null | undefined,
+): string | undefined {
+ return removeScheme(url)?.replace(/^([^/]*)/i, '');
+}
+
+/**
+ * Strip query params and fragment from a URL.
+ */
+export function removeQueryParams(
+ url: string | URL | undefined,
+): string | undefined {
+ if (url === undefined) {
+ return undefined;
+ }
+
+ const value = String(url);
+ const splitIndex = value.indexOf('?');
+ return splitIndex >= 0 ? value.slice(0, splitIndex) : value;
+}
+
+export function getBaseUrl(): string {
+ const currentUrl = new URL(window.location.href);
+ return `${currentUrl.protocol}//${currentUrl.host}`;
+}
+
+export function buildUrl(props: {
+ protocol?: string;
+ hostname: string;
+ pathname?: string | string[];
+ queryParams?: string | Record<string, string>;
+ hash?: string;
+}): URL {
+ const {
+ hostname,
+ pathname = '/',
+ queryParams = {},
+ protocol = 'https',
+ hash = '',
+ } = props;
+
+ // Base URL with domain
+ const url = new URL(protocol + '://' + removeScheme(hostname));
+
+ // URL path
+ url.pathname = Array.isArray(pathname)
+ ? '/' + pathname.map(encodeURIComponent).join('/').replace(/[/]+/, '/')
+ : pathname;
+
+ // URL search (a.k.a. queryParams)
+ if (typeof queryParams === 'string') {
+ url.search = queryParams;
+ } else {
+ for (const [key, value] of Object.entries(queryParams)) {
+ url.searchParams.set(key, value);
+ }
+ }
+
+ // URL hash
+ url.hash = hash;
+
+ return url;
+}
diff --git a/shared/utils/src/uuid.ts b/shared/utils/src/uuid.ts
new file mode 100644
index 0000000..0afa5ee
--- /dev/null
+++ b/shared/utils/src/uuid.ts
@@ -0,0 +1,22 @@
+/**
+ * Generate a variant 1 UUIDv4.
+ *
+ * @return the UUID
+ */
+export function generateUuid(): string {
+ return 'xxxxxxxx-xxxx-4xxx-Vxxx-xxxxxxxxxxxx'.replace(
+ /[xV]/g,
+ (placeholder) => {
+ let nibble = (Math.random() * 16) | 0;
+
+ if (placeholder === 'V') {
+ // Per RFC, the two MSB of byte 8 must be 0b10 (0x8).
+ // 0x3 (0b11) masks out the bottom two bits.
+ // See: https://tools.ietf.org/html/rfc4122.html#section-4.1.1
+ nibble = (nibble & 0x3) | 0x8;
+ }
+
+ return nibble.toString(16);
+ },
+ );
+}