@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.
- package/dist/_chunks-es/studio.js +57 -2
- package/dist/_chunks-es/studio.js.map +1 -1
- package/dist/core.d.ts +334 -2
- package/dist/core.js +3 -0
- package/dist/core.js.map +1 -1
- package/dist/system.d.ts +1107 -100
- package/dist/system.js +314 -110
- package/dist/system.js.map +1 -1
- package/package.json +2 -2
- package/src/core/applications/application.ts +80 -1
- package/src/core/user-applications/core-app.ts +5 -1
- package/src/core/user-applications/studios/schemas.ts +4 -0
- package/src/core/user-applications/user-application.ts +17 -0
- package/src/system/index.ts +5 -0
- package/src/system/load-federated-module.ts +54 -0
- package/src/system/remote.machine.ts +83 -37
- package/src/system/remotes.machine.ts +34 -28
- package/src/system/root.machine.ts +26 -0
- package/src/system/service.machine.ts +207 -0
- package/src/system/services.machine.ts +120 -0
|
@@ -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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
|
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
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
83
|
-
* owns one
|
|
84
|
-
* (`loading` / `ready` / `failed`) drive the
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|
|
18
|
-
| { type: "remote.settled";
|
|
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
|
|
21
|
+
* Supervisor machine for federated modules.
|
|
22
22
|
*
|
|
23
|
-
*
|
|
24
|
-
* instance
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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: {
|
|
41
|
-
!context.children.has(params.
|
|
43
|
+
remoteNotKnown: ({ context }, params: { moduleId: string }) =>
|
|
44
|
+
!context.children.has(params.moduleId),
|
|
42
45
|
},
|
|
43
46
|
actions: {
|
|
44
47
|
spawnRemote: assign({
|
|
45
|
-
children: (
|
|
48
|
+
children: (
|
|
49
|
+
{ context, spawn },
|
|
50
|
+
params: { moduleId: string; entry: string },
|
|
51
|
+
) => {
|
|
46
52
|
const ref = spawn("remote", {
|
|
47
|
-
id: params.
|
|
48
|
-
systemId: `remotes.${params.
|
|
53
|
+
id: params.moduleId,
|
|
54
|
+
systemId: `remotes.${params.moduleId}`,
|
|
49
55
|
input: {
|
|
50
|
-
|
|
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.
|
|
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:
|
|
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 }) => ({
|
|
75
|
+
params: ({ event }) => ({ moduleId: event.moduleId }),
|
|
73
76
|
},
|
|
74
77
|
actions: [
|
|
75
78
|
{
|
|
76
79
|
type: "spawnRemote",
|
|
77
|
-
params: ({ event }) => ({
|
|
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
|
+
});
|