forked from off-topic/apps.apple.com
init commit
This commit is contained in:
57
shared/apps-common/src/jet/dependencies/host.ts
Normal file
57
shared/apps-common/src/jet/dependencies/host.ts
Normal file
@@ -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`,
|
||||
);
|
||||
}
|
||||
18
shared/apps-common/src/jet/dependencies/random.ts
Normal file
18
shared/apps-common/src/jet/dependencies/random.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
118
shared/apps-common/src/jet/prefetched-intents/index.ts
Normal file
118
shared/apps-common/src/jet/prefetched-intents/index.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
109
shared/apps-common/src/jet/prefetched-intents/server-data.ts
Normal file
109
shared/apps-common/src/jet/prefetched-intents/server-data.ts
Normal file
@@ -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);
|
||||
}
|
||||
27
shared/apps-common/src/jet/prefetched-intents/types.ts
Normal file
27
shared/apps-common/src/jet/prefetched-intents/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user