summaryrefslogtreecommitdiff
path: root/shared/apps-common/src
diff options
context:
space:
mode:
Diffstat (limited to 'shared/apps-common/src')
-rw-r--r--shared/apps-common/src/jet/dependencies/host.ts57
-rw-r--r--shared/apps-common/src/jet/dependencies/random.ts18
-rw-r--r--shared/apps-common/src/jet/prefetched-intents/get-prefetched-intents.ts58
-rw-r--r--shared/apps-common/src/jet/prefetched-intents/index.ts118
-rw-r--r--shared/apps-common/src/jet/prefetched-intents/server-data.ts109
-rw-r--r--shared/apps-common/src/jet/prefetched-intents/types.ts27
6 files changed, 387 insertions, 0 deletions
diff --git a/shared/apps-common/src/jet/dependencies/host.ts b/shared/apps-common/src/jet/dependencies/host.ts
new file mode 100644
index 0000000..85a03f0
--- /dev/null
+++ b/shared/apps-common/src/jet/dependencies/host.ts
@@ -0,0 +1,57 @@
+import type {
+ ClientIdentifier,
+ Host as NativeHost,
+ ProcessPlatform,
+} from '@jet/environment';
+import type {} from '@jet/engine'; // For ClientIdentifier.Unknown
+
+export class Host implements NativeHost {
+ platform: ProcessPlatform = 'web';
+
+ get osBuild(): never {
+ throw makeWebDoesNotImplementException('osBuild');
+ }
+
+ get deviceModel(): string {
+ return 'web';
+ }
+
+ get devicePhysicalModel(): never {
+ throw makeWebDoesNotImplementException('devicePhysicalModel');
+ }
+
+ get deviceLocalizedModel() {
+ return '';
+ }
+
+ get deviceModelFamily(): never {
+ throw makeWebDoesNotImplementException('deviceModelFamily');
+ }
+
+ get clientIdentifier(): ClientIdentifier {
+ // We can't directly use the `ClientIdentifier.Unknown` enum member value
+ // because we cannot access "ambient const enums" with our TypeScript config.
+ // Enum handling is known to be tough in TypeScript and, for reasons like
+ // this, they are generally avoided.
+ // This returns a value defined on this enum by `@jet/engine`'s type definition
+ return 'unknown' as ClientIdentifier.Unknown;
+ }
+
+ get clientVersion(): never {
+ throw makeWebDoesNotImplementException('clientVersion');
+ }
+
+ isOSAtLeast(
+ _majorVersion: number,
+ _minorVersion: number,
+ _patchVersion: number,
+ ): boolean {
+ return true;
+ }
+}
+
+export function makeWebDoesNotImplementException(property: keyof NativeHost) {
+ return new Error(
+ `\`Host\` property \`${property}\` is not implemented for the "web" platform`,
+ );
+}
diff --git a/shared/apps-common/src/jet/dependencies/random.ts b/shared/apps-common/src/jet/dependencies/random.ts
new file mode 100644
index 0000000..d976879
--- /dev/null
+++ b/shared/apps-common/src/jet/dependencies/random.ts
@@ -0,0 +1,18 @@
+import type { Random as IRandom } from '@jet/environment';
+import { generateUuid } from '@amp/web-apps-utils';
+
+export class Random implements IRandom {
+ nextBoolean(): boolean {
+ // See: https://stashweb.sd.apple.com/projects/AS/repos/jet-infrastructure/browse/Frameworks/JetEngine/JetEngine/JavaScript/Stack/Native%20APIs/JSRandomObject.swift?at=e90a88fa061f5cb6b9536d29a7ffd67e5db942db#41
+ return Math.random() < 0.5;
+ }
+
+ nextNumber(): number {
+ // See: https://stashweb.sd.apple.com/projects/AS/repos/jet-infrastructure/browse/Frameworks/JetEngine/JetEngine/JavaScript/Stack/Native%20APIs/JSRandomObject.swift?at=e90a88fa061f5cb6b9536d29a7ffd67e5db942db#45
+ return Math.random();
+ }
+
+ nextUUID(): string {
+ return generateUuid();
+ }
+}
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<string, unknown> {
+ 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<string, unknown>;
+
+ private constructor(intents: Map<string, unknown>) {
+ this.intents = intents;
+ }
+
+ get<I extends Intent<unknown>>(intent: I): IntentReturnType<I> | 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<I>. 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<I> | 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 <script> tag that can be read clientside by
+ * `deserializeServerData`.
+ *
+ * Use this to share data between serverside and clientside. Include the
+ * returned HTML in the response to a client to allow it to read this data.
+ *
+ * @param data data to serialize
+ * @returns serialized data (or empty string if serialization fails)
+ */
+export function serializeServerData(data: object): string {
+ try {
+ const sanitizedData = JSON.stringify(data).replace(
+ pattern,
+ (match) => replacements[match as keyof typeof replacements],
+ );
+ return `<script type="application/json" id="${SERVER_DATA_ID}">${sanitizedData}</script>`;
+ } 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<JSON['parse']> | 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<unknown>;
+ 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<unknown> {
+ return v !== null && typeof v === 'object' && '$kind' in v;
+}