@sanity/workbench 0.1.0-alpha.19 → 0.1.0-alpha.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  import type { FederationInstance } from "@sanity/federation/runtime";
2
2
  import { assign, fromPromise, sendParent, setup } from "xstate";
3
3
 
4
+ import { loadRemoteModule } from "./load-federated-module";
5
+
4
6
  /**
5
7
  * @public
6
8
  */
@@ -15,13 +17,42 @@ export interface RemoteRenderOptions {
15
17
  }
16
18
 
17
19
  /**
20
+ * A loaded render-contract module. `TProps` is what its `render` accepts —
21
+ * `RemoteRenderOptions` for an application's full-page view, the view's props
22
+ * for a dock-panel component.
23
+ * @public
24
+ */
25
+ export interface LoadedRemoteModule<TProps = RemoteRenderOptions> {
26
+ render: (rootElement: HTMLElement, props?: TProps) => () => void;
27
+ }
28
+
29
+ /**
30
+ * A worker service's loader module — the `worker` interface's expose. Carries
31
+ * the worker bundle URL (root-relative to the app origin) for the host to
32
+ * bootstrap, not a render function; the services counterpart of
33
+ * {@link LoadedRemoteModule}.
18
34
  * @public
19
35
  */
20
- export interface LoadedRemoteModule {
21
- render: (
22
- rootElement: HTMLElement,
23
- options?: RemoteRenderOptions,
24
- ) => () => void;
36
+ export interface ServiceLoaderModule {
37
+ url: string;
38
+ type: string;
39
+ name: string;
40
+ version: number;
41
+ }
42
+
43
+ /**
44
+ * The federated-module shape each interface type exposes, keyed by
45
+ * `interface_type`. Every interface — `panel`, `app`, `worker` — loads through
46
+ * the same {@link remoteLogic} lifecycle; this maps what its loaded module looks
47
+ * like so each consumer narrows the machine's shape-agnostic `module` to the
48
+ * right type: a render contract for `panel`/`app` (rendered by an island), a
49
+ * worker loader for `worker` (run as a Web Worker, never rendered).
50
+ * @public
51
+ */
52
+ export interface RemoteModuleByInterfaceType {
53
+ panel: LoadedRemoteModule;
54
+ app: LoadedRemoteModule;
55
+ worker: ServiceLoaderModule;
25
56
  }
26
57
 
27
58
  /**
@@ -33,10 +64,11 @@ export interface RemoteError {
33
64
  }
34
65
 
35
66
  /**
36
- * Thrown by the load actor when a remote responds successfully but does
37
- * not expose a `render` function. Surfaced via `RemoteError.cause` for
38
- * consumers that need to discriminate.
39
- * @internal
67
+ * Thrown by a render consumer (e.g. the island) when a loaded module doesn't
68
+ * expose a `render` function. Render-specific `remoteLogic` itself is
69
+ * shape-agnostic, so this lives with the consumer that needs the contract, not
70
+ * the loader. Surfaced via `RemoteError.cause` for consumers that discriminate.
71
+ * @public
40
72
  */
41
73
  export class ModuleShapeError extends Error {
42
74
  constructor(remoteId: string) {
@@ -44,45 +76,59 @@ export class ModuleShapeError extends Error {
44
76
  }
45
77
  }
46
78
 
79
+ /**
80
+ * Thrown by the load actor when a remote's expose resolves empty — the module
81
+ * isn't published, or `loadRemote` returned nothing. Generic across interface
82
+ * types; the shape-specific check (a render function, a worker URL) is the
83
+ * consumer's, since `remoteLogic` loads every interface the same way.
84
+ * @internal
85
+ */
86
+ export class ModuleLoadError extends Error {
87
+ constructor(remoteId: string) {
88
+ super(`Remote module "${remoteId}" failed to load`);
89
+ }
90
+ }
91
+
47
92
  /**
48
93
  * @internal
49
94
  */
50
95
  export interface RemoteInput {
51
- id: string;
96
+ /** Fully-qualified module id — `${appId}/${expose}`. One child per moduleId. */
97
+ moduleId: string;
52
98
  entry: string;
53
99
  instance: FederationInstance;
54
100
  }
55
101
 
56
102
  type RemoteContext = RemoteInput & {
57
- module: LoadedRemoteModule | null;
103
+ /** The loaded module, shape-agnostic each consumer narrows it. @see {@link RemoteModuleByInterfaceType} */
104
+ module: unknown;
58
105
  error: RemoteError | null;
59
106
  };
60
107
 
61
- const REMOTE_MODULE = "App";
62
-
63
- const loadLogic = fromPromise<LoadedRemoteModule, RemoteInput>(
64
- async ({ input }) => {
65
- input.instance.registerRemotes([{ name: input.id, entry: input.entry }]);
66
- const remoteModule = await input.instance.loadRemote<unknown>(
67
- `${input.id}/${REMOTE_MODULE}`,
68
- );
69
- if (
70
- !remoteModule ||
71
- typeof (remoteModule as { render?: unknown }).render !== "function"
72
- ) {
73
- throw new ModuleShapeError(input.id);
74
- }
75
- return remoteModule as LoadedRemoteModule;
76
- },
77
- );
108
+ const loadLogic = fromPromise<unknown, RemoteInput>(async ({ input }) => {
109
+ const remoteModule = await loadRemoteModule<unknown>(input.instance, {
110
+ moduleId: input.moduleId,
111
+ entry: input.entry,
112
+ });
113
+ // The loader is interface-agnostic: it only guarantees a module came back.
114
+ // Render-vs-worker shape validation is the consumer's (a panel/app island
115
+ // checks `render`; a service checks `url`).
116
+ if (remoteModule == null) {
117
+ throw new ModuleLoadError(input.moduleId);
118
+ }
119
+ return remoteModule;
120
+ });
78
121
 
79
122
  /**
80
- * Lifecycle machine for a single federated application remote.
123
+ * Lifecycle machine for a single federated interface module — an application's
124
+ * `App` entry, one view component, or a worker service's loader. Interface-
125
+ * agnostic: it loads and tracks any module, leaving the shape contract
126
+ * (render vs. worker URL) to the consumer.
81
127
  *
82
- * Spawned by the {@link remotesLogic} supervisor on demand. Each instance
83
- * owns one remote's load lifecycle: `loading` → `loaded | error`. Tags
84
- * (`loading` / `ready` / `failed`) drive the UI contract — state names
85
- * are internal.
128
+ * Spawned by the {@link remotesLogic} supervisor on demand, one per remote id
129
+ * (`${appId}/${moduleId}`). Each instance owns one module's load lifecycle:
130
+ * `loading` → `loaded | error`. Tags (`loading` / `ready` / `failed`) drive the
131
+ * UI contract — state names are internal.
86
132
  *
87
133
  * The machine sends a `remote.settled` event to its parent on entry to
88
134
  * either terminal state, reserved for future supervision/retry.
@@ -100,7 +146,7 @@ export const remoteLogic = setup({
100
146
  },
101
147
  actions: {
102
148
  setModule: assign({
103
- module: (_, params: { module: LoadedRemoteModule }) => params.module,
149
+ module: (_, params: { module: unknown }) => params.module,
104
150
  error: () => null,
105
151
  }),
106
152
  setError: assign({
@@ -122,7 +168,7 @@ export const remoteLogic = setup({
122
168
  invoke: {
123
169
  src: "load",
124
170
  input: ({ context }) => ({
125
- id: context.id,
171
+ moduleId: context.moduleId,
126
172
  entry: context.entry,
127
173
  instance: context.instance,
128
174
  }),
@@ -150,7 +196,7 @@ export const remoteLogic = setup({
150
196
  tags: ["ready"],
151
197
  entry: sendParent(({ context }) => ({
152
198
  type: "remote.settled" as const,
153
- id: context.id,
199
+ moduleId: context.moduleId,
154
200
  status: "loaded" as const,
155
201
  })),
156
202
  },
@@ -158,7 +204,7 @@ export const remoteLogic = setup({
158
204
  tags: ["failed"],
159
205
  entry: sendParent(({ context }) => ({
160
206
  type: "remote.settled" as const,
161
- id: context.id,
207
+ moduleId: context.moduleId,
162
208
  status: "error" as const,
163
209
  })),
164
210
  },
@@ -1,35 +1,38 @@
1
- import {
2
- createInstance,
3
- type FederationInstance,
4
- } from "@sanity/federation/runtime";
5
- import { log } from "@sanity/federation/runtime/plugins/log";
1
+ import { type FederationInstance } from "@sanity/federation/runtime";
6
2
  import { type ActorRefFrom, assign, setup } from "xstate";
7
3
 
8
- import { logger } from "../core/log";
9
4
  import { remoteLogic } from "./remote.machine";
10
5
 
6
+ /** The shared federation instance, supplied by the root machine. */
7
+ type RemotesInput = {
8
+ instance: FederationInstance;
9
+ };
10
+
11
11
  type RemotesContext = {
12
12
  instance: FederationInstance;
13
13
  children: Map<string, ActorRefFrom<typeof remoteLogic>>;
14
14
  };
15
15
 
16
16
  type RemotesEvent =
17
- | { type: "remote.load.request"; id: string; entry: string }
18
- | { type: "remote.settled"; id: string; status: "loaded" | "error" };
17
+ | { type: "remote.load.request"; moduleId: string; entry: string }
18
+ | { type: "remote.settled"; moduleId: string; status: "loaded" | "error" };
19
19
 
20
20
  /**
21
- * Supervisor machine for federated application remotes.
21
+ * Supervisor machine for federated modules.
22
22
  *
23
- * Owns a single {@link FederationInstance} (the `workbench-applications`
24
- * instance) and spawns one {@link remoteLogic} child per requested
25
- * remote. The supervisor is invoked at the root of the OS machine for
26
- * inspector visibility (`[sanity-workbench:os:remotes]`) boot does
27
- * not gate on it.
23
+ * Uses the shared {@link FederationInstance} (the `workbench-applications`
24
+ * instance, supplied by the root machine and shared with `services`) and spawns
25
+ * one {@link remoteLogic} child per remote id (`${appId}/${moduleId}`) so an
26
+ * application's `App` entry and each of its dock-panel view components load (and
27
+ * are tracked) independently. The
28
+ * supervisor is invoked at the root of the OS machine for inspector visibility
29
+ * (`[sanity-workbench:os:remotes]`) — boot does not gate on it.
28
30
  *
29
31
  * @internal
30
32
  */
31
33
  export const remotesLogic = setup({
32
34
  types: {
35
+ input: {} as RemotesInput,
33
36
  context: {} as RemotesContext,
34
37
  events: {} as RemotesEvent,
35
38
  },
@@ -37,44 +40,47 @@ export const remotesLogic = setup({
37
40
  remote: remoteLogic,
38
41
  },
39
42
  guards: {
40
- remoteNotKnown: ({ context }, params: { id: string }) =>
41
- !context.children.has(params.id),
43
+ remoteNotKnown: ({ context }, params: { moduleId: string }) =>
44
+ !context.children.has(params.moduleId),
42
45
  },
43
46
  actions: {
44
47
  spawnRemote: assign({
45
- children: ({ context, spawn }, params: { id: string; entry: string }) => {
48
+ children: (
49
+ { context, spawn },
50
+ params: { moduleId: string; entry: string },
51
+ ) => {
46
52
  const ref = spawn("remote", {
47
- id: params.id,
48
- systemId: `remotes.${params.id}`,
53
+ id: params.moduleId,
54
+ systemId: `remotes.${params.moduleId}`,
49
55
  input: {
50
- id: params.id,
56
+ moduleId: params.moduleId,
51
57
  entry: params.entry,
52
58
  instance: context.instance,
53
59
  },
54
60
  });
55
- return new Map(context.children).set(params.id, ref);
61
+ return new Map(context.children).set(params.moduleId, ref);
56
62
  },
57
63
  }),
58
64
  },
59
65
  }).createMachine({
60
66
  id: "remotes",
61
- context: () => ({
62
- instance: createInstance({
63
- name: "workbench-applications",
64
- plugins: [log(logger.debug)],
65
- }),
67
+ context: ({ input }) => ({
68
+ instance: input.instance,
66
69
  children: new Map(),
67
70
  }),
68
71
  on: {
69
72
  "remote.load.request": {
70
73
  guard: {
71
74
  type: "remoteNotKnown",
72
- params: ({ event }) => ({ id: event.id }),
75
+ params: ({ event }) => ({ moduleId: event.moduleId }),
73
76
  },
74
77
  actions: [
75
78
  {
76
79
  type: "spawnRemote",
77
- params: ({ event }) => ({ id: event.id, entry: event.entry }),
80
+ params: ({ event }) => ({
81
+ moduleId: event.moduleId,
82
+ entry: event.entry,
83
+ }),
78
84
  },
79
85
  ],
80
86
  },
@@ -1,9 +1,16 @@
1
+ import {
2
+ createInstance,
3
+ type FederationInstance,
4
+ } from "@sanity/federation/runtime";
5
+ import { log } from "@sanity/federation/runtime/plugins/log";
1
6
  import { createSanityInstance, type SanityInstance } from "@sanity/sdk";
2
7
  import { raise, sendTo, setup, type ActorOptions } from "xstate";
3
8
 
9
+ import { logger } from "../core/log";
4
10
  import { authLogic } from "./auth.machine";
5
11
  import { inspect } from "./inspect";
6
12
  import { remotesLogic } from "./remotes.machine";
13
+ import { servicesLogic } from "./services.machine";
7
14
  import {
8
15
  type SystemPreferencesAdapter,
9
16
  systemPreferencesLogic,
@@ -19,6 +26,12 @@ type OSInput = WorkbenchUserProperties & {
19
26
 
20
27
  type OSContext = {
21
28
  instance: SanityInstance;
29
+ /**
30
+ * The single module-federation instance every interface loads through —
31
+ * shared by the `remotes` (views) and `services` (workers) supervisors so an
32
+ * app's remote is registered once for all its interfaces.
33
+ */
34
+ federationInstance: FederationInstance;
22
35
  userProperties: WorkbenchUserProperties;
23
36
  systemPreferencesAdapter: SystemPreferencesAdapter | undefined;
24
37
  };
@@ -59,6 +72,7 @@ export const os = setup({
59
72
  telemetry: "telemetry";
60
73
  "system-preferences": "systemPreferences";
61
74
  remotes: "remotes";
75
+ services: "services";
62
76
  },
63
77
  },
64
78
  actors: {
@@ -66,6 +80,7 @@ export const os = setup({
66
80
  telemetry: telemetryLogic,
67
81
  systemPreferences: systemPreferencesLogic,
68
82
  remotes: remotesLogic,
83
+ services: servicesLogic,
69
84
  },
70
85
  guards: {
71
86
  hasTag: (_, params: { hasTag: boolean }) => params.hasTag,
@@ -84,6 +99,10 @@ export const os = setup({
84
99
  id: "os",
85
100
  context: ({ input }) => ({
86
101
  instance: createSanityInstance(),
102
+ federationInstance: createInstance({
103
+ name: "workbench-applications",
104
+ plugins: [log(logger.debug)],
105
+ }),
87
106
  userProperties: {
88
107
  version: input.version,
89
108
  organizationId: input.organizationId,
@@ -150,6 +169,13 @@ export const os = setup({
150
169
  id: "remotes",
151
170
  systemId: "remotes",
152
171
  src: "remotes",
172
+ input: ({ context }) => ({ instance: context.federationInstance }),
173
+ },
174
+ {
175
+ id: "services",
176
+ systemId: "services",
177
+ src: "services",
178
+ input: ({ context }) => ({ instance: context.federationInstance }),
153
179
  },
154
180
  ],
155
181
  states: {
@@ -0,0 +1,207 @@
1
+ import { type FederationInstance } from "@sanity/federation/runtime";
2
+ import { assign, fromCallback, setup } from "xstate";
3
+
4
+ import { logger } from "../core/log";
5
+ import {
6
+ remoteLogic,
7
+ type RemoteError,
8
+ type ServiceLoaderModule,
9
+ } from "./remote.machine";
10
+
11
+ /**
12
+ * @internal
13
+ */
14
+ export interface ServiceInput {
15
+ /** Stable child key, `${appId}:${serviceName}`. */
16
+ key: string;
17
+ appId: string;
18
+ serviceName: string;
19
+ /** The app's `mf-manifest.json` URL — the federation remote entry. */
20
+ entry: string;
21
+ instance: FederationInstance;
22
+ }
23
+
24
+ type ServiceContext = ServiceInput & {
25
+ /** Resolved worker-bundle URL, absolute on the app origin. */
26
+ workerUrl: string | null;
27
+ };
28
+
29
+ /** Federation expose segment a service's loader lives under: `services/<name>`. */
30
+ const SERVICES_MODULE = "services";
31
+
32
+ /**
33
+ * Run the worker. `new Worker(appOriginUrl)` is cross-origin-blocked, and the
34
+ * dev worker isn't self-contained (its root-relative imports would resolve
35
+ * against the wrong origin from a blob). So bootstrap a same-origin worker that
36
+ * dynamically `import()`s the app-origin URL: the worker module then resolves
37
+ * its own imports against the app origin, in dev and build alike. Crashes, a
38
+ * failed load, and the worker's `console.*` (bridged by the wrapper) are
39
+ * surfaced through the host logger (the host owns logging; a worker's own
40
+ * console isn't visible in the page DevTools anyway). Cleanup disposes, then
41
+ * terminates.
42
+ */
43
+ const runWorker = fromCallback<
44
+ { type: string },
45
+ { key: string; serviceName: string; workerUrl: string }
46
+ >(({ input }) => {
47
+ let worker: Worker | undefined;
48
+ let blobUrl: string | undefined;
49
+
50
+ try {
51
+ // A same-origin module worker whose only job is to load the real worker
52
+ // from the app origin, so that worker's imports resolve there too.
53
+ const bootstrap = `import(${JSON.stringify(input.workerUrl)})`;
54
+ blobUrl = URL.createObjectURL(
55
+ new Blob([bootstrap], { type: "text/javascript" }),
56
+ );
57
+ worker = new Worker(blobUrl, { type: "module" });
58
+
59
+ worker.addEventListener("message", (event: MessageEvent) => {
60
+ if (event.data?.kind === "workbench.worker.error") {
61
+ logger.error(
62
+ `Service "${input.key}" worker error`,
63
+ event.data.payload?.message,
64
+ );
65
+ } else if (event.data?.kind === "workbench.worker.log") {
66
+ // Re-emit the worker's `console.*` (bridged by the wrapper) through the
67
+ // host logger, prefixed with the service name so it shows in the page
68
+ // console.
69
+ const { level, message } = event.data.payload ?? {};
70
+ const emit: Record<string, (message: string) => void> = {
71
+ warn: logger.warn,
72
+ error: logger.error,
73
+ debug: logger.debug,
74
+ };
75
+ (emit[level] ?? logger.info)(
76
+ `[service:${input.serviceName}] ${message ?? ""}`,
77
+ );
78
+ }
79
+ });
80
+
81
+ // A module-load failure (or any uncaught worker error) fires here on the
82
+ // host side — surface it instead of failing silently.
83
+ worker.addEventListener("error", (event: ErrorEvent) => {
84
+ logger.error(
85
+ `Service "${input.key}" worker error: ${event.message || String(event)}`,
86
+ );
87
+ });
88
+ } catch (error) {
89
+ logger.error(`Service "${input.key}" failed to start its worker`, error);
90
+ }
91
+
92
+ return () => {
93
+ try {
94
+ worker?.postMessage({ kind: "workbench.worker.terminate" });
95
+ } finally {
96
+ worker?.terminate();
97
+ if (blobUrl) URL.revokeObjectURL(blobUrl);
98
+ }
99
+ };
100
+ });
101
+
102
+ /**
103
+ * Lifecycle machine for a single background service worker.
104
+ *
105
+ * Spawned by the {@link servicesLogic} supervisor, one per `(app, service)`.
106
+ * `loading` loads the worker's loader module through the shared
107
+ * {@link remoteLogic} lifecycle — the same machine every interface (panel, app,
108
+ * worker) loads through — and reads the bundle URL off it. A worker isn't a
109
+ * render module, so it's never handed to an island; `running` bootstraps it as
110
+ * a Web Worker from that URL.
111
+ *
112
+ * @internal
113
+ */
114
+ export const serviceLogic = setup({
115
+ types: {
116
+ input: {} as ServiceInput,
117
+ context: {} as ServiceContext,
118
+ tags: {} as "loading" | "running" | "failed",
119
+ },
120
+ actors: {
121
+ loadInterface: remoteLogic,
122
+ runWorker,
123
+ },
124
+ }).createMachine({
125
+ id: "service",
126
+ initial: "loading",
127
+ context: ({ input }) => ({ ...input, workerUrl: null }),
128
+ // The invoked loader `sendParent`s a `remote.settled` on settle; this machine
129
+ // reacts via `onSnapshot` instead, so the event is a no-op here.
130
+ on: { "remote.settled": {} },
131
+ states: {
132
+ loading: {
133
+ tags: ["loading"],
134
+ invoke: {
135
+ id: "loader",
136
+ src: "loadInterface",
137
+ input: ({ context }) => ({
138
+ moduleId: `${context.appId}/${SERVICES_MODULE}/${context.serviceName}`,
139
+ entry: context.entry,
140
+ instance: context.instance,
141
+ }),
142
+ onSnapshot: [
143
+ {
144
+ // Loaded and exposing a bundle URL → run it.
145
+ guard: ({ event }) => {
146
+ const module = event.snapshot.context
147
+ .module as ServiceLoaderModule | null;
148
+ return (
149
+ event.snapshot.hasTag("ready") &&
150
+ typeof module?.url === "string"
151
+ );
152
+ },
153
+ target: "running",
154
+ actions: assign({
155
+ workerUrl: ({ context, event }) => {
156
+ const module = event.snapshot.context
157
+ .module as ServiceLoaderModule;
158
+ // The URL is root-relative to the app; resolve it absolute
159
+ // against the app origin since the loader runs in the host page.
160
+ return new URL(module.url, new URL(context.entry).origin).href;
161
+ },
162
+ }),
163
+ },
164
+ {
165
+ // Load failed, or loaded without a usable URL → no worker.
166
+ guard: ({ event }) => {
167
+ const module = event.snapshot.context
168
+ .module as ServiceLoaderModule | null;
169
+ return (
170
+ event.snapshot.hasTag("failed") ||
171
+ (event.snapshot.hasTag("ready") &&
172
+ typeof module?.url !== "string")
173
+ );
174
+ },
175
+ target: "error",
176
+ // A worker has no UI surface (unlike a view's error boundary), so a
177
+ // failed load would otherwise be silent — log it through the host.
178
+ actions: ({ context, event }) => {
179
+ const cause = (event.snapshot.context.error as RemoteError | null)
180
+ ?.message;
181
+ logger.error(
182
+ `Service "${context.key}" failed to load its worker${
183
+ cause ? `: ${cause}` : ""
184
+ }`,
185
+ );
186
+ },
187
+ },
188
+ ],
189
+ },
190
+ },
191
+ running: {
192
+ tags: ["running"],
193
+ invoke: {
194
+ src: "runWorker",
195
+ input: ({ context }) => ({
196
+ key: context.key,
197
+ serviceName: context.serviceName,
198
+ // `workerUrl` is set on entry to `running` from `loading`'s output.
199
+ workerUrl: context.workerUrl!,
200
+ }),
201
+ },
202
+ },
203
+ error: {
204
+ tags: ["failed"],
205
+ },
206
+ },
207
+ });