From bce557cc2dc767628bed6aac87301a1be7c5431b Mon Sep 17 00:00:00 2001 From: rxliuli Date: Tue, 4 Nov 2025 05:03:50 +0800 Subject: init commit --- .../prefetched-intents/get-prefetched-intents.ts | 58 ++++++++++ .../src/jet/prefetched-intents/index.ts | 118 +++++++++++++++++++++ .../src/jet/prefetched-intents/server-data.ts | 109 +++++++++++++++++++ .../src/jet/prefetched-intents/types.ts | 27 +++++ 4 files changed, 312 insertions(+) create mode 100644 shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/index.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/server-data.ts create mode 100644 shared/apps-common/src/jet/prefetched-intents/types.ts (limited to 'shared/apps-common/src/jet/prefetched-intents') diff --git a/shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts b/shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts new file mode 100644 index 0000000..4d59186 --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts @@ -0,0 +1,58 @@ +import { getCookie } from '@amp/web-app-components/src/utils/cookie'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import { isSome } from '@amp/web-apps-utils'; +import { deserializeServerData, stableStringify } from './server-data'; +import { type PrefetchedIntent, isPrefetchedIntents } from './types'; + +export function getPrefetchedIntents( + loggerFactory: LoggerFactory, + options?: { evenIfSignedIn?: boolean; featureKitItfe?: string }, +): Map { + const logger = loggerFactory.loggerFor('getPrefetchedIntents'); + const evenIfSignedIn = options?.evenIfSignedIn; + const itfe = options?.featureKitItfe; + + const data = deserializeServerData(); + if (!data || !isPrefetchedIntents(data)) { + return new Map(); + } + + // We avoid prefetched intents in two scenarios: + // + // Condition 1: User is signed in (and evenIfSignedIn is false) + // It's possible/likely that dispatching an intent when signed in behaves + // differently. + // + // Condition 2: ITFE is enabled in Feature Kit + // When ITFE is active, we discard prefetched intents so that media API + // calls are triggered in the browser, allowing Feature Kit to inject ITFE + // into those calls. + if ((!evenIfSignedIn && getCookie('media-user-token')) || itfe) { + logger.info( + 'Discarding prefetched intents - signed in user or ITFE enabled', + ); + return new Map(); + } + + logger.debug('received prefetched intents from the server:', data); + return new Map( + data + .map( + ({ + intent, + data, + }: PrefetchedIntent): [string, unknown] | null => { + try { + if (intent.$kind.includes('Library')) { + return null; + } + // NOTE: PrefetchedIntents.get depends on stableStringify + return [stableStringify(intent), data]; + } catch (e) { + return null; + } + }, + ) + .filter(isSome), + ); +} diff --git a/shared/apps-common/src/jet/prefetched-intents/index.ts b/shared/apps-common/src/jet/prefetched-intents/index.ts new file mode 100644 index 0000000..dd5d393 --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/index.ts @@ -0,0 +1,118 @@ +import type { LoggerFactory } from '@amp/web-apps-logger'; +import type { Intent, IntentReturnType } from '@jet/environment/dispatching'; +import { serializeServerData, stableStringify } from './server-data'; +import type { PrefetchedIntent } from './types'; +import { getPrefetchedIntents } from './get-prefetched-intents'; + +export type { PrefetchedIntent } from './types'; + +export function serializePrefetchedIntents( + loggerFactory: LoggerFactory, + prefetchedIntents: PrefetchedIntent[], +): string { + const serialized = serializeServerData( + prefetchedIntents.map(removeSeoData), + ); + + if (serialized.length === 0) { + const logger = loggerFactory.loggerFor('serializePrefetchedIntents'); + logger.warn('failed to serialize prefetched intents'); + } + + return serialized; +} + +// SEO data is never needed for the first clientside render since the server +// already adds SEO tags. The seoData convention is ubiquitous across the apps. +// See: rdar://144581413 (Etag constantly changes on pages with songs due to seoData.ogSongs) +function removeSeoData(intent: PrefetchedIntent): PrefetchedIntent { + const { data } = intent; + + // We very intentionally return the original intent to prevent + // needlessly allocating new objects. + + if (data === null || typeof data !== 'object' || !('seoData' in data)) { + return intent; + } + + const { seoData } = data; + if (seoData === null || typeof seoData !== 'object') { + return intent; + } + + let partialSeoData: + | { pageTitle?: unknown; titleHeader?: unknown } + | undefined = undefined; + if ('pageTitle' in seoData || 'titleHeader' in seoData) { + partialSeoData = {}; + + if ('pageTitle' in seoData) { + partialSeoData['pageTitle'] = seoData.pageTitle; + } + + if ('titleHeader' in seoData) { + partialSeoData['titleHeader'] = seoData.titleHeader; + } + } + + // Only if we're actually going to do the removal do we spread + return { + ...intent, + data: { + ...data, + // Page title is desirable to keep as it is occasionally consulted + // outside of MetaTags.svelte + seoData: partialSeoData, + }, + }; +} + +export class PrefetchedIntents { + static empty(): PrefetchedIntents { + return new PrefetchedIntents(new Map()); + } + + static fromDom( + loggerFactory: LoggerFactory, + options?: { evenIfSignedIn?: boolean; featureKitItfe?: string }, + ): PrefetchedIntents { + return new PrefetchedIntents( + getPrefetchedIntents(loggerFactory, options), + ); + } + + private intents: Map; + + private constructor(intents: Map) { + this.intents = intents; + } + + get>(intent: I): IntentReturnType | undefined { + if (this.intents.size === 0) { + return; + } + + let subject: string | void; + try { + subject = stableStringify(intent); + } catch (e) { + // It's possible the intents don't stringify. If that's that case, + // then we won't find it in this.intents, since the keys of that + // are successfully stringified intents. We could try something + // sophisticated here, but it's probably not worth it as most + // intents will serialize. + return; + } + + const data = this.intents.get(subject); + + // Remove the prefetched data so that it can only be used once + this.intents.delete(subject); + + // NOTE: There really isn't a good way to be safe with types here. We + // don't have a type guard for arbitrary IntentReturnType. We just + // have to trust that the serialized data is of the correct type. This + // isn't unreasonable since we control serialization. + return data as unknown as IntentReturnType | undefined; + } +} diff --git a/shared/apps-common/src/jet/prefetched-intents/server-data.ts b/shared/apps-common/src/jet/prefetched-intents/server-data.ts new file mode 100644 index 0000000..fba215c --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/server-data.ts @@ -0,0 +1,109 @@ +import { isPOJO } from '@amp/web-apps-utils'; + +// NOTE: be careful with imports here. This file is imported by browser code, +// so we expect tree shaking to only keep these functions. + +const SERVER_DATA_ID = 'serialized-server-data'; + +// Take care with < (which has special meaning inside script tags) +// See: https://github.com/sveltejs/kit/blob/ff9a27b4/packages/kit/src/runtime/server/page/serialize_data.js#L4-L28 +const replacements = { + '<': '\\u003C', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g'); + +/** + * Serializes a POJO into a HTML `; + } catch (e) { + // Don't let recursive data (or other non-serializable things) throw. + // We'd rather just let the serialize no-op to avoid breaking consumers. + return ''; + } +} + +/** + * Deserializes data serialized on the server by `serializeServerData`. + * + * @returns deserialized data (or undefined if it doesn't exist/errors) + */ +export function deserializeServerData(): ReturnType | undefined { + const script = document.getElementById(SERVER_DATA_ID); + if (!script) { + return; + } + + script.parentNode?.removeChild(script); + + try { + return JSON.parse(script.textContent || ''); + } catch (e) { + // If the content is malformed, we want to avoid throwing. This + // situation should be impossible since we control the serialization + // above. + return; + } +} + +/** + * JSON stringify a POJO value in a stable manner. Specifically, this means that + * objects which are structurally equal serialize to the same string. + * + * This is useful when comparing objects serialized by a server against objects + * build in browser. With plain JSON.stringify(), property order matters and is + * not guaranteed to be the same. In other words these two objects would + * JSON.stringify() differently: + * + * { a: 1, b: 2 } + * { b: 2, a: 1 } + * + * But these are structurally equal--they have the same keys and values. + * + * The expected use case for this function is generating keys for a Map for + * objects from a server that will be compared against objects from the browser. + * This function should be used on objects returned from `deserializeServerData` + * before they are used in such contexts. + * + * See: https://stackoverflow.com/a/43049877 + */ +export function stableStringify(data: unknown): string { + if (Array.isArray(data)) { + const items = data.map(stableStringify).join(','); + return `[${items}]`; + } + + // Sort object keys before serializing + if (isPOJO(data)) { + const keys = [...Object.keys(data)]; + keys.sort(); + + const properties = keys + // undefined values should not get included in stringification + .filter((key) => typeof data[key] !== 'undefined') + .map( + (key) => `${JSON.stringify(key)}:${stableStringify(data[key])}`, + ) + .join(','); + + return `{${properties}}`; + } + + return JSON.stringify(data); +} diff --git a/shared/apps-common/src/jet/prefetched-intents/types.ts b/shared/apps-common/src/jet/prefetched-intents/types.ts new file mode 100644 index 0000000..b44a14b --- /dev/null +++ b/shared/apps-common/src/jet/prefetched-intents/types.ts @@ -0,0 +1,27 @@ +import type { Intent } from '@jet/environment/dispatching'; + +export interface PrefetchedIntent { + intent: Intent; + data: unknown; +} + +export function isPrefetchedIntents(v: unknown): v is PrefetchedIntent[] { + return Array.isArray(v) && v.every(isPrefetchedIntent); +} + +function isPrefetchedIntent(v: unknown): v is PrefetchedIntent { + return hasIntentAndData(v) && isIntent(v.intent); +} + +function hasIntentAndData(v: unknown): v is HasIntentAndData { + return v !== null && typeof v === 'object' && 'intent' in v && 'data' in v; +} + +interface HasIntentAndData { + intent: unknown; + data: unknown; +} + +function isIntent(v: unknown): v is Intent { + return v !== null && typeof v === 'object' && '$kind' in v; +} -- cgit v1.2.3