summaryrefslogtreecommitdiff
path: root/src/jet
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/jet
init commit
Diffstat (limited to 'src/jet')
-rw-r--r--src/jet/action-handlers/browser.ts16
-rw-r--r--src/jet/action-handlers/compound-action.ts33
-rw-r--r--src/jet/action-handlers/external-url-action.ts19
-rw-r--r--src/jet/action-handlers/flow-action.ts369
-rw-r--r--src/jet/bootstrap.ts125
-rw-r--r--src/jet/dependencies/bag.ts290
-rw-r--r--src/jet/dependencies/client.ts96
-rw-r--r--src/jet/dependencies/console.ts26
-rw-r--r--src/jet/dependencies/feature-flags.ts20
-rw-r--r--src/jet/dependencies/locale.ts99
-rw-r--r--src/jet/dependencies/localization.ts523
-rw-r--r--src/jet/dependencies/make-dependencies.ts45
-rw-r--r--src/jet/dependencies/media-token-service.ts11
-rw-r--r--src/jet/dependencies/metrics-identifiers.ts13
-rw-r--r--src/jet/dependencies/net.ts117
-rw-r--r--src/jet/dependencies/object-graph.ts59
-rw-r--r--src/jet/dependencies/properties.ts5
-rw-r--r--src/jet/dependencies/seo.ts254
-rw-r--r--src/jet/dependencies/storage.ts44
-rw-r--r--src/jet/dependencies/user.ts30
-rw-r--r--src/jet/intents/charts-page-redirect-intent-controller.ts68
-rw-r--r--src/jet/intents/error-page-intent-controller.ts52
-rw-r--r--src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts18
-rw-r--r--src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts23
-rw-r--r--src/jet/intents/route-url/route-url-controller.ts28
-rw-r--r--src/jet/intents/route-url/route-url-intent.ts48
-rw-r--r--src/jet/intents/static-message-pages/carrier-page-intent-controller.ts41
-rw-r--r--src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts49
-rw-r--r--src/jet/intents/static-message-pages/invoice-page-intent-controller.ts41
-rw-r--r--src/jet/intents/static-message-pages/win-back-page-intent-controller.ts49
-rw-r--r--src/jet/jet.ts320
-rw-r--r--src/jet/metrics/providers/StorefrontFieldsProvider.ts19
-rw-r--r--src/jet/metrics/providers/index.ts15
-rw-r--r--src/jet/metrics/settings.ts20
-rw-r--r--src/jet/models/error-page.ts15
-rw-r--r--src/jet/models/external-action.ts7
-rw-r--r--src/jet/models/flow-action.ts28
-rw-r--r--src/jet/models/page.ts177
-rw-r--r--src/jet/models/static-message-page.ts33
-rw-r--r--src/jet/svelte.ts45
-rw-r--r--src/jet/utils/app-event-formatted-date.ts194
-rw-r--r--src/jet/utils/error-metadata.ts16
-rw-r--r--src/jet/utils/handle-modal-presentation.ts29
-rw-r--r--src/jet/utils/with-platform.ts5
44 files changed, 3534 insertions, 0 deletions
diff --git a/src/jet/action-handlers/browser.ts b/src/jet/action-handlers/browser.ts
new file mode 100644
index 0000000..08d1a5a
--- /dev/null
+++ b/src/jet/action-handlers/browser.ts
@@ -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);
+}
diff --git a/src/jet/action-handlers/compound-action.ts b/src/jet/action-handlers/compound-action.ts
new file mode 100644
index 0000000..9cf1be0
--- /dev/null
+++ b/src/jet/action-handlers/compound-action.ts
@@ -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';
+ });
+}
diff --git a/src/jet/action-handlers/external-url-action.ts b/src/jet/action-handlers/external-url-action.ts
new file mode 100644
index 0000000..a9d3769
--- /dev/null
+++ b/src/jet/action-handlers/external-url-action.ts
@@ -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';
+ });
+}
diff --git a/src/jet/action-handlers/flow-action.ts b/src/jet/action-handlers/flow-action.ts
new file mode 100644
index 0000000..4cdcc5e
--- /dev/null
+++ b/src/jet/action-handlers/flow-action.ts
@@ -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);
+}
diff --git a/src/jet/bootstrap.ts b/src/jet/bootstrap.ts
new file mode 100644
index 0000000..32b10a0
--- /dev/null
+++ b/src/jet/bootstrap.ts
@@ -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,
+ };
+}
diff --git a/src/jet/dependencies/bag.ts b/src/jet/dependencies/bag.ts
new file mode 100644
index 0000000..32f6bc7
--- /dev/null
+++ b/src/jet/dependencies/bag.ts
@@ -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);
+ }
+ }
+}
diff --git a/src/jet/dependencies/client.ts b/src/jet/dependencies/client.ts
new file mode 100644
index 0000000..6b8a979
--- /dev/null
+++ b/src/jet/dependencies/client.ts
@@ -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;
+ }
+}
diff --git a/src/jet/dependencies/console.ts b/src/jet/dependencies/console.ts
new file mode 100644
index 0000000..fe0ba64
--- /dev/null
+++ b/src/jet/dependencies/console.ts
@@ -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);
+ }
+}
diff --git a/src/jet/dependencies/feature-flags.ts b/src/jet/dependencies/feature-flags.ts
new file mode 100644
index 0000000..e745137
--- /dev/null
+++ b/src/jet/dependencies/feature-flags.ts
@@ -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;
+ }
+}
diff --git a/src/jet/dependencies/locale.ts b/src/jet/dependencies/locale.ts
new file mode 100644
index 0000000..e48e935
--- /dev/null
+++ b/src/jet/dependencies/locale.ts
@@ -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,
+ };
+ }
+}
diff --git a/src/jet/dependencies/localization.ts b/src/jet/dependencies/localization.ts
new file mode 100644
index 0000000..d6961e4
--- /dev/null
+++ b/src/jet/dependencies/localization.ts
@@ -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');
+ }
+}
diff --git a/src/jet/dependencies/make-dependencies.ts b/src/jet/dependencies/make-dependencies.ts
new file mode 100644
index 0000000..f03c7ca
--- /dev/null
+++ b/src/jet/dependencies/make-dependencies.ts
@@ -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,
+ };
+}
diff --git a/src/jet/dependencies/media-token-service.ts b/src/jet/dependencies/media-token-service.ts
new file mode 100644
index 0000000..45cae9e
--- /dev/null
+++ b/src/jet/dependencies/media-token-service.ts
@@ -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
+ }
+}
diff --git a/src/jet/dependencies/metrics-identifiers.ts b/src/jet/dependencies/metrics-identifiers.ts
new file mode 100644
index 0000000..e48c9d1
--- /dev/null
+++ b/src/jet/dependencies/metrics-identifiers.ts
@@ -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;
+ }
+}
diff --git a/src/jet/dependencies/net.ts b/src/jet/dependencies/net.ts
new file mode 100644
index 0000000..dd7fdb9
--- /dev/null
+++ b/src/jet/dependencies/net.ts
@@ -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();
+}
diff --git a/src/jet/dependencies/object-graph.ts b/src/jet/dependencies/object-graph.ts
new file mode 100644
index 0000000..40ad0a9
--- /dev/null
+++ b/src/jet/dependencies/object-graph.ts
@@ -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);
+}
diff --git a/src/jet/dependencies/properties.ts b/src/jet/dependencies/properties.ts
new file mode 100644
index 0000000..8956d7f
--- /dev/null
+++ b/src/jet/dependencies/properties.ts
@@ -0,0 +1,5 @@
+export function makeProperties(): PackageProperties {
+ return {
+ clientFeatures: {},
+ };
+}
diff --git a/src/jet/dependencies/seo.ts b/src/jet/dependencies/seo.ts
new file mode 100644
index 0000000..0938afa
--- /dev/null
+++ b/src/jet/dependencies/seo.ts
@@ -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),
+ };
+ }
+}
diff --git a/src/jet/dependencies/storage.ts b/src/jet/dependencies/storage.ts
new file mode 100644
index 0000000..fe1da2c
--- /dev/null
+++ b/src/jet/dependencies/storage.ts
@@ -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>';
+ }
+}
diff --git a/src/jet/dependencies/user.ts b/src/jet/dependencies/user.ts
new file mode 100644
index 0000000..2dad212
--- /dev/null
+++ b/src/jet/dependencies/user.ts
@@ -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: {},
+ };
+ },
+ };
+}
diff --git a/src/jet/intents/charts-page-redirect-intent-controller.ts b/src/jet/intents/charts-page-redirect-intent-controller.ts
new file mode 100644
index 0000000..06d41ce
--- /dev/null
+++ b/src/jet/intents/charts-page-redirect-intent-controller.ts
@@ -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;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/error-page-intent-controller.ts b/src/jet/intents/error-page-intent-controller.ts
new file mode 100644
index 0000000..59ac1fd
--- /dev/null
+++ b/src/jet/intents/error-page-intent-controller.ts
@@ -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;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts
new file mode 100644
index 0000000..046914b
--- /dev/null
+++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts
@@ -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 };
+ },
+ };
diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts
new file mode 100644
index 0000000..a2a085e
--- /dev/null
+++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts
@@ -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,
+ };
+}
diff --git a/src/jet/intents/route-url/route-url-controller.ts b/src/jet/intents/route-url/route-url-controller.ts
new file mode 100644
index 0000000..8c8fdb6
--- /dev/null
+++ b/src/jet/intents/route-url/route-url-controller.ts
@@ -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;
+ },
+};
diff --git a/src/jet/intents/route-url/route-url-intent.ts b/src/jet/intents/route-url/route-url-intent.ts
new file mode 100644
index 0000000..841bd25
--- /dev/null
+++ b/src/jet/intents/route-url/route-url-intent.ts
@@ -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 };
+}
diff --git a/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts
new file mode 100644
index 0000000..a1b049c
--- /dev/null
+++ b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts
@@ -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;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts
new file mode 100644
index 0000000..ba2babd
--- /dev/null
+++ b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts
@@ -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;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts
new file mode 100644
index 0000000..caa02f4
--- /dev/null
+++ b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts
@@ -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;
+ },
+ );
+ },
+};
diff --git a/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts
new file mode 100644
index 0000000..2b78ba0
--- /dev/null
+++ b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts
@@ -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;
+ },
+ );
+ },
+};
diff --git a/src/jet/jet.ts b/src/jet/jet.ts
new file mode 100644
index 0000000..75b0afc
--- /dev/null
+++ b/src/jet/jet.ts
@@ -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)
+}
diff --git a/src/jet/metrics/providers/StorefrontFieldsProvider.ts b/src/jet/metrics/providers/StorefrontFieldsProvider.ts
new file mode 100644
index 0000000..f4c5448
--- /dev/null
+++ b/src/jet/metrics/providers/StorefrontFieldsProvider.ts
@@ -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');
+ }
+}
diff --git a/src/jet/metrics/providers/index.ts b/src/jet/metrics/providers/index.ts
new file mode 100644
index 0000000..98f3780
--- /dev/null
+++ b/src/jet/metrics/providers/index.ts
@@ -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',
+ },
+ ];
+}
diff --git a/src/jet/metrics/settings.ts b/src/jet/metrics/settings.ts
new file mode 100644
index 0000000..c0c5075
--- /dev/null
+++ b/src/jet/metrics/settings.ts
@@ -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;
+ },
+ };
+}
diff --git a/src/jet/models/error-page.ts b/src/jet/models/error-page.ts
new file mode 100644
index 0000000..80bcdf5
--- /dev/null
+++ b/src/jet/models/error-page.ts
@@ -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>;
+}
diff --git a/src/jet/models/external-action.ts b/src/jet/models/external-action.ts
new file mode 100644
index 0000000..25dbd14
--- /dev/null
+++ b/src/jet/models/external-action.ts
@@ -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';
+}
diff --git a/src/jet/models/flow-action.ts b/src/jet/models/flow-action.ts
new file mode 100644
index 0000000..d5edb40
--- /dev/null
+++ b/src/jet/models/flow-action.ts
@@ -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;
+}
diff --git a/src/jet/models/page.ts b/src/jet/models/page.ts
new file mode 100644
index 0000000..a05e59f
--- /dev/null
+++ b/src/jet/models/page.ts
@@ -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')
+ );
+}
diff --git a/src/jet/models/static-message-page.ts b/src/jet/models/static-message-page.ts
new file mode 100644
index 0000000..91dafb0
--- /dev/null
+++ b/src/jet/models/static-message-page.ts
@@ -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';
+}
diff --git a/src/jet/svelte.ts b/src/jet/svelte.ts
new file mode 100644
index 0000000..f1870ca
--- /dev/null
+++ b/src/jet/svelte.ts
@@ -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);
+ };
+}
diff --git a/src/jet/utils/app-event-formatted-date.ts b/src/jet/utils/app-event-formatted-date.ts
new file mode 100644
index 0000000..c885687
--- /dev/null
+++ b/src/jet/utils/app-event-formatted-date.ts
@@ -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,
+ );
+}
diff --git a/src/jet/utils/error-metadata.ts b/src/jet/utils/error-metadata.ts
new file mode 100644
index 0000000..1322dfd
--- /dev/null
+++ b/src/jet/utils/error-metadata.ts
@@ -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;
+}
diff --git a/src/jet/utils/handle-modal-presentation.ts b/src/jet/utils/handle-modal-presentation.ts
new file mode 100644
index 0000000..9040d4f
--- /dev/null
+++ b/src/jet/utils/handle-modal-presentation.ts
@@ -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);
+ });
+};
diff --git a/src/jet/utils/with-platform.ts b/src/jet/utils/with-platform.ts
new file mode 100644
index 0000000..6e11ab8
--- /dev/null
+++ b/src/jet/utils/with-platform.ts
@@ -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;
+}