init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
// Browser ONLY logic. Must have the same exports as server.ts
// See: docs/isomorphic-imports.md
import type { Dependencies } from './types';
import { registerHandler as registerFlowActionHandler } from '~/jet/action-handlers/flow-action';
import { registerHandler as registerExternalURLActionHandler } from '~/jet/action-handlers/external-url-action';
import { registerHandler as registerCompoundActionHandler } from '~/jet/action-handlers/compound-action';
export type { Dependencies };
export function registerActionHandlers(dependencies: Dependencies) {
registerCompoundActionHandler(dependencies);
registerFlowActionHandler(dependencies);
registerExternalURLActionHandler(dependencies);
}

View File

@@ -0,0 +1,33 @@
import type { LoggerFactory } from '@amp/web-apps-logger';
import type { Jet } from '~/jet';
import type { CompoundAction } from '~/jet/models';
export type Dependencies = {
jet: Jet;
logger: LoggerFactory;
};
export async function registerHandler(dependencies: Dependencies) {
const { jet, logger } = dependencies;
const log = logger.loggerFor('jet/action-handlers/compound-action');
jet.onAction('compoundAction', async (action: CompoundAction) => {
log.info('received CompoundAction:', action);
const { subactions = [] } = action;
// Perform actions in sequence
for (const action of subactions) {
await jet.perform(action).catch((e) => {
// Throwing error stops for...of execution
// TODO: rdar://73165545 (Error Handling Across App)
throw new Error(
`an error occurred while handling CompoundAction: ${e}`,
);
});
}
return 'performed';
});
}

View File

@@ -0,0 +1,19 @@
import type { Jet } from '~/jet';
import type { LoggerFactory } from '@amp/web-apps-logger';
import type { ExternalUrlAction } from '@jet-app/app-store/api/models';
export type Dependencies = {
jet: Jet;
logger: LoggerFactory;
};
export function registerHandler(dependencies: Dependencies) {
const { jet, logger } = dependencies;
const log = logger.loggerFor('jet/action-handlers/external-url-action');
jet.onAction('ExternalUrlAction', async (action: ExternalUrlAction) => {
log.info('received external URL action:', action);
return 'performed';
});
}

View File

@@ -0,0 +1,369 @@
import { isNothing, unwrapOptional } from '@jet/environment';
import type { Intent } from '@jet/environment/dispatching';
import type { LoggerFactory } from '@amp/web-apps-logger';
import { History } from '@amp/web-apps-utils';
import type { FlowAction } from '@jet-app/app-store/api/models';
import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent';
import { isChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent';
import type { Jet } from '~/jet';
import { type Page, assertIsPage, FLOW_ACTION_KIND } from '~/jet/models';
import { mapException } from '~/utils/error';
import { stripHost } from '~/utils/url';
import type { ComponentProps } from 'svelte';
import type AppComponent from '~/App.svelte';
import { handleModalPresentation } from '~/jet/utils/handle-modal-presentation';
import { addRejectedIntent } from '../utils/error-metadata';
type AppComponentProps = Partial<ComponentProps<AppComponent>>;
// This action handler is responsible for all routing and related state
// management.
//
// Take care when making modifications here. There are many subtle invariants
// that must be maintained. They should be documented in comments throughout.
// It might be best to read the whole file to understand this full context
// before attempting even a small fix.
//
// High level overview:
//
// There are two ways for routing state changes to arise in the app:
//
// 1. Direct user interaction with the app (a FlowAction)
// 2. Indirect user interaction via browser back/forward buttons (popstate)
//
// FlowAction is the bedrock of navigation in the app. Anytime the user interacts
// with a button, link, etc. a FlowAction is performed (Jet.perform). When that
// happens, the Jet runtime eventually invokes the handler in this file
// (see jet.onAction below) to change the state of the app.
//
// This file manages the browser history and thus has the dual responsibility
// of handling state changes that come from the back and forward buttons. The
// state stored off when handling a FlowAction is later used by the popstate
// handler to navigate backwards without needing to re-fetch the previous page.
//
// Take note that these two processes are coupled fairly tightly due to the
// popstate needing data from the previous navigation. This is stored in the
// State interface. Take care when updating one flow that a modification is
// likely needed in the other.
//
// At the end of both of these processes, a call to updateApp is made. This
// changes the view model passed down to the top level <App> component. As a
// result of Svelte's reactivity, this could result in the entire page changing
// or just a part of it being amended to or removed. Additionally, the `page`
// passed in (the view model) can also be a promise. In which case, <App> will
// await it and display a loading spinner until it resolves or rejects.
//
// Notable specifics:
//
// Handling a FlowAction roughly has the following steps:
//
// 1. Extract a "destination" intent from the FlowAction. Recall that Jet
// actions communicate a user interaction, but return no value. Jet
// intents can be contained within an action and return data. In this case,
// the intent derived from a FlowAction is used to retrieve the data for
// the new page to which the FlowAction sends the user.
//
// 2. Dispatch the "destination" intent. Here, we resolve the Promise when
// the page is ready, but we'll resolve early with an unresolve page
// promise after 500ms. We take advantage of that the fact that passing a
// Promise to updateApp will show a loading spinner. We wait 500ms,
// because we don't want to immediately show a loading spinner or change
// the page.
//
// 3. Update current page state in the history (ex. scroll position) and then
// push a new history state for the page we're about to display. Note that
// this must be done after the page Promise resolves, because we need to
// store the page view model itself and we only know the canonicalURL of
// it once it resolves. This state is used by popstate to return to the
// page should the user ever leave and then come back to it.
//
// 4. Call updateApp to change the UI presented. At this point, it could be a
// completed page (in which case step 3 will have already happened). The
// app will display the new page immediately. Or, it could still be a
// Promise (in which case step 3 will happen once it resolves and then the
// page will resolve). The <App> will display a loading spinner until this
// resolution happens.
//
// Handling a popstate event follows a similar pattern, but has some additional
// complexity.
//
// The simple case is that the state that we stored off above in step 3 is
// available. In which case, returning to the old page only involves calling
// updateApp with the view model we stored.
//
// But, we don't want to store an infinite history as these view models are
// sizable. We limit history to an arbitrary depth. After the user has
// navigated beyond that depth, we forget the oldest states. If a user ever
// were to back button all the way back to them, there would be no view model
// to restore. But, we do have the URL, so we use that and pretend like we're
// deeplinking into the app again for the first time. Care must be taken here
// to not perform a FlowAction, since that would modify the history. popstate
// events have already modified the browser history to point to the desired
// new state. So, we manually dispatch the page intent and perform other
// actions (such as switching the selected tab) ourselves. We then use the page
// promise as above to call updateApp.
export type Dependencies = {
jet: Jet;
logger: LoggerFactory;
updateApp: (props: AppComponentProps) => void;
};
interface State {
page: Page;
}
export function registerHandler(dependencies: Dependencies) {
const { jet, logger, updateApp } = dependencies;
const history = new History<State>(logger, {
getScrollablePageElement() {
return (
document.getElementById('scrollable-page-override') ||
document.getElementById('scrollable-page') ||
// If we haven't defined a specific scrollable element,
// scroll the whole page
document.getElementsByTagName('html')?.[0]
);
},
});
const log = logger.loggerFor('jet/action-handlers/flow-action');
let isFirstPage = true;
jet.onAction(FLOW_ACTION_KIND, async (action: FlowAction) => {
log.info('received FlowAction:', action);
// timer for request time start
// TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
// const pageSpeedMetric = perfkit.makeNewPageSpeedMetric();
// pageSpeedMetric.capturePageRequestTime();
let intent: Intent<unknown>;
try {
intent = unwrapOptional(action.destination);
} catch (e) {
log.info(
'`FlowAction` received without a destination `Intent`: update the Jet app to attach an `Intent` to this `FlowAction`',
);
return;
}
// If the destination `Intent` must be performed server-side, determine
// the destination URL and perform full browser navigation to that location
if (!isFirstPage && mustPerformServerSide(intent)) {
const { pageUrl } = action;
if (isNothing(pageUrl)) {
log.error(
`\`${intent.$kind}\` must be performed server-side, but the action lacks a \`pageUrl\` to navigate to`,
);
return 'performed';
}
window.location.href = stripHost(pageUrl);
return 'performed';
}
// We capture this variable since below it is used asynchronously, but
// we updated it at the end of this handler (so it could change before
// it's used below).
const shouldReplace = isFirstPage;
// Resolves either when the page is ready or 800ms have elapsed
// (we want to show a loading spinner after 800ms)
const page = await getPage(intent, action);
// If the action requires the page to be rendered in a modal.
if (action.presentationContext === 'presentModal') {
handleModalPresentation(page, log, action.page);
return 'performed';
}
// This must happen before history.replaceState/pushState
// We call this now, because the next line updates <App> which changes
// the DOM. After that point we can't do things like record scroll
// position, etc.
history.beforeTransition();
updateApp({
page: page.promise.then((page: Page): Page => {
const state = {
page,
};
const canonicalURL = mapException(
() => unwrapOptional(page.canonicalURL),
'`page` resolved without a `canonicalURL`, which is required for navigation',
);
// TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
// perfkit.setPageType(page.pageMetrics?.pageFields?.pageType as string | undefined || 'unknown');
if (shouldReplace) {
history.replaceState(state, canonicalURL);
} else {
history.pushState(state, canonicalURL);
}
didEnterPage(page);
return page;
}),
isFirstPage,
});
// Future updates won't be for the first page
isFirstPage = false;
return 'performed';
});
history.onPopState(
async (url: string, state: State | undefined): Promise<void> => {
// NOTE: We don't call history.beforeTransition() anywhere here,
// because we don't expect to save any state from the previous page
// on back.
if (state) {
const { page } = state;
log.info('received popstate, so resetting page:', page);
didEnterPage(page);
updateApp({ page, isFirstPage });
return;
}
// If the state is missing page data, we have to recompute the view model
const routing = await jet.routeUrl(url);
if (!routing) {
log.error(
'received popstate without data, but URL was unroutable:',
url,
);
// This probably shouldn't happen (since we only ever push valid
// URLs to the history), but if it does, the best we can do is show
// an error.
didEnterPage(null); // to exit the current page
updateApp({
page: Promise.reject(new Error('404')),
isFirstPage,
});
return;
}
log.info(
'received popstate without data, so routing URL to:',
routing,
);
// We can't perform the FlowAction here, as that would cause a new
// history state to be pushed. Since we're in the context of a
// popState, that would cause an infinite history loop where the back
// button goes back but then immediately pushes again to the history
// (so the user doesn't actually go back in history).
// See: rdar://92621382 (Navigating more than 10 pages and then going back breaks back button)
//
// Careful reading will note that this promise will not reject.
// Only the page.promise can reject (and we'll hand that to updateApp,
// which will display the appropriate error for this case).
//
// Like in the handling of FlowAction (above), this blocks for at
// most 800ms before resolving. Either the page is ready, or we
// want to display a loading spinner. updateApp() will show a
// spinner if page.promise is not ready.
const page = await getPage(routing.intent, routing.action);
updateApp({
page: page.promise.then((page: Page): Page => {
// No history.replaceState/pushState like in handling FlowAction
// (above) since this is in the context of a popstate. The
// history stack, URL bar, etc. have already been updated.
didEnterPage(page);
return page;
}),
isFirstPage,
});
},
);
/**
* Get a Page by dispatching its intent. Returns a promise that resolves
* when the page is ready or after 800ms, whichever is first.
*
* The promise-inside-an-object-inside-a-promise return type is
* intentional. If we just returned Promise<Page>, then this function
* would not resolve until the page was ready. But we want it to resolve
* after 800ms, even if the page isn't ready.
*/
async function getPage(
intent: Intent<unknown>,
sourceAction: FlowAction | undefined,
): Promise<{ promise: Promise<Page> }> {
const page = (async (): Promise<Page> => {
try {
let page = await jet.dispatch(intent);
log.info('FlowAction destination resolved to:', page);
assertIsPage(page);
return page;
} catch (e: any) {
log.error('FlowAction destination rejected:', e);
// Provide a way to retry the flow action from <ErrorPage>
if (!e.userInfo || e.userInfo.status !== 404) {
e.retryFlowAction = sourceAction;
}
e.isFirstPage = isFirstPage;
addRejectedIntent(e, intent);
throw e;
}
})();
// Wait until the page loads (or up to 500ms, then show loading spinner)
await Promise.race([
page,
// Note that this has interplay with <PageResolver>
new Promise((resolve) => setTimeout(resolve, 500)),
// TODO: rdar://78166703 Add test to ensure catch no-ops
//
// NOTE: This catch is important. If the page promise rejects, we
// want that to flow down into updateApp, where the appropriate
// error page will be displayed. If we don't no-op here, we'll
// cause the FlowAction to not finish handling (and updateApp will
// never be called).
]).catch(() => {});
// Wrapping in an object to prevent this function's promise from
// not resolving until the page is ready. We want to resolve
// immediately if it's already been 800ms
return { promise: page };
}
function didEnterPage(page: Page | null): void {
// Wrapped in an IIFE to avoid blocking anything (or breaking anything
// if this fails)
(async (): Promise<void> => {
try {
await jet.didEnterPage(page);
} catch (e) {
log.error('didEnterPage error:', e);
}
})();
}
}
/**
* Determines if an `Intent` must be performed server-side
*/
function mustPerformServerSide(intent: Intent<unknown>): boolean {
return isSearchResultsPageIntent(intent) || isChartsPageIntent(intent);
}

125
src/jet/bootstrap.ts Normal file
View File

@@ -0,0 +1,125 @@
import { makeRouterUsingRegisteredControllers } from '@jet/environment/routing';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { AppStoreIntentDispatcher } from '@jet-app/app-store/foundation/runtime/app-store-intent-dispatcher';
import { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime';
import {
type Dependencies,
ObjectGraphType,
makeObjectGraph,
} from '~/jet/dependencies';
import { AppEventPageIntentController } from '@jet-app/app-store/controllers/app-events/app-event-page-intent-controller';
import { BundlePageIntentController } from '@jet-app/app-store/controllers/product-page/bundle-page-intent-controller';
import { EditorialPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-page-intent-controller';
import { EditorialShelfCollectionPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-shelf-collection-page-intent-controller';
import { GroupingPageIntentController } from '@jet-app/app-store/controllers/grouping/grouping-page-intent-controller';
import { ProductPageIntentController } from '@jet-app/app-store/controllers/product-page/product-page-intent-controller';
import { SearchLandingPageIntentController } from '@jet-app/app-store/controllers/search/search-landing-page-intent-controller';
import { SearchResultsPageIntentController } from '@jet-app/app-store/controllers/search/search-results-controller';
import { RoutableArticlePageIntentController } from '@jet-app/app-store/controllers/today/routable-article-page-intent-controller';
import { ArcadeGroupingPageIntentController } from '@jet-app/app-store/controllers/arcade/arcade-grouping-page-intent-controller';
import { DeveloperPageIntentController } from '@jet-app/app-store/controllers/developer/developer-page-intent-controller';
import { ChartsPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-page-intent-controller';
import { ChartsHubPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-hub-page-intent-controller';
import { SeeAllPageIntentController } from '@jet-app/app-store/controllers/product-page/see-all-intent-controller';
import { RoutableTodayPageIntentController } from '@jet-app/app-store/controllers/today/routable-today-page-intent-controller';
import { RoomPageIntentController } from '@jet-app/app-store/controllers/room/room-page-intent-controller';
import { RoutableArcadeSeeAllPageController } from '@jet-app/app-store/controllers/arcade/routable-arcade-see-all-page-controller';
import * as landingPageNavigationControllers from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers';
import { RootRedirectController } from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers';
import { EulaPageIntentController } from '@jet-app/app-store/controllers/product-page/eula-page-intent-controller';
import { CategoryTabsIntentController } from '@jet-app/app-store/controllers/web-navigation/category-tabs-intent-controller';
import { ErrorPageIntentController } from '~/jet/intents/error-page-intent-controller';
import { ChartsPageRedirectIntentController } from '~/jet/intents/charts-page-redirect-intent-controller';
import {
RouteUrlIntentController,
LintMetricsEventIntentController,
} from '~/jet/intents';
import * as staticMessagePageControllers from '~/jet/intents/static-message-pages';
function makeIntentDispatcher(): AppStoreIntentDispatcher {
const intentDispatcher = new AppStoreIntentDispatcher();
intentDispatcher.register(RouteUrlIntentController);
intentDispatcher.register(LintMetricsEventIntentController);
// Route Providers
for (const Controller of Object.values(landingPageNavigationControllers)) {
// `RootRedirectController` needs to be registered last, due to it's path match of `/{sf}`,
// it could inadvertently match a landing page route like `/vision`, so we are skipping it here
// and registering it at the bottom of this function.
if (Controller !== RootRedirectController) {
intentDispatcher.register(Controller);
}
}
for (const StaticMessagePageController of Object.values(
staticMessagePageControllers,
)) {
intentDispatcher.register(StaticMessagePageController);
}
intentDispatcher.register(ArcadeGroupingPageIntentController);
intentDispatcher.register(BundlePageIntentController);
intentDispatcher.register(EditorialPageIntentController);
intentDispatcher.register(EditorialShelfCollectionPageIntentController);
intentDispatcher.register(GroupingPageIntentController);
intentDispatcher.register(new SearchResultsPageIntentController());
intentDispatcher.register(SearchLandingPageIntentController);
intentDispatcher.register(DeveloperPageIntentController);
intentDispatcher.register(RoutableArticlePageIntentController);
intentDispatcher.register(RoutableTodayPageIntentController);
intentDispatcher.register(RoomPageIntentController);
intentDispatcher.register(RoutableArcadeSeeAllPageController);
intentDispatcher.register(EulaPageIntentController);
intentDispatcher.register(ChartsPageRedirectIntentController);
intentDispatcher.register(ErrorPageIntentController);
// "Charts" Pages; "hub" must come first since so it's URL matches before the "detail" page
intentDispatcher.register(ChartsHubPageIntentController);
intentDispatcher.register(ChartsPageIntentController);
// Product Page Routes; order is important due to overlapping URL patterns
// The product page itself must come last or it will match the more-specific patterns
intentDispatcher.register(AppEventPageIntentController);
intentDispatcher.register(SeeAllPageIntentController);
intentDispatcher.register(ProductPageIntentController);
intentDispatcher.register(new CategoryTabsIntentController());
// We register the root redirect controller last so more specific path patterns can be matched first
intentDispatcher.register(RootRedirectController);
return intentDispatcher;
}
/**
* Bootstraps the Jet runtime for Apps
*
* @param dependencies dependencies to initialize the Object Graph with
*/
export function bootstrap(dependencies: Dependencies): {
runtime: AppStoreRuntime;
objectGraph: AppStoreObjectGraph;
} {
const intentDispatcher = makeIntentDispatcher();
const baseObjectGraph = makeObjectGraph(dependencies);
const router = makeRouterUsingRegisteredControllers(
intentDispatcher,
baseObjectGraph,
);
const appObjectGraph = baseObjectGraph
.adding(ObjectGraphType.router, router)
.adding(ObjectGraphType.dispatcher, intentDispatcher);
return {
runtime: new AppStoreRuntime(intentDispatcher, appObjectGraph),
objectGraph: appObjectGraph,
};
}

290
src/jet/dependencies/bag.ts Normal file
View File

@@ -0,0 +1,290 @@
import type { Bag as NativeBag, BagKeyDescriptor } from '@jet/environment';
import type { Opt } from '@jet/environment';
import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
import type { Locale } from './locale';
import {
EU_STOREFRONTS,
SUPPORTED_STOREFRONTS_FOR_VISION,
UNSUPPORTED_STOREFRONTS_FOR_ARCADE,
} from '~/constants/storefront';
export type BagRetrievalMethod = Exclude<keyof NativeBag, 'registerBagKeys'>;
export function makeUnimplementedKeyRequestWarning(
method: BagRetrievalMethod,
key: string,
) {
return `requested unimplemented \`${method}\` key \`${key}\``;
}
export class WebBag implements NativeBag {
private readonly log: Logger;
private readonly locale: Locale;
constructor(loggerFactory: LoggerFactory, locale: Locale) {
this.log = loggerFactory.loggerFor('Bag');
this.locale = locale;
}
private provideNoValue(method: BagRetrievalMethod, key: string): null {
this.log.warn(makeUnimplementedKeyRequestWarning(method, key));
return null;
}
registerBagKeys(_keys: BagKeyDescriptor[]): void {
// We hardcode, so registration is a no-op
}
double(key: string): Opt<number> {
switch (key) {
case 'game-controller-recommended-rollout-rate':
return 1.0; // set to 1.0 to enable `learn more` button for game controller capability
case 'icon-artwork-rollout-rate':
return 1.0; // set to 1.0 to enable new icon artwork style
default:
return this.provideNoValue('double', key);
}
}
integer(key: string): Opt<number> {
return this.provideNoValue('integer', key);
}
boolean(key: string): Opt<boolean> {
switch (key) {
case 'enableAppEvents':
return true;
case 'enable-app-accessibility-labels':
return true;
case 'enable-app-store-age-ratings':
return true;
case 'enable-external-purchase':
return true;
case 'enable-privacy-nutrition-labels':
return true;
case 'enable-system-app-reviews':
return true;
case 'enable-vision-platform':
return SUPPORTED_STOREFRONTS_FOR_VISION.has(
this.locale.activeStorefront,
);
case 'arcade-enabled':
return !UNSUPPORTED_STOREFRONTS_FOR_ARCADE.has(
this.locale.activeStorefront,
);
// Enable required `GroupingPage` features
case 'enable-featured-categories-on-groupings':
case 'enable-category-bricks-on-groupings':
return true;
case 'enable-seller-info':
return true;
case 'enable-preview-platform-for-web':
return false;
case 'enableProductPageVariants':
return true;
case 'game-center-extend-supported-features':
return true;
case 'enable-product-page-install-size':
return true;
case 'enable-icon-artwork':
return true;
default:
return this.provideNoValue('boolean', key);
}
}
array(key: string): Opt<unknown> {
switch (key) {
// URL patterns that are opted into the "edge" domains
// https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L350
case 'apps-media-api-edge-end-points':
return [
// Including a pattern that matches our "search" API endpoint ensures
// that the built URL uses the `apps-media-api-search-edge-host` host
// https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L352
'/search',
];
case 'enabled-external-purchase-placements':
return ['product-page-banner', 'product-page-info-section'];
case 'tabs/standard':
return [
{
id: 'today',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Today',
),
'image-identifier': 'text.rectangle.page',
},
{
id: 'apps',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Apps',
),
'image-identifier': 'app.3.stack.3d.fill',
},
{
id: 'apps-and-games',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.AppsAndGames',
),
'image-identifier': 'rocket.fill',
},
{
id: 'arcade',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Arcade',
),
'image-identifier': 'joystickcontroller.fill',
},
{
id: 'create',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Create',
),
'image-identifier': 'paintbrush.fill',
},
{
id: 'discover',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Discover',
),
'image-identifier': 'star.fill',
},
{
id: 'games',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Games',
),
'image-identifier': 'rocket.fill',
},
{
id: 'work',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Work',
),
'image-identifier': 'paperplane.fill',
},
{
id: 'play',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Play',
),
'image-identifier': 'rocket.fill',
},
{
id: 'develop',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Develop',
),
'image-identifier': 'hammer.fill',
},
{
id: 'categories',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Categories',
),
'image-identifier': 'square.grid.2x2.fill',
},
{
id: 'search',
title: this.locale.i18n.t(
'ASE.Web.AppStore.Navigation.LandingPage.Search',
),
'image-identifier': 'magnifyingglass',
},
];
default:
return this.provideNoValue('array', key);
}
}
dictionary(key: string): Opt<unknown> {
return this.provideNoValue('dictionary', key);
}
url(key: string): Opt<string> {
switch (key) {
case 'apps-media-api-host':
return 'amp-api-edge.apps.apple.com';
case 'apps-media-api-edge-host':
return 'amp-api-edge.apps.apple.com';
case 'apps-media-api-search-edge-host':
return 'amp-api-search-edge.apps.apple.com';
default:
return this.provideNoValue('url', key);
}
}
string(key: string): Opt<string> {
switch (key) {
case 'countryCode':
return this.locale.activeStorefront;
case 'language-tag':
return this.locale.activeLanguage;
case 'language':
// TODO: rdar://78159789: util for this? What about zh-Hant, etc.
return this.locale.activeLanguage.split('-')[0];
// Some URLs are accessed as strings
// TODO: fix this upstream in `ios-appstore-app` so it uses `.url()` instead
case 'apps-media-api-edge-host':
case 'apps-media-api-search-edge-host':
return this.url(key);
case 'game-controller-learn-more-editorial-item-id':
return '1687769242';
case 'familySubscriptionsLearnMoreEditorialItemId':
return '1563279606';
case 'external-purchase-learn-more-editorial-item-id':
if (this.locale.activeStorefront === 'kr') {
return 'id1727067165';
}
return 'id1760810284';
case 'appPrivacyLearnMoreEditorialItemId':
return 'id1538632801';
case 'ageRatingLearnMoreEditorialItemId':
return '1825160725';
case 'accessibility-learn-more-editorial-item-id':
return '1814164299';
case 'external-purchase-product-page-banner-text-variant':
return '2';
case 'external-purchase-product-page-annotation-variant':
return '4';
case 'transparencyLawEditorialItemId':
if (EU_STOREFRONTS.includes(this.locale.activeStorefront)) {
return 'id1620909697';
}
return null;
case 'appPrivacyDefinitionsEditorialItemId':
return '1539235847';
case 'metrics_topic':
return 'xp_amp_appstore_unidentified';
case 'in-app-purchases-learn-more-editorial-item-id':
return '1436214772';
case 'web-navigation-category-tabs-editorial-item-id':
return '1842456901';
default:
return this.provideNoValue('string', key);
}
}
}

View File

@@ -0,0 +1,96 @@
import type { Locale } from './locale';
export class WebClient implements Client {
private readonly locale: Locale;
deviceType: DeviceType = 'web';
// Tell the App Store Client that we're *really* the "web", even if the `DeviceType`
// says otherwise
__isReallyWebClient = true as const;
// TODO: how do we define this for the "client" web, when it can change over time?
screenSize: { width: number; height: number } = { width: 0, height: 0 };
// TODO: how is this used? We can't have a consistent value across multiple sessions
guid: string = 'xxx-xx-xxx';
screenCornerRadius: number = 0;
newPaymentMethodEnabled = false;
isActivityAvailable = false;
isElectrocardiogramInstallationAllowed = false;
isScandiumInstallationAllowed = false;
isSidepackingEnabled = false;
isTinkerWatch = false;
supportsHEIF: boolean = false;
isMandrakeSupported: boolean = false;
isCharonSupported: boolean = false;
buildType: BuildType;
maxAppContentRating: number = 1000;
isIconArtworkCapable: boolean = true;
constructor(buildType: BuildType, locale: Locale) {
this.buildType = buildType;
this.locale = locale;
}
get storefrontIdentifier(): string {
return this.locale.activeStorefront;
}
deviceHasCapabilities(_capabilities: string[]): boolean {
return false;
}
deviceHasCapabilitiesIncludingCompatibilityCheckIsVisionOSCompatibleIOSApp(
_capabilities: string[],
_supportsVisionOSCompatibleIOSBinary: boolean,
): boolean {
return false;
}
isActivePairedWatchSystemVersionAtLeastMajorVersionMinorVersionPatchVersion(
_majorVersion: number,
_minorVersion: number,
_patchVersion: number,
): boolean {
return false;
}
canDevicePerformAppActionWithAppCapabilities(
_appAction: string,
_appCapabilities: string[] | undefined | null,
): boolean {
return false;
}
isAutomaticDownloadingEnabled(): boolean {
return false;
}
isAuthorizedForUserNotifications(): boolean {
return false;
}
deletableSystemAppCanBeInstalledOnWatchWithBundleID(
_bundleId: string,
): boolean {
return false;
}
isDeviceEligibleForDomain(_domain: string): boolean {
return false;
}
}

View File

@@ -0,0 +1,26 @@
import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
import type { RequiredConsole } from '@jet-app/app-store/foundation/wrappers/console';
export class WebConsole implements RequiredConsole {
private readonly logger: Logger;
constructor(loggerFactory: LoggerFactory) {
this.logger = loggerFactory.loggerFor('jet-console');
}
error(...data: unknown[]): void {
this.logger.error(...data);
}
info(...data: unknown[]): void {
this.logger.info(...data);
}
log(...data: unknown[]): void {
this.logger.info(...data);
}
warn(...data: unknown[]): void {
this.logger.warn(...data);
}
}

View File

@@ -0,0 +1,20 @@
const ENABLED_FEATURES = new Set([
// Make the `ProductPageIntentController` return a `ShelfBasedProductPage` instance
'shelves_2_0_product',
// Enable shelf-based "Top Charts" features
// 'shelves_2_0_top_charts',
// Make the `RibbonBarShelf` contain an array of `RibbonBarItem`s
'shelves_2_0_generic',
// Enable AX Metadata
'product_accessibility_support_2025A',
]);
export class WebFeatureFlags implements FeatureFlags {
isEnabled(feature: string): boolean {
return ENABLED_FEATURES.has(feature);
}
isGSEUIEnabled(_feature: string): boolean {
return false;
}
}

View File

@@ -0,0 +1,99 @@
import type { Locale as JetLocaleDependency } from '@jet-app/app-store/foundation/dependencies/locale/locale';
import type {
NormalizedLanguage,
NormalizedStorefront,
NormalizedLocale,
UnnormalizedLocale,
} from '@jet-app/app-store/api/locale';
import type I18N from '@amp/web-apps-localization';
import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
import type { Jet } from '~/jet/jet';
import {
DEFAULT_STOREFRONT_CODE,
DEFAULT_LANGUAGE_BCP47,
} from '~/constants/storefront';
import {
type NormalizedLocaleWithDefault,
normalizeStorefront,
normalizeLanguage,
} from '~/utils/locale';
import type { Optional } from '@jet/environment';
/**
* Contains information related to the locale of the request currently being
* made to the application.
*
* Typically, localization information is expected to be known when the Jet
* instance is initialized. The Web, however, will not know the current
* locale and langauge until after routing has already taken place.
*
* This object exists to contain that lazily-determined locale information,
* so that other dependencies can retreive it from here. It is to be created
* with the rest of the dependencies and passed to them when they are created.
*
* Localization information is set in the {@linkcode Jet#setLocale} method
*/
export class Locale implements JetLocaleDependency {
private readonly logger: Logger;
private _storefront: NormalizedStorefront | undefined;
private _language: NormalizedLanguage | undefined;
i18n: I18N | undefined;
constructor(loggerFactory: LoggerFactory) {
this.logger = loggerFactory.loggerFor('locale');
}
get activeStorefront(): NormalizedStorefront {
if (!this._storefront) {
this.logger.warn('`storefront` was accessed before being set');
return DEFAULT_STOREFRONT_CODE;
}
return this._storefront;
}
get activeLanguage(): NormalizedLanguage {
if (!this._language) {
this.logger.warn('`language` was accessed before being set');
return DEFAULT_LANGUAGE_BCP47;
}
return this._language;
}
setActiveLocale(locale: NormalizedLocale): void {
this._storefront = locale.storefront;
this._language = locale.language;
}
normalize({
storefront,
language,
}: UnnormalizedLocale): NormalizedLocaleWithDefault {
const {
storefront: normalizedStorefront,
languages,
defaultLanguage,
} = normalizeStorefront(storefront);
return {
storefront: normalizedStorefront,
...normalizeLanguage(language || '', languages, defaultLanguage),
};
}
deriveLocaleForUrl(locale: NormalizedLocale): {
storefront: string;
language: Optional<string>;
} {
const { isDefaultLanguage } = this.normalize(locale);
return {
storefront: locale.storefront,
language: isDefaultLanguage ? undefined : locale.language,
};
}
}

View File

@@ -0,0 +1,523 @@
import type I18N from '@amp/web-apps-localization';
import type { LoggerFactory, Logger } from '@amp/web-apps-logger';
import { isNothing } from '@jet/environment';
import type { Locale } from './locale';
import { abbreviateNumber } from '~/utils/number-formatting';
import { getFileSizeParts } from '~/utils/file-size';
import {
getPlural,
interpolateString,
} from '@amp/web-apps-localization/src/translator';
import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization';
const SECONDS_PER_MINUTE = 60;
const SECONDS_PER_HOUR = 60 * 60;
const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24;
const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365;
export function makeWebDoesNotImplementException(property: keyof Localization) {
return new Error(
`\`Localization\` method \`${property}\` is not implemented for the "web" platform`,
);
}
/**
* Determines if {@linkcode key} appears to be a "client" translation key
*
* "Client" keys are defined in `SCREAMING_SNAKE_CASE`
*/
function isClientLocalizationKey(key: string): boolean {
return /^[A-Z_]+$/.test(key);
}
/**
* Transforms an App Store Client-used translation key to the format that we have
* a value for.
*
* This accounts for the fact that the "raw" key used by the App Store Client
* is either a "client" key, that we filed an analogue for in our own translations,
* or a "server" key that exists in the App Store Client translations under their
* own namespace. In either case, we need to perform a transformation on the key as
* they use it into a format that we have a value for.
*/
function transformKeyToSupportedFormat(key: string): string {
return isClientLocalizationKey(key)
? transformClientKeyToSupportedFormat(key)
: transformServerKeyToSupportedFormat(key);
}
/**
* Transforms an App Store Client server-side translation key into the format
* that we have a value for.
*
* This handles the fact that the App Store Client namespaces all of
* their translation strings under `AppStore.` but does not include
* that namespace when referencing the key. Since their tooling implicitly
* injects that namespace for them, we have to do the same thing manually.
* @example
* transformServerKeyToSupportedFormat('Account.Purchases');
* // "AppStore.Account.Purchases"
*/
function transformServerKeyToSupportedFormat(key: string): string {
return `AppStore.${key}`;
}
/**
* Capitalizes the first character in {@linkcode input}
*/
function capitalizeFirstCharacter(input: string): string {
const [first, ...rest] = input;
return first.toUpperCase() + rest.join('');
}
/**
* Transforms an App Store Client client-side translation key into the format
* that we have a value for.
*
* "Client" keys used by the App Store Client are typically provided by the OS;
* this is not available to a web application, we need an alternative to providing
* values for these translation keys.
*
* To accomplish this, we have submitted these keys to the server-side localization
* service ourelves, under a specific namespace that designates that they are the
* client-side keys that we needed to define. Other formatting changes are made to
* the key at the request of the LOC team.
*
* @example
* transformClientKeyToSupportedFormat('ACCOUNT_PURCHASES');
* // "ASE.Web.AppStoreClient.Account.Purchases"
*/
function transformClientKeyToSupportedFormat(key: string): string {
const keyInSrvLocFormat = key
.toLowerCase()
.split('_')
.map((segment) => capitalizeFirstCharacter(segment))
.join('.');
return `ASE.Web.AppStoreClient.${keyInSrvLocFormat}`;
}
/**
* "Web" implementation of the `AppStoreKit` {@linkcode Localization} dependency
*/
export class WebLocalization implements Localization {
private readonly locale: Locale;
private readonly logger: Logger;
constructor(locale: Locale, loggerFactory: LoggerFactory) {
this.locale = locale;
this.logger = loggerFactory.loggerFor('jet/dependency/localization');
}
get i18n(): I18N {
if (this.locale.i18n) {
return this.locale.i18n;
}
throw new Error('`i18n` not yet configured ');
}
/**
* The `BCP 47` identifier for the active locale
*
* @see {@link https://developer.apple.com/documentation/foundation/locale | Foundation Frameworks Locale Documentation}
* @see {@link https://en.wikipedia.org/wiki/IETF_language_tag | BCP 47}
*/
get identifier() {
return this.locale.activeLanguage;
}
decimal(
n: number | null | undefined,
decimalPlaces?: number | null | undefined,
): string | null {
if (isNothing(n)) {
return null;
}
let langCode: string = this.locale.activeLanguage;
if (!langCode.includes('-')) {
langCode = `${this.locale.activeLanguage}-${this.locale.activeStorefront}`;
}
const numberingSystem = new Intl.NumberFormat(
langCode,
).resolvedOptions().numberingSystem;
const formatter = new Intl.NumberFormat(this.locale.activeLanguage, {
numberingSystem,
minimumFractionDigits: decimalPlaces ?? undefined,
maximumFractionDigits: decimalPlaces ?? undefined,
});
return formatter.format(n);
}
string(key: string): string {
const keyInSupportedFormat = transformKeyToSupportedFormat(key);
// `.getUninterpolatedString` is used instead of `.t` here to match
// the behavior of the `.stringWithCount` method
return this.i18n.getUninterpolatedString(keyInSupportedFormat);
}
stringForPreferredLocale(_key: string, _locale: string | null): string {
throw makeWebDoesNotImplementException('stringForPreferredLocale');
}
stringWithCount(key: string, count: number): string {
let keyInSupportedFormat = transformKeyToSupportedFormat(key);
// The App Store Client has some behavior around pluralization that differs
// from how the Media Apps localization normally works. In order to handle
// this, we have to avoid the default pluralization behavior of the `.i18n.t`
// method and do the pluralization ourselves
const keyWithPluralizationSuffix = getPlural(
count,
keyInSupportedFormat,
this.identifier as SupportedLanguageIdentifier,
);
// The key difference in pluralization logic is that the `other` case is
// actually handled by the "base" key without any suffix.
// Therefore, we should only use the pluralized key if it does not reflect
// the `other` case
if (!keyWithPluralizationSuffix.endsWith('.other')) {
keyInSupportedFormat = keyWithPluralizationSuffix;
}
const uninterpolatedValue =
this.i18n.getUninterpolatedString(keyInSupportedFormat);
// Since the `count` might be interpolated into the localization string,
// we need to run the interpolation ourselves on uninterpolated value
return interpolateString(
key,
uninterpolatedValue,
{ count },
null,
this.identifier as SupportedLanguageIdentifier,
);
}
stringWithCounts(_key: string, _counts: number[]): string {
throw makeWebDoesNotImplementException('stringWithCounts');
}
uppercased(_value: string): string {
throw makeWebDoesNotImplementException('uppercased');
}
/**
* Converts a number of bytes into a localized file size string
*
* @param bytes The number of bytes to convert
* @return The localized file size string
*/
fileSize(bytes: number): string | null {
let { count, unit } = getFileSizeParts(bytes);
return this.i18n.t(`ASE.Web.AppStore.FileSize.${unit}`, {
count,
});
}
formattedCount(count: number | null | undefined): string | null {
if (isNothing(count)) {
return null;
}
return abbreviateNumber(count, this.locale.activeLanguage);
}
formattedCountForPreferredLocale(
count: number | null,
locale: string | null,
): string | null {
if (isNothing(count)) {
return null;
}
return isNothing(locale)
? abbreviateNumber(count, this.locale.activeLanguage)
: abbreviateNumber(count, locale);
}
/**
* Convert a date into a time ago label, showing how long ago
* the date occurred.
*
* @param date The date object to convert
* @return The localized string representing the amount of time that has passed
*/
timeAgo(date: Date | null | undefined): string | null {
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
return null;
}
const relativeTimeIntl = new Intl.RelativeTimeFormat(
this.locale.activeLanguage,
{
style: 'narrow',
},
);
const now = new Date();
const secondsAgo = (now.getTime() - date.getTime()) / 1000;
const minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE);
const hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR);
const daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY);
const yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR);
const isSameYear = now.getFullYear() === date.getFullYear();
const isUpcoming = date.getTime() > now.getTime();
if (secondsAgo < 0 && isUpcoming) {
return new Intl.DateTimeFormat(this.locale.activeLanguage, {
month: 'short',
day: 'numeric',
}).format(date);
}
if (secondsAgo < 60) {
return relativeTimeIntl.format(-secondsAgo, 'seconds');
}
if (minutesAgo < 60) {
return relativeTimeIntl.format(-minutesAgo, 'minutes');
}
if (hoursAgo < 24) {
return relativeTimeIntl.format(-hoursAgo, 'hours');
}
if (daysAgo < 7) {
return relativeTimeIntl.format(-daysAgo, 'days');
}
if (isSameYear) {
return new Intl.DateTimeFormat(this.locale.activeLanguage, {
month: 'short',
day: 'numeric',
}).format(date);
}
if (yearsAgo >= 0) {
return new Intl.DateTimeFormat(this.locale.activeLanguage, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}).format(date);
}
// this return statement is here to satisfy typescript, all possible cases are
// satisfied by the above conditionals.
return null;
}
timeAgoWithContext(
_date: Date | null | undefined,
_context: DateContext,
): string | null {
return null;
}
formatDate(format: string, date: Date | null | undefined): string | null {
if (isNothing(date)) {
return null;
}
let formatterConfiguration: Intl.DateTimeFormatOptions | undefined;
switch (format) {
case 'MMM d': // e.g. Jan 29
formatterConfiguration = {
month: 'short',
day: 'numeric',
};
break;
case 'MMMM d': // e.g. January 29
formatterConfiguration = {
month: 'long',
day: 'numeric',
};
break;
case 'j:mm': // e.g. 9:00
formatterConfiguration = {
hour: 'numeric',
minute: '2-digit',
};
break;
case 'MMM d, y': // e.g. Jan 29, 2025
formatterConfiguration = {
month: 'short',
day: 'numeric',
year: 'numeric',
};
break;
case 'MMMM d, y': // e.g. "January 29, 2025"
formatterConfiguration = {
year: 'numeric',
month: 'long',
day: 'numeric',
};
break;
case 'EEE j:mm': // e.g. "SAT 9:00PM"
formatterConfiguration = {
weekday: 'short',
hour: 'numeric',
minute: '2-digit',
hour12: true,
};
break;
case 'd، MMM، yyyy': // e.g. "29 Jan 2025"
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'MMM d, yyyy': // e.g. "Jan 29, 2025"
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'd MMM yyyy': // e.g. "29 January 2025"
formatterConfiguration = {
day: 'numeric',
month: 'long',
year: 'numeric',
};
break;
case 'yyyy MMMM d': // e.g. "2025 January 29"
formatterConfiguration = {
day: 'numeric',
month: 'long',
year: 'numeric',
};
case 'd M yyyy':
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'd MMM., yyyy':
formatterConfiguration = {
day: 'numeric',
month: 'long',
year: 'numeric',
};
break;
case 'dd/MM/yyyy': // e.g. "29/01/2025"
formatterConfiguration = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
};
break;
case 'd MMM , yyyy': // e.g. "29 Jan , 2025"
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'd. MMM. yyyy.': // e.g. "29. Jan. 2025."
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'd. MMM yyyy': // e.g. "29. Jan 2025"
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'yyyy. MMM d.': // e.g. "2025. Jan 29."
formatterConfiguration = {
day: 'numeric',
month: 'short',
year: 'numeric',
};
break;
case 'd.M.yyyy': // e.g. "29.1.2025"
formatterConfiguration = {
day: 'numeric',
month: 'numeric',
year: 'numeric',
};
break;
case 'd/M/yyyy': // e.g. "29/1/2025"
formatterConfiguration = {
day: 'numeric',
month: 'numeric',
year: 'numeric',
};
break;
default:
this.logger.warn(
`\`formatDate\` called with unexpected format \`${format}\``,
);
return null;
}
return new Intl.DateTimeFormat(
this.locale.activeLanguage,
formatterConfiguration,
).format(date);
}
formatDateWithContext(
format: string,
date: Date | null | undefined,
_context: DateContext,
): string | null {
return this.formatDate(format, date);
}
formatDateInSentence(
sentence: string,
format: string,
date: Date | null | undefined,
): string | null {
const formattedDate = this.formatDate(format, date);
if (isNothing(formattedDate)) {
return null;
}
return (
sentence
// "Server-Side" LOC keys us `@@date@@` to mark the date to replace
.replace('@@date@@', formattedDate)
// "Client-Side" LOC keys use `%@` to mark the date to replace
.replace('%@', formattedDate)
);
}
relativeDate(date: Date | null | undefined): string | null {
if (isNothing(date)) {
return null;
}
return date.toString();
}
formatDuration(_value: number, _unit: TimeUnit): string | null {
throw makeWebDoesNotImplementException('formatDuration');
}
}

View File

@@ -0,0 +1,45 @@
import type { LoggerFactory as AppLoggerFactory } from '@amp/web-apps-logger';
import { Random } from '@amp/web-apps-common/src/jet/dependencies/random';
import { Host } from '@amp/web-apps-common/src/jet/dependencies/host';
import { WebBag } from './bag';
import { WebClient } from './client';
import { WebConsole } from './console';
import { Locale } from './locale';
import { WebLocalization } from './localization';
import { makeProperties } from './properties';
import { WebMetricsIdentifiers } from './metrics-identifiers';
import { Net, type FeaturesCallbacks } from './net';
import { WebStorage } from './storage';
import { makeUnauthenticatedUser } from './user';
import { SEO } from './seo';
export type Dependencies = ReturnType<typeof makeDependencies>;
export function makeDependencies(
loggerFactory: AppLoggerFactory,
fetch: typeof window.fetch,
featuresCallbacks?: FeaturesCallbacks,
) {
const locale = new Locale(loggerFactory);
return {
bag: new WebBag(loggerFactory, locale),
client: new WebClient(
// TODO: set the right `BuildType` based on the environment where the app is running
'production',
locale,
),
console: new WebConsole(loggerFactory),
host: new Host(),
localization: new WebLocalization(locale, loggerFactory),
locale,
metricsIdentifiers: new WebMetricsIdentifiers(),
net: new Net(fetch, featuresCallbacks),
properties: makeProperties(),
random: new Random(),
seo: new SEO(locale),
storage: new WebStorage(),
user: makeUnauthenticatedUser(),
URL,
};
}

View File

@@ -0,0 +1,11 @@
import { MEDIA_API_JWT } from '~/config/media-api';
export class WebMediaTokenService implements MediaTokenService {
refreshToken(): Promise<string> {
return Promise.resolve(MEDIA_API_JWT);
}
resetToken(): void {
// No-op; every request uses the same token for the "web" platform
}
}

View File

@@ -0,0 +1,13 @@
export class WebMetricsIdentifiers implements MetricsIdentifiers {
async getIdentifierForContext(
_metricsIdentifierKeyContext: MetricsIdentifierKeyContext,
): Promise<string | undefined> {
return undefined;
}
async getMetricsFieldsForContexts(
_metricsIdentifierKeyContexts: MetricsIdentifierKeyContext[],
): Promise<JSONData | undefined> {
return undefined;
}
}

117
src/jet/dependencies/net.ts Normal file
View File

@@ -0,0 +1,117 @@
import type { Network, FetchRequest, FetchResponse } from '@jet/environment';
import { fromEntries } from '@amp/web-apps-utils';
import {
shouldUseSearchJWT,
makeSearchJWTAuthorizationHeader,
} from '~/config/media-api';
const CORRELATION_KEY_HEADER = 'x-apple-jingle-correlation-key';
type FetchFunction = typeof window.fetch;
// TODO: these URLs are also referenced in `bag` definition; we should have a single
// source-of-truth for these domains
const MEDIA_API_ORIGINS = [
'https://amp-api.apps.apple.com',
'https://amp-api-edge.apps.apple.com',
'https://amp-api-search-edge.apps.apple.com',
];
export interface FeaturesCallbacks {
getITFEValues(): string | undefined;
}
export class Net implements Network {
private readonly underlyingFetch: FetchFunction;
private readonly getITFEValues: () => string | undefined = () => undefined;
constructor(
underlyingFetch: FetchFunction,
featuresCallbacks?: FeaturesCallbacks,
) {
this.underlyingFetch = underlyingFetch;
this.getITFEValues =
featuresCallbacks?.getITFEValues ?? this.getITFEValues;
}
async fetch(request: FetchRequest): Promise<FetchResponse> {
const requestStartTime = getTimestampMs();
const requestURL = new URL(request.url);
request.headers = request.headers ?? {};
if (MEDIA_API_ORIGINS.includes(requestURL.origin)) {
// Need to fake this for the server due to Kong origin checks.
// Has no effect clientside.
request.headers['origin'] = 'https://apps.apple.com';
const itfe = this.getITFEValues?.();
if (itfe) {
// Add ITFE value as query string when set
requestURL.searchParams.set('itfe', itfe);
}
}
// The App Store Client will have already injected the JWT from the
// `media-token-service` ObjectGraph dependency into the headers. However,
// some endpoints need a different JWT. Here we determine if that's the
// case and override the existing JWT if necessary.
if (shouldUseSearchJWT(requestURL)) {
request.headers = {
...request.headers,
...makeSearchJWTAuthorizationHeader(),
};
}
// TODO: rdar://78158575: timeout
const response = await this.underlyingFetch(requestURL.toString(), {
...request,
cache: request.cache ?? undefined,
credentials: 'include',
headers: request.headers ?? undefined,
method: request.method ?? undefined,
});
const responseStartTime = getTimestampMs();
const { ok, redirected, status, statusText, url } = response;
const headers = fromEntries(response.headers);
const body = await response.text();
const responseEndTime = getTimestampMs();
return {
ok,
headers,
redirected,
status,
statusText,
url,
body,
// TODO: rdar://78158575: redirect: 'manual' to get all metrics?
metrics: [
{
clientCorrelationKey: response.headers.get(
CORRELATION_KEY_HEADER,
),
pageURL: response.url,
requestStartTime,
responseStartTime,
responseEndTime,
// TODO: rdar://78158575: responseWasCached?
// TODO: rdar://78158575: parseStartTime/parseEndTime
},
],
};
}
}
/**
* Returns the current UTC timestamp in milliseconds.
*/
function getTimestampMs(): number {
return Date.now();
}

View File

@@ -0,0 +1,59 @@
import { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { ObjectGraphType } from '@jet-app/app-store/gameservicesui/src/foundation/object-graph-types';
import type { Dependencies } from './make-dependencies';
import { WebFeatureFlags } from './feature-flags';
import { WebMediaTokenService } from './media-token-service';
export { ObjectGraphType };
class AppStoreWebObjectGraph extends AppStoreObjectGraph {
/**
* Configures the ObjectGraph from our `Dependencies` definition
*
* @param dependencies
* @returns
*/
configureWithDependencies(dependencies: Dependencies) {
const {
bag,
client,
console,
host,
locale,
localization,
metricsIdentifiers,
net,
properties,
random,
seo,
storage,
user,
} = dependencies;
return this.addingClient(client)
.addingNetwork(net)
.addingHost(host)
.addingBag(bag)
.addingLoc(localization)
.addingMediaToken(new WebMediaTokenService())
.addingConsole(console)
.addingAppleSilicon(undefined)
.addingProperties(properties)
.addingLocale(locale)
.addingUser(user)
.addingFeatureFlags(new WebFeatureFlags())
.addingMetricsIdentifiers(metricsIdentifiers)
.addingSEO(seo)
.addingStorage(storage)
.addingRandom(random);
}
}
export function makeObjectGraph(
dependencies: Dependencies,
): AppStoreObjectGraph {
const objectGraph = new AppStoreWebObjectGraph('app-store');
return objectGraph.configureWithDependencies(dependencies);
}

View File

@@ -0,0 +1,5 @@
export function makeProperties(): PackageProperties {
return {
clientFeatures: {},
};
}

254
src/jet/dependencies/seo.ts Normal file
View File

@@ -0,0 +1,254 @@
import type { Opt } from '@jet/environment/types/optional';
import type {
ArcadeSeeAllGamesPage,
ArticlePage,
ChartsHubPage,
GenericPage,
ReviewsPage,
SearchLandingPage,
SearchResultsPage,
SeeAllPage,
ShelfBasedProductPage,
TodayPage,
TopChartsPage,
} from '@jet-app/app-store/api/models';
import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
import type { SEO as SEODependency } from '@jet-app/app-store/foundation/dependencies/seo';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import type { DataContainer } from '@jet-app/app-store/foundation/media/data-structure';
import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
import type { Locale } from './locale';
import { seoDataForAnyPage, updateCanonicalURL } from '~/utils/seo/common';
import { seoDataForArticlePage } from '~/utils/seo/article-page';
import { seoDataForChartsPage } from '~/utils/seo/charts-page';
import { seoDataForChartsHubPage } from '~/utils/seo/charts-hub-page';
import { seoDataForDeveloperPage } from '~/utils/seo/developer-page';
import { seoDataForProductPage } from '~/utils/seo/product-page';
import { seoDataForAppEventDetailPage } from '~/utils/seo/app-event-detail-page';
import { seoDataForReviewsPage } from '~/utils/seo/reviews-page';
import { seoDataForSearchLandingPage } from '~/utils/seo/search-landing-page';
import { seoDataForSearchResultsPage } from '~/utils/seo/search-results-page';
import { seoDataForEditorialShelfCollectionPage } from '~/utils/seo/editorial-shelf-collection-page';
import { seoDataForArcadeSeeAllPage } from '~/utils/seo/arcade-see-all-page';
import { seoDataForSeeAllPage } from '~/utils/seo/see-all-page';
export class SEO implements SEODependency {
private locale: Locale;
constructor(locale: Locale) {
this.locale = locale;
}
private get i18n() {
if (this.locale.i18n) {
return this.locale.i18n;
}
throw new Error('`i18n` not yet configured ');
}
private getSEODataForGenericPage(page: GenericPage): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
};
}
updateCanonicalURL(page: WebRenderablePage, canonicalURL: string): void {
updateCanonicalURL(page, canonicalURL);
}
/// MARK: Page SEO Data Hooks
getSEODataForAppEventPage(
objectGraph: AppStoreObjectGraph,
page: GenericPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForAppEventDetailPage(
page,
this.i18n,
objectGraph.locale.activeLanguage,
),
};
}
getSEODataForArcadeSeeAllPage(
_objectGraph: AppStoreObjectGraph,
page: ArcadeSeeAllGamesPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForArcadeSeeAllPage(page, this.i18n),
};
}
getSEODataForArticlePage(
objectGraph: AppStoreObjectGraph,
page: ArticlePage,
response: Opt<DataContainer>,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForArticlePage(
objectGraph,
this.i18n,
page,
response,
objectGraph.locale.activeLanguage,
),
};
}
getSEODataForBundlePage(
objectGraph: AppStoreObjectGraph,
page: ShelfBasedProductPage,
data: Opt<DataContainer>,
): Opt<SeoData> {
return this.getSEODataForProductPage(objectGraph, page, data);
}
getSEODataForChartsPage(
objectGraph: AppStoreObjectGraph,
page: TopChartsPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForChartsPage(
page,
this.i18n,
objectGraph.locale.activeLanguage,
),
};
}
getSEODataForChartsHubPage(
objectGraph: AppStoreObjectGraph,
page: ChartsHubPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForChartsHubPage(
page,
this.i18n,
objectGraph.locale.activeLanguage,
),
};
}
getSEODataForDeveloperPage(
objectGraph: AppStoreObjectGraph,
page: GenericPage,
response: Opt<DataContainer>,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForDeveloperPage(objectGraph, response, this.i18n),
};
}
getSEODataForEditorialPage(
_objectGraph: AppStoreObjectGraph,
page: GenericPage,
): Opt<SeoData> {
return this.getSEODataForGenericPage(page);
}
getSEODataForEditorialShelfCollectionPage(
_objectGraph: AppStoreObjectGraph,
page: GenericPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForEditorialShelfCollectionPage(page, this.i18n),
};
}
getSEODataForGroupingPage(
_objectGraph: AppStoreObjectGraph,
page: GenericPage,
): Opt<SeoData> {
return this.getSEODataForGenericPage(page);
}
getSEODataForProductPage(
objectGraph: AppStoreObjectGraph,
page: ShelfBasedProductPage,
data: Opt<DataContainer>,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForProductPage(
objectGraph,
page,
data,
this.i18n,
objectGraph.locale.activeLanguage,
),
};
}
getSEODataForReviewsPage(
objectGraph: AppStoreObjectGraph,
page: ReviewsPage,
productPage: ShelfBasedProductPage,
): Opt<SeoData> {
return {
...this.getSEODataForGenericPage(page),
...seoDataForReviewsPage(this.i18n, page, productPage, objectGraph),
};
}
getSEODataForRoomPage(
_objectGraph: AppStoreObjectGraph,
page: GenericPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
};
}
getSEODataForSearchLandingPage(
_objectGraph: AppStoreObjectGraph,
page: SearchLandingPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForSearchLandingPage(page, this.i18n),
};
}
getSEODataForSearchResultsPage(
objectGraph: AppStoreObjectGraph,
page: SearchResultsPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForSearchResultsPage(
page,
this.i18n,
objectGraph.locale.activeLanguage,
),
};
}
getSEODataForTodayPage(
_objectGraph: AppStoreObjectGraph,
page: TodayPage,
): Opt<SeoData> {
return seoDataForAnyPage(page, this.i18n);
}
getSEODataForSeeAllPage(
_objectGraph: AppStoreObjectGraph,
page: SeeAllPage,
): Opt<SeoData> {
return {
...seoDataForAnyPage(page, this.i18n),
...seoDataForSeeAllPage(page, this.i18n),
};
}
}

View File

@@ -0,0 +1,44 @@
/**
* `AppStoreKit` `Storage` implementation for the "web" client
*
* Note: The `AppStoreKit` `Storage` interface is declared as a global, which has the (presumably
* accidental) side-effect of implicitly being merged with the DOM library's own `Storage` interface
* (like `localStorage`), since interfaces declared in the same scope are merged together by TypeScript.
* There's no way to tell TypeScript that we only care about the `AppStoreKit` part of it, so
* satifying TypeScript here means that we need to implement both interfaces.
*/
export class WebStorage extends Map<string, string> implements Storage {
/* == "DOM" `Storage` Interface == */
get length() {
return this.size;
}
getItem(key: string): string | null {
return this.get(key) ?? null;
}
key(_index: number): string | null {
throw new Error('Method not implemented.');
}
removeItem(key: string): void {
this.delete(key);
}
setItem(key: string, value: string): void {
this.set(key, value);
}
/* == AppStoreKit `Storage` Interface == */
storeString(aString: string, key: string): void {
this.set(key, aString);
}
retrieveString(key: string): string {
// Fallback value designed based on how the ObjectGraph `StorageWrapper` handles that specific value
// https://github.pie.apple.com/app-store/ios-appstore-app/blob/1761d575b8dc3d7a63e7e36f3320cf9245be9f37/src/foundation/wrappers/storage.ts#L13
return this.get(key) ?? '<null>';
}
}

View File

@@ -0,0 +1,30 @@
/**
* Create an "unauthenticated" {@linkcode User} representation
*
* The property values below match the way that `AppStoreKit` will define the `user`
* when the session is not authenticated.
*/
export function makeUnauthenticatedUser(): User {
return {
accountIdentifier: undefined,
dsid: undefined,
firstName: undefined,
// Note: this property is `true` for the native apps but `false` makes
// more sense in the context of the "web" client
isFitnessAppInstallationAllowed: false,
isManagedAppleID: false,
isOnDevicePersonalizationEnabled: false,
isUnderThirteen: false,
katanaId: undefined,
lastName: undefined,
treatmentGroupIdOverride: undefined,
userAgeIfAvailable: undefined,
onDevicePersonalizationDataContainerForAppIds(appIds) {
return {
personalizationData: {},
metricsData: {},
};
},
};
}

View File

@@ -0,0 +1,68 @@
import type { IntentController } from '@jet/environment/dispatching';
import type { RouteProvider } from '@jet/environment/routing';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
import { makeChartsPageURL } from '@jet-app/app-store/common/charts/charts-page-url';
import { makeChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent';
import { GenericPage } from '@jet-app/app-store/api/models';
import { isPreviewPlatform } from '@jet-app/app-store/api/models/preview-platform';
import { notFoundError } from '@jet-app/app-store/foundation/media/network';
const makeIntent = (opts) => ({
...opts,
$kind: 'ChartsPageRedirect',
});
// This will catch URLs like `/charts/iphone`
const { routes: routesWithoutGenreId } = generateRoutes(
makeIntent,
'/charts/{platform}',
);
// This will catch URLs like `/charts/iphone/utilities-apps/6002`
const { routes: routesWithGenreId } = generateRoutes(
makeIntent,
'/charts/{platform}/{slug}/{genreId}',
);
function chartsPageRedirectRoutes(objectGraph: AppStoreObjectGraph) {
return [
...routesWithoutGenreId(objectGraph),
...routesWithGenreId(objectGraph),
];
}
export const ChartsPageRedirectIntentController: IntentController<any> &
RouteProvider = {
$intentKind: 'ChartsPageRedirect',
routes: chartsPageRedirectRoutes,
async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
return await withActiveIntent(
objectGraphWithoutActiveIntent,
intent,
async (objectGraph) => {
const page = new GenericPage([]);
const chartPageIntent = makeChartsPageIntent(intent);
if (!isPreviewPlatform(intent.platform)) {
throw notFoundError();
}
// Setting the `canonicalUrl` on the page to normal Charts Page URL (e.g. /{platform}/charts)
// will trigger a 301 redirect to the that page.
page.canonicalURL = makeChartsPageURL(
objectGraph,
chartPageIntent,
);
injectWebNavigation(objectGraph, page, intent.platform);
return page;
},
);
},
};

View File

@@ -0,0 +1,52 @@
import type { Intent, IntentController } from '@jet/environment/dispatching';
import type { Opt } from '@jet/environment/types/optional';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
import { ErrorPage } from '~/jet/models/error-page';
import type { Page } from '~/jet/models/page';
import { getRejectedIntent } from '~/jet/utils/error-metadata';
import { isWithPlatform } from '~/jet/utils/with-platform';
interface ErrorPageIntent extends Intent<Page> {
$kind: 'ErrorPageIntent';
error: Opt<Error>;
}
export function makeErrorPageIntent(
options: Omit<ErrorPageIntent, '$kind'>,
): ErrorPageIntent {
return {
...options,
$kind: 'ErrorPageIntent',
};
}
export const ErrorPageIntentController: IntentController<ErrorPageIntent> = {
$intentKind: 'ErrorPageIntent',
async perform(
intent,
objectGraphWithoutActiveIntent: AppStoreObjectGraph,
): Promise<Page> {
const { error } = intent;
const rejectedIntent = error ? getRejectedIntent(error) : null;
const platform =
(rejectedIntent && isWithPlatform(rejectedIntent)
? rejectedIntent.platform
: null) ?? 'iphone';
return await withActiveIntent(
objectGraphWithoutActiveIntent,
{ ...intent, platform },
async (objectGraph) => {
const page = new ErrorPage({ error: intent.error });
injectWebNavigation(objectGraph, page, platform);
return page;
},
);
},
};

View File

@@ -0,0 +1,18 @@
import type { IntentController } from '@jet/environment/dispatching/base/intent-controller';
import type { LintedMetricsEvent } from '@jet/environment/types/metrics';
import {
type LintMetricsEventIntent,
LintMetricsEventIntentKind,
} from './lint-metrics-event-intent';
export const LintMetricsEventIntentController: IntentController<LintMetricsEventIntent> =
{
$intentKind: LintMetricsEventIntentKind.Name,
async perform(
intent: LintMetricsEventIntent,
): Promise<LintedMetricsEvent> {
return { fields: intent.fields };
},
};

View File

@@ -0,0 +1,23 @@
import type { Intent } from '@jet/environment/dispatching';
import type {
LintedMetricsEvent,
MetricsFields,
} from '@jet/environment/types/metrics';
export const enum LintMetricsEventIntentKind {
Name = 'LintMetricsEventIntent',
}
export interface LintMetricsEventIntent extends Intent<LintedMetricsEvent> {
$kind: LintMetricsEventIntentKind.Name;
fields: MetricsFields;
}
export function makeLintMetricsEventIntent(
options: Omit<LintMetricsEventIntent, '$kind'>,
): LintMetricsEventIntent {
return {
...options,
$kind: LintMetricsEventIntentKind.Name,
};
}

View File

@@ -0,0 +1,28 @@
import { isSome } from '@jet/environment/types/optional';
import type { IntentController } from '@jet/environment/dispatching';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { isRoutableIntent } from '@jet-app/app-store/api/intents/routable-intent';
import type { RouteUrlIntent } from '~/jet/intents';
import { makeFlowAction } from '~/jet/models';
export const RouteUrlIntentController: IntentController<RouteUrlIntent> = {
$intentKind: 'RouteUrlIntent',
async perform(intent: RouteUrlIntent, objectGraph: AppStoreObjectGraph) {
const targetIntent = objectGraph.router.intentFor(intent.url);
if (isSome(targetIntent) && isRoutableIntent(targetIntent)) {
return {
// intent needed for SSR
intent: targetIntent,
// only ever used by client; only clients have actions
action: makeFlowAction(targetIntent),
storefront: targetIntent.storefront,
language: targetIntent.language,
};
}
return null;
},
};

View File

@@ -0,0 +1,48 @@
import type { Optional } from '@jet/environment/types/optional';
import type { Intent } from '@jet/environment/dispatching';
import type { FlowAction } from '@jet-app/app-store/api/models';
import type {
NormalizedStorefront,
NormalizedLanguage,
} from '@jet-app/app-store/api/locale';
/**
* A response from the router given an incoming (deeplink) URL.
*/
export interface RouterResponse {
/**
* The intent to dispatch to get the view model for this URL.
*/
intent: Intent<unknown>;
/**
* action to navigate to a new page of the app.
*/
action: FlowAction;
storefront: NormalizedStorefront;
language: NormalizedLanguage;
}
export interface RouteUrlIntent extends Intent<Optional<RouterResponse>> {
$kind: 'RouteUrlIntent';
/**
* The URL to route (ex. "https://podcasts.apple.com/us/show/serial/id123").
*/
url: string;
}
export function isRouteUrlIntent(
intent: Intent<unknown>,
): intent is RouteUrlIntent {
return intent.$kind === 'RouteUrlIntent';
}
export function makeRouteUrlIntent(
options: Omit<RouteUrlIntent, '$kind'>,
): RouteUrlIntent {
return { $kind: 'RouteUrlIntent', ...options };
}

View File

@@ -0,0 +1,41 @@
import type { IntentController } from '@jet/environment/dispatching';
import type { RouteProvider } from '@jet/environment/routing';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
import { StaticMessagePage } from '~/jet/models/static-message-page';
const { routes, makeCanonicalUrl } = generateRoutes(
(opts) => ({
...opts,
$kind: 'CarrierPageIntent',
}),
'/carrier',
);
export const CarrierPageIntentController: IntentController<any> &
RouteProvider = {
$intentKind: 'CarrierPageIntent',
routes,
async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
return await withActiveIntent(
objectGraphWithoutActiveIntent,
intent,
async (objectGraph) => {
const page = new StaticMessagePage({
titleLocKey: 'ASE.Web.AppStore.Carrier.Title',
contentType: 'carrier',
});
page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
injectWebNavigation(objectGraph, page, intent.platform);
return page;
},
);
},
};

View File

@@ -0,0 +1,49 @@
import type { IntentController } from '@jet/environment/dispatching';
import type { RouteProvider } from '@jet/environment/routing';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
import { StaticMessagePage } from '~/jet/models/static-message-page';
const { routes, makeCanonicalUrl } = generateRoutes(
(opts) => ({
...opts,
$kind: 'ContingentPriceIntent',
}),
'/contingent-price/{offerId}',
[],
{
extraRules: [
{
regex: [/(?:\/[a-z]{2})?\/contingent-price/],
},
],
},
);
export const ContingentPricingIntentController: IntentController<any> &
RouteProvider = {
$intentKind: 'ContingentPriceIntent',
routes,
async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
return await withActiveIntent(
objectGraphWithoutActiveIntent,
intent,
async (objectGraph) => {
const page = new StaticMessagePage({
titleLocKey: 'ASE.Web.AppStore.WinBack.Title',
contentType: 'contingent-price',
});
page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
injectWebNavigation(objectGraph, page, intent.platform);
return page;
},
);
},
};

View File

@@ -0,0 +1,41 @@
import type { IntentController } from '@jet/environment/dispatching';
import type { RouteProvider } from '@jet/environment/routing';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
import { StaticMessagePage } from '~/jet/models/static-message-page';
const { routes, makeCanonicalUrl } = generateRoutes(
(opts) => ({
...opts,
$kind: 'InvoicePageIntent',
}),
'/invoice',
);
export const InvoicePageIntentController: IntentController<any> &
RouteProvider = {
$intentKind: 'InvoicePageIntent',
routes,
async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
return await withActiveIntent(
objectGraphWithoutActiveIntent,
intent,
async (objectGraph) => {
const page = new StaticMessagePage({
titleLocKey: 'ASE.Web.AppStore.Invoice.Title',
contentType: 'invoice',
});
page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
injectWebNavigation(objectGraph, page, intent.platform);
return page;
},
);
},
};

View File

@@ -0,0 +1,49 @@
import type { IntentController } from '@jet/environment/dispatching';
import type { RouteProvider } from '@jet/environment/routing';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent';
import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes';
import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation';
import { StaticMessagePage } from '~/jet/models/static-message-page';
const { routes, makeCanonicalUrl } = generateRoutes(
(opts) => ({
...opts,
$kind: 'WinBackPageIntent',
}),
'/win-back/{offerId}',
[],
{
extraRules: [
{
regex: [/(?:\/[a-z]{2})?\/win-back/],
},
],
},
);
export const WinBackPageIntentController: IntentController<any> &
RouteProvider = {
$intentKind: 'WinBackPageIntent',
routes,
async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) {
return await withActiveIntent(
objectGraphWithoutActiveIntent,
intent,
async (objectGraph) => {
const page = new StaticMessagePage({
titleLocKey: 'ASE.Web.AppStore.WinBack.Title',
contentType: 'win-back',
});
page.canonicalURL = makeCanonicalUrl(objectGraph, intent);
injectWebNavigation(objectGraph, page, intent.platform);
return page;
},
);
},
};

320
src/jet/jet.ts Normal file
View File

@@ -0,0 +1,320 @@
import type I18N from '@amp/web-apps-localization';
import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import type { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime';
import type {
NormalizedStorefront,
NormalizedLanguage,
} from '@jet-app/app-store/api/locale';
import type {
LintedMetricsEvent,
MetricsFields,
PageMetrics,
} from '@jet/environment/types/metrics';
import { type Opt } from '@jet/environment/types/optional';
import type { Intent, IntentReturnType } from '@jet/environment/dispatching';
import {
type ActionImplementation,
ActionDispatcher,
type ActionOutcome,
type MetricsBehavior,
} from '@jet/engine';
import { Metrics } from '@amp/web-apps-metrics-8';
import { makeMetricsSettings } from '~/jet/metrics/settings';
import { makeMetricsProviders } from '~/jet/metrics/providers';
import { config as metricsConfig } from '~/config/metrics';
import { bootstrap } from '~/jet/bootstrap';
import { makeDependencies } from '~/jet/dependencies';
import type { Locale } from '~/jet/dependencies/locale';
import type { WebLocalization } from '~/jet/dependencies/localization';
import {
type RouterResponse,
type RouteUrlIntent,
makeRouteUrlIntent,
makeLintMetricsEventIntent,
} from '~/jet/intents';
import type { Page, ActionModel } from '~/jet/models';
import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
import { CONTEXT_NAME } from '~/jet/svelte';
import type { FeaturesCallbacks } from './dependencies/net';
/**
* The entry point for interacting with the Jet shared business logic.
*/
export class Jet {
private readonly log: Logger;
private readonly runtime: AppStoreRuntime;
private readonly actionDispatcher: ActionDispatcher;
private readonly metrics: Metrics;
private readonly locale: Locale;
/**
* Intents (and their resolved data) that have yet to be dispatched that
* were recently dispatched. These are consulted before dispatching
* intents. If a prefetched intent exists for an ongoing dispatch, it will
* be used as the return value instead of actually dispatching.
*
* This can be used, for example, for intents that are dispatched during
* SSR. The server can serialize the intents it dispatches and then the
* client can populate this, to avoid re-dispatching the intents.
*/
private readonly prefetchedIntents: PrefetchedIntents;
/**
* A set of the action types that already have registered implementations to catch
* double registers.
*/
private readonly wiredActions: Set<string>;
readonly objectGraph: AppStoreObjectGraph;
readonly localization: WebLocalization;
static load({
loggerFactory,
context,
fetch,
prefetchedIntents = PrefetchedIntents.empty(),
featuresCallbacks,
}: {
loggerFactory: LoggerFactory;
context: Map<string, unknown>;
fetch: typeof window.fetch;
prefetchedIntents?: PrefetchedIntents;
featuresCallbacks?: FeaturesCallbacks;
}): Jet {
const dependencies = makeDependencies(
loggerFactory,
fetch,
featuresCallbacks,
);
const { runtime, objectGraph } = bootstrap(dependencies);
let jet: Jet;
const processEvent = async (
fields: MetricsFields,
): Promise<LintedMetricsEvent> => {
const intent = makeLintMetricsEventIntent({ fields });
return jet.dispatch(intent);
};
const metrics = Metrics.load(
loggerFactory,
context,
processEvent,
metricsConfig,
makeMetricsProviders(objectGraph),
makeMetricsSettings(context),
);
const actionDispatcher = new ActionDispatcher(
// `@amp/web-apps-metrics` depends on a different version of `@jet/engine` with a different
// type definition for `MetricsPipeline`
// @ts-expect-error
metrics.metricsPipeline,
);
jet = new Jet(
loggerFactory.loggerFor('Jet'),
runtime,
objectGraph,
actionDispatcher,
metrics,
dependencies.locale,
prefetchedIntents,
dependencies.localization,
);
context.set(CONTEXT_NAME, jet);
return jet;
}
private constructor(
log: Logger,
runtime: AppStoreRuntime,
objectGraph: AppStoreObjectGraph,
actionDispatcher: ActionDispatcher,
metrics: Metrics,
locale: Locale,
prefetchedIntents: PrefetchedIntents,
localization: WebLocalization,
) {
this.log = log;
this.runtime = runtime;
this.objectGraph = objectGraph;
this.actionDispatcher = actionDispatcher;
this.metrics = metrics;
this.locale = locale;
this.localization = localization;
this.prefetchedIntents = prefetchedIntents;
this.wiredActions = new Set();
}
async didEnterPage(page: Page | null): Promise<void> {
// This is a very temporary hacky fix to move the `platformContext` value from
// `pageRenderFields` to `pageFields`, which will eventually happen in the Jet
// business logic.
const pageWithMetrics = { ...page };
if (pageWithMetrics.pageMetrics?.pageFields) {
pageWithMetrics.pageMetrics.pageFields.platformContext =
pageWithMetrics.pageMetrics.pageRenderFields?.platformContext;
}
// @ts-expect-error - pageMetrics property not required at runtime
await this.metrics.didEnterPage(page);
}
get pageMetrics(): Opt<PageMetrics> {
return this.metrics.currentPageMetrics?.pageMetrics;
}
/**
* Dispatch a Jet intent, returning its output.
*
* @param intent The intent to dispatch
* @return output The value returned by the intent's controller
*/
async dispatch<I extends Intent<unknown>>(
intent: I,
): Promise<IntentReturnType<I>> {
const data = this.prefetchedIntents.get(intent);
if (data) {
this.log.info(
're-using prefetched intent response for:',
intent,
'data:',
data,
);
return data;
}
// TODO: rdar://73165545 (Error Handling Across App)
return this.runtime.dispatch(intent);
}
/**
* Perform a Jet action, returning the outcome.
*
* @param action The action to perform
* @param metricsBehavior Indicates how to handle metrics for this action
* @return outcome Either 'performed' or 'unsupported'
*/
async perform(
action: ActionModel,
metricsBehavior?: MetricsBehavior,
): Promise<ActionOutcome> {
if (!metricsBehavior) {
if (this.pageMetrics) {
metricsBehavior = {
behavior: 'fromAction',
context: this.pageMetrics || {},
};
} else {
this.log.warn(
'No pageMetrics found for jet.perform action:',
action,
);
metricsBehavior = { behavior: 'notProcessed' };
}
}
// TODO: rdar://73165545 (Error Handling Across App): handle throw
const outcome = await this.actionDispatcher.perform(
action,
metricsBehavior,
);
if (outcome === 'unsupported') {
this.log.error(
'unable to perform action:',
action,
metricsBehavior,
);
}
return outcome;
}
/**
* Register an implementation to handle a Jet action.
*
* @param kind The type of the action
* @param implementation The code to run when that action is performed
*/
onAction<A extends ActionModel>(
kind: string,
implementation: ActionImplementation<A>,
): void {
if (this.wiredActions.has(kind)) {
throw new Error(
`onAction called twice with the same action type: ${kind}`,
);
}
this.actionDispatcher.register(kind, implementation);
this.wiredActions.add(kind);
}
/**
* Route a URL using Jet, returning the routing if the URL could be routed.
*
* @param url The URL to route
* @return routing The routing of the URL or null if unrouteable
*/
async routeUrl(url: string): Promise<RouterResponse | null> {
// TODO: rdar://73165545 (Error Handling Across App): what about 404s?
const routerResponse = await this.dispatch<RouteUrlIntent>(
makeRouteUrlIntent({ url }),
);
if (routerResponse && routerResponse.action) {
return routerResponse;
}
this.log.warn(
'url did not resolve to a flow action with a discernable intent:',
url,
routerResponse,
);
return null;
}
/**
* Propagates the routing-derrived localization information through the Jet app
*
* The {@link Locale} instance that is configured here is referenced by
* the rest of our Jet dependencies in order to lazily retreive the locale
* information.
*
* @param localizer
* @param storefront
* @param language
*/
setLocale(
localizer: I18N,
storefront: NormalizedStorefront,
language: NormalizedLanguage,
): void {
this.locale.i18n = localizer;
this.locale.setActiveLocale({ storefront, language });
}
recordCustomMetricsEvent(fields?: Opt<MetricsFields>) {
this.metrics.recordCustomEvent(fields);
}
enableFunnelKit(): void {
this.metrics.enableFunnelKit();
}
disableFunnelKit(): void {
this.metrics.disableFunnelKit();
}
// TODO: rdar://75011660 (Bridge Jet to MetricsKit and PerfKit for reporting)
}

View File

@@ -0,0 +1,19 @@
import type {
MetricsFieldsBuilder,
MetricsFieldsContext,
MetricsFieldsProvider,
} from '@jet/engine';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { getLocale } from '@jet-app/app-store/common/locale';
export class StorefrontFieldsProvider implements MetricsFieldsProvider {
constructor(private readonly objectGraph: AppStoreObjectGraph) {}
addMetricsFields(
builder: MetricsFieldsBuilder,
_context: MetricsFieldsContext,
) {
const { storefront } = getLocale(this.objectGraph);
builder.addValue(storefront, 'storeFrontCountryCode');
}
}

View File

@@ -0,0 +1,15 @@
import type { MetricsProvider } from '@amp/web-apps-metrics-8';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { StorefrontFieldsProvider } from './StorefrontFieldsProvider';
export function makeMetricsProviders(
objectGraph: AppStoreObjectGraph,
): MetricsProvider[] {
return [
{
provider: new StorefrontFieldsProvider(objectGraph),
request: 'storeFrontCountryCode',
},
];
}

View File

@@ -0,0 +1,20 @@
import type { MetricSettings } from '@amp/web-apps-metrics-8';
/**
* Generates a metric settings for Metrics class.
*
* @param context - app context map
* @returns MetricSettings
*/
export function makeMetricsSettings(
context: Map<string, unknown>,
): MetricSettings {
return {
shouldEnableFunnelKit: function (): boolean {
return false;
},
getConsumerId: async function (): Promise<string> {
return null;
},
};
}

View File

@@ -0,0 +1,15 @@
import { GenericPage } from '@jet-app/app-store/api/models';
import type { Opt } from '@jet/environment';
export class ErrorPage extends GenericPage {
constructor({ error }: { error: Opt<Error> }) {
super([]);
this.error = error;
}
// Used in our type guards to narrow a `Page` down to a `ErrorPage`
pageType: string = 'errorPage';
// The browser `Error`, used to determine which message to display to the user
error: Opt<Error>;
}

View File

@@ -0,0 +1,7 @@
import type { Action, ExternalUrlAction } from '@jet-app/app-store/api/models';
export function isExternalUrlAction(
action: Action,
): action is ExternalUrlAction {
return action.$kind === 'ExternalUrlAction';
}

View File

@@ -0,0 +1,28 @@
import type { Intent } from '@jet/environment/dispatching';
import { FlowAction } from '@jet-app/app-store/api/models';
export const FLOW_ACTION_KIND: FlowAction['$kind'] = 'flowAction';
/**
* Creates a FlowAction For a given destination.
*
* Note: this is only here temporarily as a convenience for the "web" client, to be used
* while the upstream `FlowAction` is represented as a class that needs to be constructed,
* so those details are abstracted away from our codebase. Once `FlowAction` has been
* migrated to a POJO, there should be a factory-function provided that we should leverage
* instead
*
* @param destination Destination of the `FlowAction`
*/
export function makeFlowAction(destination: Intent<unknown>): FlowAction {
const action = new FlowAction(
// This data is only used by the Jet app's `PageRouter` architecture, which is not
// relevant for us. We should safely be able to pass an arbitrary value here.
'page',
);
// The important part for the "web" client router: setting the `destination`
action.destination = destination;
return action;
}

177
src/jet/models/page.ts Normal file
View File

@@ -0,0 +1,177 @@
import type {
ArticlePage,
ChartsHubPage,
GenericPage,
SearchLandingPage,
SearchResultsPage,
ShelfBasedProductPage,
TopChartsPage,
TodayPage,
SeeAllPage,
} from '@jet-app/app-store/api/models';
import { StaticMessagePage } from '~/jet/models/static-message-page';
import { isObject } from '~/utils/types';
import { ErrorPage } from './error-page';
import type { WebRenderablePage } from 'node_modules/@jet-app/app-store/src/api/models/web-renderable-page';
/**
* The union of every type of page that the App Store Onyx app can render
*/
export type Page = (
| ArticlePage
| ChartsHubPage
| GenericPage
| SearchLandingPage
| SearchResultsPage
| ShelfBasedProductPage
| StaticMessagePage
| TopChartsPage
| TodayPage
| ErrorPage
) &
// TS needs to be told this explicitly, even though all the above implement this
WebRenderablePage;
/**
* Detects if {@linkcode page} is actually an {@linkcode AppEventDetailPage}
*/
export function isAppEventDetailPage(page: Page): page is GenericPage {
return (
'shelves' in page &&
page.shelves.some(({ contentType }) => contentType === 'appEventDetail')
);
}
/**
* Detects if {@linkcode page} is actually an {@linkcode ArticlePage}
*/
export function isArticlePage(page: Page): page is ArticlePage {
return 'card' in page && 'shelves' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode ChartsHubPage}
*/
export function isChartsHubPage(page: Page): page is ChartsHubPage {
return 'charts' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode GenericPage}
*/
export function isGenericPage(page: Page): page is GenericPage {
return 'shelves' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode ShelfBasedProductPage}
*/
export function isShelfBasedProductPage(
page: Page,
): page is ShelfBasedProductPage {
return 'shelfMapping' in page && !('seeAllType' in page);
}
/**
* Detects if {@linkcode page} is actually a {@linkcode SeeAllPage}
*/
export function isSeeAllPage(page: Page): page is SeeAllPage {
return 'seeAllType' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode SearchLandingPage}
*/
export function isSearchLandingPage(page: Page): page is SearchLandingPage {
return 'adIncidents' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode SearchResultsPage}
*/
export function isSearchResultsPage(page: Page): page is SearchResultsPage {
return 'searchClearAction' in page || 'searchCancelAction' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode TopChartsPage}
*/
export function isTopChartsPage(page: Page): page is TopChartsPage {
return 'segments' in page && 'categories' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode TodayPage}
*/
export function isTodayPage(page: Page): page is TodayPage {
return 'titleDetail' in page;
}
/**
* Detects if {@linkcode page} is actually a {@linkcode StaticMessagePage}
*/
export function isStaticMessagePage(
page: GenericPage,
): page is StaticMessagePage {
return 'pageType' in page && page.pageType === 'staticMessagePage';
}
export function isErrorPage(page: GenericPage) {
return 'pageType' in page && page.pageType === 'errorPage';
}
/**
* Type-guard that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition
*/
export function isPage(page: unknown): page is Page {
if (!isObject(page)) {
return false;
}
return [
isAppEventDetailPage,
isArticlePage,
isChartsHubPage,
isGenericPage,
isShelfBasedProductPage,
isSearchLandingPage,
isSearchResultsPage,
isTopChartsPage,
isTodayPage,
isErrorPage,
isSeeAllPage,
].some((specificPageTypePredicate) =>
specificPageTypePredicate(
// This type-cast reflects the fact that we don't really know if `page` is really a `Page`,
// but that we're going to use the type-guards of our `Page` members to see if `page` looks
// like one of them
page as Page,
),
);
}
/**
* Type-assertion that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition
*/
export function assertIsPage(page: unknown): asserts page is Page {
if (!isPage(page)) {
throw new Error(
'The view-model for the dispatched `Intent` does not match a known renderable shape',
);
}
}
/**
* Detects if {@linkcode page} has the Vision Pro pathname in it's URL.
*/
export function hasVisionProUrl(page: GenericPage) {
if (!page.canonicalURL) {
return false;
}
const url = new URL(page.canonicalURL);
return (
url.pathname.includes('/vision/apps-and-games') ||
url.pathname.includes('/vision/arcade')
);
}

View File

@@ -0,0 +1,33 @@
import { GenericPage } from '@jet-app/app-store/api/models';
const contentTypes = [
'win-back',
'carrier',
'invoice',
'contingent-price',
] as const;
export type ContentType = (typeof contentTypes)[number];
export class StaticMessagePage extends GenericPage {
constructor({
titleLocKey,
contentType,
}: {
titleLocKey: string;
contentType: ContentType;
}) {
super([]);
this.titleLocKey = titleLocKey;
this.contentType = contentType;
}
titleLocKey?: string;
// Used to indicate which type of content the page needs to show, used to pull in the proper
// LOC keys when rendering
contentType: ContentType;
// Used in our type guards to narrow a `Page` down to a `StaticMessagePage`
pageType: string = 'staticMessagePage';
}

45
src/jet/svelte.ts Normal file
View File

@@ -0,0 +1,45 @@
import { getContext } from 'svelte';
import type { Opt } from '@jet/environment';
import type { ActionOutcome } from '@jet/engine';
import type { ActionModel } from '~/jet/models';
import type { Jet } from '~/jet/jet';
export const CONTEXT_NAME = 'jet';
/**
* Gets the current Jet instance from the Svelte context.
*
* @return jet The current instance of Jet
*/
export function getJet(): Jet {
const jet = getContext<Opt<Jet>>(CONTEXT_NAME);
if (!jet) {
throw new Error('getJet called before Jet.load');
}
return jet;
}
/**
* Jet helper to expose jet.perform in single location
*
* @return Promise<ActionOutcome>
*/
type ActionUndefined = 'noActionProvided';
export function getJetPerform(): (
action: ActionModel,
) => Promise<ActionOutcome | ActionUndefined> {
const jet = getJet();
return (action: ActionModel) => {
if (!action) {
//TODO: rdar://73165545 (Error Handling Across App)
return Promise.resolve('noActionProvided');
}
return jet.perform(action);
};
}

View File

@@ -0,0 +1,194 @@
import {
type Optional,
isSome,
isNothing,
} from '@jet/environment/types/optional';
import type { LocalizationWrapper } from '@jet-app/app-store/foundation/wrappers/localization';
import type {
AppEventFormattedDate,
AppEventBadgeKind,
} from '@jet-app/app-store/api/models';
import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
import { formattedDatesWithKind } from '@jet-app/app-store/common/app-promotions/app-event';
/**
* Partial type of {@linkcode AppEventFormattedDate} with just the properties
* that are actually used
*/
export type RequiredAppEventFormattedDate = Pick<
AppEventFormattedDate,
'displayText' | 'displayFromDate' | 'countdownToDate' | 'countdownStringKey'
>;
/**
* Represents a client-side serialization of an {@linkcode RequiredAppEventFormattedDate}
*
* This is needed because our client-side code will receive the event object with `Date` properties
* serialized as ISO 8601-formatted strings, while the server-side code will receive the original
* `Date` values. We need to normalize this to make sure we have consistent logic in both environments
*/
type SerializedAppEventFormattedDate = Pick<
RequiredAppEventFormattedDate,
'displayText' | 'countdownStringKey'
> & {
readonly displayFromDate?: string;
readonly countdownToDate?: string;
};
function deserializeDate(value: Optional<Date | string>): Date | undefined {
if (isNothing(value)) {
return undefined;
}
return typeof value === 'string' ? new Date(value) : value;
}
/**
* Turn {@linkcode date} in either the client- or server-side format into the
* server-side format by parsing the ISO 8601 string values into `Date` instances
*/
function deserializeDateProperties(
date: SerializedAppEventFormattedDate | RequiredAppEventFormattedDate,
): RequiredAppEventFormattedDate {
const { countdownToDate, displayFromDate, ...rest } = date;
return {
// Normalize properties that might have been serialized as `string` to `Date`
countdownToDate: deserializeDate(countdownToDate),
displayFromDate: deserializeDate(displayFromDate),
// Use all of the other properties with their existing values
...rest,
};
}
/**
* A {@linkcode RequiredAppEventFormattedDate} with a definitely-defined `.displayFromDate` property
*/
type AppEventFormattedDateWithDisplayFromDate =
RequiredAppEventFormattedDate & {
readonly displayFromDate: Date;
};
function hasDisplayRequirement(
date: RequiredAppEventFormattedDate,
): date is AppEventFormattedDateWithDisplayFromDate {
return isSome(date.displayFromDate);
}
export function chooseAppEventDate(
dates: (SerializedAppEventFormattedDate | RequiredAppEventFormattedDate)[],
): Optional<RequiredAppEventFormattedDate> {
const nowTime = Date.now();
// We might be passed `dates` in the expected format (server-side) or with their `Date`
// properties serialized as strings (client-side); we need to normalize them all to the
// same format
const normalizedDates = dates.map((date) =>
deserializeDateProperties(date),
);
// A `dates` member might not have a `.displayFromDate`; if that's the case, we will
// use that as a fallback if all other options are in the future
const fallback = normalizedDates.find(
(date) => !hasDisplayRequirement(date),
);
// Find all of the `dates` members with a `.displayFromDate` in the past
const optionsWithPastDisplayFromDates = normalizedDates
// Ensure all `date` objects have a display requirement
.filter((date) => hasDisplayRequirement(date))
// Filter out any `date` objects with a display requirement in the future
.filter((date) => {
const dateTime = date.displayFromDate.getTime();
const timeDifference = nowTime - dateTime;
return timeDifference > 0;
});
// If there are none, use the fallback
if (optionsWithPastDisplayFromDates.length === 0) {
return fallback;
}
// Otherwise, find the `date` object with the most recent `.displayFromDate`
return optionsWithPastDisplayFromDates.reduce((acc, next) => {
const accTime = acc.displayFromDate.getTime();
const nextTime = next.displayFromDate.getTime();
// Which time is closer to "now"?
const accTimeDiff = nowTime - accTime;
const nextTimeDiff = nowTime - nextTime;
return accTimeDiff > nextTimeDiff ? next : acc;
});
}
/**
* Partial type of {@linkcode LocalizationWrapper} with just the methods that
* are actually called
*
* This partial type simplifies testing by reducing the surface area of the function's
* dependencies
*/
type RequiredLocalization = Pick<LocalizationWrapper, 'string'>;
function msToMinutes(ms: number): number {
return ms / (1_000 * 60);
}
export function renderDate(
localization: RequiredLocalization,
date: RequiredAppEventFormattedDate,
): Optional<string> {
if (typeof date.countdownStringKey === 'string' && date.countdownToDate) {
const nowTime = Date.now();
const translationString = localization.string(date.countdownStringKey);
const countdownToDateTime = date.countdownToDate.getTime();
const diffTime = countdownToDateTime - nowTime;
const count = Math.floor(msToMinutes(diffTime));
return translationString.replace('@@count@@', count.toString());
}
if (typeof date.displayText === 'string') {
return date.displayText;
}
return undefined;
}
/**
* Helper function to compute formatted dates for app events.
* Handles date conversion and error handling.
*
* @param objectGraph - objectGraph from Jet
* @param badgeKind - The badge kind from the app event
* @param startDate - The start date (string or Date)
* @param endDate - The optional end date (string or Date)
* @returns Array of formatted dates or undefined if an error occurs
*/
export function computeAppEventFormattedDates(
objectGraph: AppStoreObjectGraph,
badgeKind: AppEventBadgeKind,
startDate: string | Date,
endDate?: string | Date | null,
): RequiredAppEventFormattedDate[] | undefined {
// Use deserializeDate function to convert dates
const startDateObj = deserializeDate(startDate);
const endDateObj = deserializeDate(endDate);
// Validate that we have a valid start date
if (!startDateObj || isNaN(startDateObj.getTime())) {
return undefined;
}
return formattedDatesWithKind(
objectGraph,
badgeKind,
startDateObj,
endDateObj,
);
}

View File

@@ -0,0 +1,16 @@
import type { Opt } from '@jet/environment';
import type { Intent } from '@jet/environment/dispatching';
export function addRejectedIntent(error: Error, intent: Intent<unknown>) {
(error as any).rejectedIntent = intent;
}
export function getRejectedIntent(error: Error): Opt<Intent<unknown>> {
return hasRejectedIntent(error) ? error.rejectedIntent : null;
}
function hasRejectedIntent(
error: Error,
): error is Error & { rejectedIntent: Intent<unknown> } {
return 'rejectedIntent' in error;
}

View File

@@ -0,0 +1,29 @@
import { getModalPageStore } from '~/stores/modalPage';
import { isGenericPage, type Page } from '../models';
import type { Logger } from '@amp/web-apps-logger/src';
/**
* This function handles rendering flow action pages into a modal container.
* NOTE: Rendering a page in a modal will not update URL or history
*
* @param page page promise
* @param log app logger
*/
export const handleModalPresentation = (
page: { promise: Promise<Page> },
log: Logger<unknown[]>,
pageDetail?: string,
) => {
page.promise
.then((page) => {
if (isGenericPage(page)) {
const modalStore = getModalPageStore();
modalStore.setPage({ page, pageDetail });
} else {
throw new Error('only generic page is rendered in modal');
}
})
.catch((e) => {
log.error('modal presentation failed', e);
});
};

View File

@@ -0,0 +1,5 @@
import type { WithPlatform } from 'node_modules/@jet-app/app-store/src/api/models/preview-platform';
export function isWithPlatform(x: unknown): x is WithPlatform {
return typeof x === 'object' && x !== null && 'platform' in x;
}