/ src / jet / jet.ts
jet.ts
  1  import type I18N from '@amp/web-apps-localization';
  2  import type { Logger, LoggerFactory } from '@amp/web-apps-logger';
  3  
  4  import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph';
  5  import type { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime';
  6  import type {
  7      NormalizedStorefront,
  8      NormalizedLanguage,
  9  } from '@jet-app/app-store/api/locale';
 10  
 11  import type {
 12      LintedMetricsEvent,
 13      MetricsFields,
 14      PageMetrics,
 15  } from '@jet/environment/types/metrics';
 16  import { type Opt } from '@jet/environment/types/optional';
 17  import type { Intent, IntentReturnType } from '@jet/environment/dispatching';
 18  import {
 19      type ActionImplementation,
 20      ActionDispatcher,
 21      type ActionOutcome,
 22      type MetricsBehavior,
 23  } from '@jet/engine';
 24  
 25  import { Metrics } from '@amp/web-apps-metrics-8';
 26  import { makeMetricsSettings } from '~/jet/metrics/settings';
 27  import { makeMetricsProviders } from '~/jet/metrics/providers';
 28  import { config as metricsConfig } from '~/config/metrics';
 29  
 30  import { bootstrap } from '~/jet/bootstrap';
 31  import { makeDependencies } from '~/jet/dependencies';
 32  import type { Locale } from '~/jet/dependencies/locale';
 33  import type { WebLocalization } from '~/jet/dependencies/localization';
 34  import {
 35      type RouterResponse,
 36      type RouteUrlIntent,
 37      makeRouteUrlIntent,
 38      makeLintMetricsEventIntent,
 39  } from '~/jet/intents';
 40  import type { Page, ActionModel } from '~/jet/models';
 41  import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
 42  import { CONTEXT_NAME } from '~/jet/svelte';
 43  import type { FeaturesCallbacks } from './dependencies/net';
 44  
 45  /**
 46   * The entry point for interacting with the Jet shared business logic.
 47   */
 48  export class Jet {
 49      private readonly log: Logger;
 50      private readonly runtime: AppStoreRuntime;
 51      private readonly actionDispatcher: ActionDispatcher;
 52      private readonly metrics: Metrics;
 53      private readonly locale: Locale;
 54  
 55      /**
 56       * Intents (and their resolved data) that have yet to be dispatched that
 57       * were recently dispatched. These are consulted before dispatching
 58       * intents. If a prefetched intent exists for an ongoing dispatch, it will
 59       * be used as the return value instead of actually dispatching.
 60       *
 61       * This can be used, for example, for intents that are dispatched during
 62       * SSR. The server can serialize the intents it dispatches and then the
 63       * client can populate this, to avoid re-dispatching the intents.
 64       */
 65      private readonly prefetchedIntents: PrefetchedIntents;
 66  
 67      /**
 68       * A set of the action types that already have registered implementations to catch
 69       * double registers.
 70       */
 71      private readonly wiredActions: Set<string>;
 72  
 73      readonly objectGraph: AppStoreObjectGraph;
 74      readonly localization: WebLocalization;
 75  
 76      static load({
 77          loggerFactory,
 78          context,
 79          fetch,
 80          prefetchedIntents = PrefetchedIntents.empty(),
 81          featuresCallbacks,
 82      }: {
 83          loggerFactory: LoggerFactory;
 84          context: Map<string, unknown>;
 85          fetch: typeof window.fetch;
 86          prefetchedIntents?: PrefetchedIntents;
 87          featuresCallbacks?: FeaturesCallbacks;
 88      }): Jet {
 89          const dependencies = makeDependencies(
 90              loggerFactory,
 91              fetch,
 92              featuresCallbacks,
 93          );
 94          const { runtime, objectGraph } = bootstrap(dependencies);
 95          let jet: Jet;
 96  
 97          const processEvent = async (
 98              fields: MetricsFields,
 99          ): Promise<LintedMetricsEvent> => {
100              const intent = makeLintMetricsEventIntent({ fields });
101              return jet.dispatch(intent);
102          };
103          const metrics = Metrics.load(
104              loggerFactory,
105              context,
106              processEvent,
107              metricsConfig,
108              makeMetricsProviders(objectGraph),
109              makeMetricsSettings(context),
110          );
111          const actionDispatcher = new ActionDispatcher(
112              // `@amp/web-apps-metrics` depends on a different version of `@jet/engine` with a different
113              // type definition for `MetricsPipeline`
114              // @ts-expect-error
115              metrics.metricsPipeline,
116          );
117  
118          jet = new Jet(
119              loggerFactory.loggerFor('Jet'),
120              runtime,
121              objectGraph,
122              actionDispatcher,
123              metrics,
124              dependencies.locale,
125              prefetchedIntents,
126              dependencies.localization,
127          );
128  
129          context.set(CONTEXT_NAME, jet);
130  
131          return jet;
132      }
133  
134      private constructor(
135          log: Logger,
136          runtime: AppStoreRuntime,
137          objectGraph: AppStoreObjectGraph,
138          actionDispatcher: ActionDispatcher,
139          metrics: Metrics,
140          locale: Locale,
141          prefetchedIntents: PrefetchedIntents,
142          localization: WebLocalization,
143      ) {
144          this.log = log;
145          this.runtime = runtime;
146          this.objectGraph = objectGraph;
147          this.actionDispatcher = actionDispatcher;
148  
149          this.metrics = metrics;
150          this.locale = locale;
151          this.localization = localization;
152  
153          this.prefetchedIntents = prefetchedIntents;
154  
155          this.wiredActions = new Set();
156      }
157  
158      async didEnterPage(page: Page | null): Promise<void> {
159          // This is a very temporary hacky fix to move the `platformContext` value from
160          // `pageRenderFields` to `pageFields`, which will eventually happen in the Jet
161          // business logic.
162          const pageWithMetrics = { ...page };
163          if (pageWithMetrics.pageMetrics?.pageFields) {
164              pageWithMetrics.pageMetrics.pageFields.platformContext =
165                  pageWithMetrics.pageMetrics.pageRenderFields?.platformContext;
166          }
167  
168          // @ts-expect-error - pageMetrics property not required at runtime
169          await this.metrics.didEnterPage(page);
170      }
171  
172      get pageMetrics(): Opt<PageMetrics> {
173          return this.metrics.currentPageMetrics?.pageMetrics;
174      }
175  
176      /**
177       * Dispatch a Jet intent, returning its output.
178       *
179       * @param intent The intent to dispatch
180       * @return output The value returned by the intent's controller
181       */
182      async dispatch<I extends Intent<unknown>>(
183          intent: I,
184      ): Promise<IntentReturnType<I>> {
185          const data = this.prefetchedIntents.get(intent);
186          if (data) {
187              this.log.info(
188                  're-using prefetched intent response for:',
189                  intent,
190                  'data:',
191                  data,
192              );
193              return data;
194          }
195  
196          // TODO: rdar://73165545 (Error Handling Across App)
197          return this.runtime.dispatch(intent);
198      }
199  
200      /**
201       * Perform a Jet action, returning the outcome.
202       *
203       * @param action The action to perform
204       * @param metricsBehavior Indicates how to handle metrics for this action
205       * @return outcome Either 'performed' or 'unsupported'
206       */
207      async perform(
208          action: ActionModel,
209          metricsBehavior?: MetricsBehavior,
210      ): Promise<ActionOutcome> {
211          if (!metricsBehavior) {
212              if (this.pageMetrics) {
213                  metricsBehavior = {
214                      behavior: 'fromAction',
215                      context: this.pageMetrics || {},
216                  };
217              } else {
218                  this.log.warn(
219                      'No pageMetrics found for jet.perform action:',
220                      action,
221                  );
222                  metricsBehavior = { behavior: 'notProcessed' };
223              }
224          }
225          // TODO: rdar://73165545 (Error Handling Across App): handle throw
226          const outcome = await this.actionDispatcher.perform(
227              action,
228              metricsBehavior,
229          );
230  
231          if (outcome === 'unsupported') {
232              this.log.error(
233                  'unable to perform action:',
234                  action,
235                  metricsBehavior,
236              );
237          }
238  
239          return outcome;
240      }
241  
242      /**
243       * Register an implementation to handle a Jet action.
244       *
245       * @param kind The type of the action
246       * @param implementation The code to run when that action is performed
247       */
248      onAction<A extends ActionModel>(
249          kind: string,
250          implementation: ActionImplementation<A>,
251      ): void {
252          if (this.wiredActions.has(kind)) {
253              throw new Error(
254                  `onAction called twice with the same action type: ${kind}`,
255              );
256          }
257  
258          this.actionDispatcher.register(kind, implementation);
259          this.wiredActions.add(kind);
260      }
261  
262      /**
263       * Route a URL using Jet, returning the routing if the URL could be routed.
264       *
265       * @param url The URL to route
266       * @return routing The routing of the URL or null if unrouteable
267       */
268      async routeUrl(url: string): Promise<RouterResponse | null> {
269          // TODO: rdar://73165545 (Error Handling Across App): what about 404s?
270          const routerResponse = await this.dispatch<RouteUrlIntent>(
271              makeRouteUrlIntent({ url }),
272          );
273  
274          if (routerResponse && routerResponse.action) {
275              return routerResponse;
276          }
277  
278          this.log.warn(
279              'url did not resolve to a flow action with a discernable intent:',
280              url,
281              routerResponse,
282          );
283  
284          return null;
285      }
286  
287      /**
288       * Propagates the routing-derrived localization information through the Jet app
289       *
290       * The {@link Locale} instance that is configured here is referenced by
291       * the rest of our Jet dependencies in order to lazily retreive the locale
292       * information.
293       *
294       * @param localizer
295       * @param storefront
296       * @param language
297       */
298      setLocale(
299          localizer: I18N,
300          storefront: NormalizedStorefront,
301          language: NormalizedLanguage,
302      ): void {
303          this.locale.i18n = localizer;
304          this.locale.setActiveLocale({ storefront, language });
305      }
306  
307      recordCustomMetricsEvent(fields?: Opt<MetricsFields>) {
308          this.metrics.recordCustomEvent(fields);
309      }
310  
311      enableFunnelKit(): void {
312          this.metrics.enableFunnelKit();
313      }
314  
315      disableFunnelKit(): void {
316          this.metrics.disableFunnelKit();
317      }
318  
319      // TODO: rdar://75011660 (Bridge Jet to MetricsKit and PerfKit for reporting)
320  }