@adobe/uix-host 0.6.3
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/LICENSE +201 -0
- package/README.md +1 -0
- package/dist/debug-host.d.ts +8 -0
- package/dist/debug-host.d.ts.map +1 -0
- package/dist/esm/index.js +408 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/extensions-provider/composition.d.ts +9 -0
- package/dist/extensions-provider/composition.d.ts.map +1 -0
- package/dist/extensions-provider/extension-registry.d.ts +29 -0
- package/dist/extensions-provider/extension-registry.d.ts.map +1 -0
- package/dist/extensions-provider/index.d.ts +3 -0
- package/dist/extensions-provider/index.d.ts.map +1 -0
- package/dist/host.d.ts +247 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/index.d.ts +65 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +413 -0
- package/dist/index.js.map +1 -0
- package/dist/port.d.ts +173 -0
- package/dist/port.d.ts.map +1 -0
- package/package.json +39 -0
- package/src/debug-host.ts +79 -0
- package/src/extensions-provider/composition.ts +30 -0
- package/src/extensions-provider/extension-registry.ts +152 -0
- package/src/extensions-provider/index.ts +14 -0
- package/src/host.ts +429 -0
- package/src/index.ts +76 -0
- package/src/port.ts +430 -0
- package/tsconfig.json +19 -0
package/src/port.ts
ADDED
@@ -0,0 +1,430 @@
|
|
1
|
+
/*
|
2
|
+
Copyright 2022 Adobe. All rights reserved.
|
3
|
+
This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
4
|
+
you may not use this file except in compliance with the License. You may obtain a copy
|
5
|
+
of the License at http://www.apache.org/licenses/LICENSE-2.0
|
6
|
+
|
7
|
+
Unless required by applicable law or agreed to in writing, software distributed under
|
8
|
+
the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
9
|
+
OF ANY KIND, either express or implied. See the License for the specific language
|
10
|
+
governing permissions and limitations under the License.
|
11
|
+
*/
|
12
|
+
|
13
|
+
import type {
|
14
|
+
Emits,
|
15
|
+
GuestConnection,
|
16
|
+
HostMethodAddress,
|
17
|
+
NamedEvent,
|
18
|
+
RemoteHostApis,
|
19
|
+
GuestApis,
|
20
|
+
Unsubscriber,
|
21
|
+
} from "@adobe/uix-core";
|
22
|
+
import { Emitter } from "@adobe/uix-core";
|
23
|
+
import { Connection, connectToChild, Methods } from "penpal";
|
24
|
+
|
25
|
+
/**
|
26
|
+
* A specifier for methods to be expected on a remote interface.
|
27
|
+
*
|
28
|
+
* @remarks
|
29
|
+
* A CapabilitySpec is a description of an interface, like a very simplified
|
30
|
+
* type definition. It specifies an object structure and the paths in that
|
31
|
+
* structure that must be functions. (It doesn't specify anything about the
|
32
|
+
* signatures or return values of those functions.)
|
33
|
+
*
|
34
|
+
* Use CapabilitySpec objects as queries, or filters, to get a subset of
|
35
|
+
* installed extensions which have registered methods which match the spec.
|
36
|
+
*
|
37
|
+
* @example
|
38
|
+
* As an extensible app developer, you are making an extension point for spell
|
39
|
+
* check. Your code expects extensions to register an API `spellCheck` with
|
40
|
+
* methods called `spellCheck.correct(text)` and `spellCheck.suggest(text)`.
|
41
|
+
*
|
42
|
+
* ```javascript
|
43
|
+
* async function correctText(text) {
|
44
|
+
* const spellCheckers = host.getLoadedGuests({
|
45
|
+
* spellCheck: [
|
46
|
+
* 'correct',
|
47
|
+
* 'suggest'
|
48
|
+
* ]
|
49
|
+
* });
|
50
|
+
* let correcting = text;
|
51
|
+
* for (const checker of spellCheckers) {
|
52
|
+
* correcting = await checker.apis.spellCheck.correct(correcting);
|
53
|
+
* }
|
54
|
+
* return Promise.all(checkers.map(checker =>
|
55
|
+
* checker.apis.spellCheck.suggest(correcting)
|
56
|
+
* ));
|
57
|
+
* }
|
58
|
+
* ```
|
59
|
+
*
|
60
|
+
* @public
|
61
|
+
*/
|
62
|
+
export type CapabilitySpec<T extends GuestApis> = {
|
63
|
+
[Name in keyof T]: (keyof T[Name])[];
|
64
|
+
};
|
65
|
+
|
66
|
+
/**
|
67
|
+
* Interface for decoupling of guest Penpal object
|
68
|
+
* @internal
|
69
|
+
*/
|
70
|
+
interface GuestProxyWrapper {
|
71
|
+
/**
|
72
|
+
* Methods from guest
|
73
|
+
*/
|
74
|
+
apis: RemoteHostApis;
|
75
|
+
/**
|
76
|
+
* Emit an event in the guest frame
|
77
|
+
*/
|
78
|
+
emit(type: string, detail: unknown): Promise<void>;
|
79
|
+
}
|
80
|
+
|
81
|
+
/** @public */
|
82
|
+
type PortEvent<
|
83
|
+
GuestApi,
|
84
|
+
Type extends string = string,
|
85
|
+
Detail = Record<string, unknown>
|
86
|
+
> = NamedEvent<
|
87
|
+
Type,
|
88
|
+
Detail &
|
89
|
+
Record<string, unknown> & {
|
90
|
+
guestPort: Port<GuestApi>;
|
91
|
+
}
|
92
|
+
>;
|
93
|
+
|
94
|
+
/** @public */
|
95
|
+
export type PortEvents<
|
96
|
+
GuestApi,
|
97
|
+
HostApi extends Record<string, unknown> = Record<string, unknown>
|
98
|
+
> =
|
99
|
+
| PortEvent<GuestApi, "hostprovide">
|
100
|
+
| PortEvent<GuestApi, "unload">
|
101
|
+
| PortEvent<GuestApi, "beforecallhostmethod", HostMethodAddress<HostApi>>;
|
102
|
+
|
103
|
+
/** @public */
|
104
|
+
export type PortOptions = {
|
105
|
+
/**
|
106
|
+
* Time in milliseconds to wait for the guest to connect before throwing.
|
107
|
+
*/
|
108
|
+
timeout?: number;
|
109
|
+
/**
|
110
|
+
* Set true to log copiously in the console.
|
111
|
+
*/
|
112
|
+
debug?: boolean;
|
113
|
+
};
|
114
|
+
|
115
|
+
const defaultOptions = {
|
116
|
+
timeout: 10000,
|
117
|
+
debug: false,
|
118
|
+
};
|
119
|
+
|
120
|
+
/**
|
121
|
+
* A Port is the Host-maintained object representing an extension running as a
|
122
|
+
* guest. It exposes methods registered by the Guest, and can provide Host
|
123
|
+
* methods back to the guest.
|
124
|
+
*
|
125
|
+
* @remarks
|
126
|
+
* When the Host object loads extensions via {@link Host.load}, it creates a
|
127
|
+
* Port object for each extension. When retrieving and filtering extensions
|
128
|
+
* via {@link Host.(getLoadedGuests:2)}, a list of Port objects is returned. From
|
129
|
+
* the point of view of the extensible app using the Host object, extensions
|
130
|
+
* are always Port objects, which expose the methods registered by the
|
131
|
+
* extension at the {@link Port.apis} property.
|
132
|
+
*
|
133
|
+
* @privateRemarks
|
134
|
+
* We've gone through several possible names for this object. GuestProxy,
|
135
|
+
* GuestInterface, GuestConnection, etc. "Port" is not ideal, but it conflicted
|
136
|
+
* the least with other types we defined in early drafts. It's definitely
|
137
|
+
* something we should review.
|
138
|
+
* @public
|
139
|
+
*/
|
140
|
+
export class Port<GuestApi>
|
141
|
+
extends Emitter<PortEvents<GuestApi>>
|
142
|
+
implements GuestConnection
|
143
|
+
{
|
144
|
+
// #region Properties (15)
|
145
|
+
|
146
|
+
private connection: Connection<RemoteHostApis<GuestApi>>;
|
147
|
+
private debug: boolean;
|
148
|
+
private debugLogger?: Console;
|
149
|
+
private frame: HTMLIFrameElement;
|
150
|
+
private guest: GuestProxyWrapper;
|
151
|
+
private hostApis: RemoteHostApis = {};
|
152
|
+
private isLoaded = false;
|
153
|
+
private runtimeContainer: HTMLElement;
|
154
|
+
private sharedContext: Record<string, unknown>;
|
155
|
+
private subscriptions: Unsubscriber[] = [];
|
156
|
+
private timeout: number;
|
157
|
+
|
158
|
+
/**
|
159
|
+
* Dictionary of namespaced methods that were registered by this guest at the
|
160
|
+
* time of connection, using {@link @adobe/uix-guest#register}.
|
161
|
+
*
|
162
|
+
* @remarks
|
163
|
+
* These methods are proxy methods; you can only pass serializable objects to
|
164
|
+
* them, not class instances, methods or callbacks.
|
165
|
+
* @public
|
166
|
+
*/
|
167
|
+
public apis: RemoteHostApis;
|
168
|
+
/**
|
169
|
+
* If any errors occurred during the loading of guests, this property will
|
170
|
+
* contain the error that was raised.
|
171
|
+
* @public
|
172
|
+
*/
|
173
|
+
error?: Error;
|
174
|
+
private uiConnections: Map<string, Connection<RemoteHostApis<GuestApi>>> =
|
175
|
+
new Map();
|
176
|
+
/**
|
177
|
+
* The URL of the guest provided by the extension registry. The Host will
|
178
|
+
* load this URL in the background, in the invisible the bootstrap frame, so
|
179
|
+
* this URL must point to a page that calls {@link @adobe/uix-guest#register}
|
180
|
+
* when it loads.
|
181
|
+
*/
|
182
|
+
url: URL;
|
183
|
+
|
184
|
+
// #endregion Properties (15)
|
185
|
+
|
186
|
+
// #region Constructors (1)
|
187
|
+
|
188
|
+
constructor(config: {
|
189
|
+
owner: string;
|
190
|
+
id: string;
|
191
|
+
url: URL;
|
192
|
+
/**
|
193
|
+
* An alternate DOM element to use for invisible iframes. Will create its
|
194
|
+
* own if this option is not populated with a DOM element.
|
195
|
+
*/
|
196
|
+
runtimeContainer: HTMLElement;
|
197
|
+
options: PortOptions;
|
198
|
+
debugLogger?: Console;
|
199
|
+
/**
|
200
|
+
* Initial object to populate the shared context with. Once the guest
|
201
|
+
* connects, it will be able to access these properties.
|
202
|
+
*/
|
203
|
+
sharedContext: Record<string, unknown>;
|
204
|
+
events: Emits;
|
205
|
+
}) {
|
206
|
+
super(config.id);
|
207
|
+
const { timeout, debug } = { ...defaultOptions, ...(config.options || {}) };
|
208
|
+
this.timeout = timeout;
|
209
|
+
this.debug = debug;
|
210
|
+
this.id = config.id;
|
211
|
+
this.url = config.url;
|
212
|
+
this.runtimeContainer = config.runtimeContainer;
|
213
|
+
this.sharedContext = config.sharedContext;
|
214
|
+
this.subscriptions.push(
|
215
|
+
config.events.addEventListener("contextchange", async (event) => {
|
216
|
+
this.sharedContext = (
|
217
|
+
(event as CustomEvent).detail as unknown as Record<string, unknown>
|
218
|
+
).context as Record<string, unknown>;
|
219
|
+
await this.connect();
|
220
|
+
await this.guest.emit("contextchange", { context: this.sharedContext });
|
221
|
+
})
|
222
|
+
);
|
223
|
+
}
|
224
|
+
|
225
|
+
// #endregion Constructors (1)
|
226
|
+
|
227
|
+
// #region Public Methods (6)
|
228
|
+
|
229
|
+
/**
|
230
|
+
* Connect an iframe element which is displaying another page in the extension
|
231
|
+
* with the extension's bootstrap frame, so they can share context and events.
|
232
|
+
*/
|
233
|
+
public attachUI(iframe: HTMLIFrameElement) {
|
234
|
+
const uniqueId = Math.random().toString(36);
|
235
|
+
const uiConnection = this.attachFrame(iframe);
|
236
|
+
this.uiConnections.set(uniqueId, uiConnection);
|
237
|
+
return uiConnection;
|
238
|
+
}
|
239
|
+
|
240
|
+
/**
|
241
|
+
* Returns true if the guest has registered methods matching the provided
|
242
|
+
* capability spec. A capability spec is simply an object whose properties are
|
243
|
+
* declared in an array of keys, description the names of the functions and
|
244
|
+
* methods that the Port will expose.
|
245
|
+
*/
|
246
|
+
public hasCapabilities(requiredMethods: CapabilitySpec<GuestApis>) {
|
247
|
+
this.assertReady();
|
248
|
+
return Object.keys(requiredMethods).every((key) => {
|
249
|
+
if (!Reflect.has(this.apis, key)) {
|
250
|
+
return false;
|
251
|
+
}
|
252
|
+
const api = this.apis[key];
|
253
|
+
const methodList = requiredMethods[
|
254
|
+
key as keyof typeof requiredMethods
|
255
|
+
] as string[];
|
256
|
+
return methodList.every(
|
257
|
+
(methodName: string) =>
|
258
|
+
Reflect.has(api, methodName) &&
|
259
|
+
typeof api[methodName as keyof typeof api] === "function"
|
260
|
+
);
|
261
|
+
});
|
262
|
+
}
|
263
|
+
|
264
|
+
/**
|
265
|
+
* True when al extensions have loaded.
|
266
|
+
*/
|
267
|
+
public isReady(): boolean {
|
268
|
+
return this.isLoaded && !this.error;
|
269
|
+
}
|
270
|
+
|
271
|
+
/**
|
272
|
+
* Loads the extension. Returns a promise which resolves when the extension
|
273
|
+
* has loaded. The Host calls this method after retrieving extensions.
|
274
|
+
*/
|
275
|
+
public async load() {
|
276
|
+
try {
|
277
|
+
if (!this.apis) {
|
278
|
+
await this.connect();
|
279
|
+
}
|
280
|
+
return this.apis;
|
281
|
+
} catch (e) {
|
282
|
+
this.apis = null;
|
283
|
+
this.guest = null;
|
284
|
+
this.error = e instanceof Error ? e : new Error(String(e));
|
285
|
+
throw e;
|
286
|
+
}
|
287
|
+
}
|
288
|
+
|
289
|
+
/**
|
290
|
+
* The host-side equivalent of {@link @adobe/uix-guest#register}. Pass a set
|
291
|
+
* of methods down to the guest as proxies.
|
292
|
+
*/
|
293
|
+
public provide(apis: RemoteHostApis) {
|
294
|
+
Object.assign(this.hostApis, apis);
|
295
|
+
this.emit("hostprovide", { guestPort: this, apis });
|
296
|
+
}
|
297
|
+
|
298
|
+
/**
|
299
|
+
* Disconnect from the extension.
|
300
|
+
*/
|
301
|
+
public async unload(): Promise<void> {
|
302
|
+
if (this.connection) {
|
303
|
+
await this.connection.destroy();
|
304
|
+
}
|
305
|
+
for (const connection of this.uiConnections.values()) {
|
306
|
+
await connection.destroy();
|
307
|
+
}
|
308
|
+
if (this.frame && this.frame.parentElement) {
|
309
|
+
this.frame.parentElement.removeChild(this.frame);
|
310
|
+
this.frame = undefined;
|
311
|
+
}
|
312
|
+
this.emit("unload", { guestPort: this });
|
313
|
+
}
|
314
|
+
|
315
|
+
// #endregion Public Methods (6)
|
316
|
+
|
317
|
+
// #region Private Methods (6)
|
318
|
+
|
319
|
+
private assert(
|
320
|
+
condition: boolean,
|
321
|
+
errorMessage: () => string
|
322
|
+
): asserts condition {
|
323
|
+
if (!condition) {
|
324
|
+
throw new Error(
|
325
|
+
`Error in guest extension "${this.id}": ${errorMessage()}`
|
326
|
+
);
|
327
|
+
}
|
328
|
+
}
|
329
|
+
|
330
|
+
private assertReady() {
|
331
|
+
this.assert(this.isReady(), () => "Attempted to interact before loaded");
|
332
|
+
}
|
333
|
+
|
334
|
+
private attachFrame(iframe: HTMLIFrameElement) {
|
335
|
+
return connectToChild<RemoteHostApis<GuestApi>>({
|
336
|
+
iframe,
|
337
|
+
debug: this.debug,
|
338
|
+
childOrigin: this.url.origin,
|
339
|
+
timeout: this.timeout,
|
340
|
+
methods: {
|
341
|
+
getSharedContext: () => this.sharedContext,
|
342
|
+
invokeHostMethod: (address: HostMethodAddress) =>
|
343
|
+
this.invokeHostMethod(address),
|
344
|
+
},
|
345
|
+
});
|
346
|
+
}
|
347
|
+
|
348
|
+
private async connect() {
|
349
|
+
this.frame = this.runtimeContainer.ownerDocument.createElement("iframe");
|
350
|
+
this.frame.setAttribute("src", this.url.href);
|
351
|
+
this.frame.setAttribute("data-uix-guest", "true");
|
352
|
+
this.runtimeContainer.appendChild(this.frame);
|
353
|
+
if (this.debugLogger) {
|
354
|
+
this.debugLogger.info(
|
355
|
+
`Guest ${this.id} attached iframe of ${this.url.href}`,
|
356
|
+
this
|
357
|
+
);
|
358
|
+
}
|
359
|
+
this.connection = this.attachFrame(this.frame);
|
360
|
+
this.guest = (await this.connection
|
361
|
+
.promise) as unknown as GuestProxyWrapper;
|
362
|
+
this.apis = this.guest.apis || {};
|
363
|
+
this.isLoaded = true;
|
364
|
+
if (this.debugLogger) {
|
365
|
+
this.debugLogger.info(
|
366
|
+
`Guest ${this.id} established connection, received methods`,
|
367
|
+
this.apis,
|
368
|
+
this
|
369
|
+
);
|
370
|
+
}
|
371
|
+
}
|
372
|
+
|
373
|
+
private getHostMethodCallee<T = unknown>(
|
374
|
+
{ name, path }: HostMethodAddress,
|
375
|
+
methodSource: RemoteHostApis
|
376
|
+
): Methods {
|
377
|
+
const dots = (level: number) =>
|
378
|
+
`uix.host.${path.slice(0, level).join(".")}`;
|
379
|
+
const methodCallee = path.reduce((current, prop, level) => {
|
380
|
+
this.assert(
|
381
|
+
Reflect.has(current, prop),
|
382
|
+
() => `${dots(level)} has no property "${prop}"`
|
383
|
+
);
|
384
|
+
const next = current[prop];
|
385
|
+
this.assert(
|
386
|
+
typeof next === "object",
|
387
|
+
() =>
|
388
|
+
`${dots(
|
389
|
+
level
|
390
|
+
)}.${prop} is not an object; namespaces must be objects with methods`
|
391
|
+
);
|
392
|
+
return next as RemoteHostApis<GuestApi>;
|
393
|
+
}, methodSource);
|
394
|
+
this.assert(
|
395
|
+
typeof methodCallee[name] === "function" &&
|
396
|
+
Reflect.has(methodCallee, name),
|
397
|
+
() => `"${dots(path.length - 1)}.${name}" is not a function`
|
398
|
+
);
|
399
|
+
return methodCallee;
|
400
|
+
}
|
401
|
+
|
402
|
+
private invokeHostMethod<T = unknown>(
|
403
|
+
address: HostMethodAddress,
|
404
|
+
privateMethods?: RemoteHostApis
|
405
|
+
): T {
|
406
|
+
const { name, path, args = [] } = address;
|
407
|
+
this.assert(name && typeof name === "string", () => "Method name required");
|
408
|
+
this.assert(
|
409
|
+
path.length > 0,
|
410
|
+
() =>
|
411
|
+
`Cannot call a method directly on the host; ".${name}()" must be in a namespace.`
|
412
|
+
);
|
413
|
+
let methodCallee;
|
414
|
+
if (privateMethods) {
|
415
|
+
try {
|
416
|
+
methodCallee = this.getHostMethodCallee(address, privateMethods);
|
417
|
+
} catch (e) {
|
418
|
+
methodCallee = this.getHostMethodCallee(address, this.hostApis);
|
419
|
+
}
|
420
|
+
}
|
421
|
+
const method = methodCallee[name] as (...args: unknown[]) => T;
|
422
|
+
this.emit("beforecallhostmethod", { guestPort: this, name, path, args });
|
423
|
+
return method.apply(methodCallee, [
|
424
|
+
{ id: this.id, url: this.url },
|
425
|
+
...args,
|
426
|
+
]) as T;
|
427
|
+
}
|
428
|
+
|
429
|
+
// #endregion Private Methods (6)
|
430
|
+
}
|
package/tsconfig.json
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
{
|
2
|
+
"$schema": "http://json.schemastore.org/tsconfig",
|
3
|
+
"extends": "../../tsconfig-base.json",
|
4
|
+
"compilerOptions": {
|
5
|
+
"outDir": "dist",
|
6
|
+
"rootDir": "src"
|
7
|
+
},
|
8
|
+
"include": [
|
9
|
+
"src/**/*"
|
10
|
+
],
|
11
|
+
"exclude": [
|
12
|
+
"src/**/*.test.tsx?"
|
13
|
+
],
|
14
|
+
"references": [
|
15
|
+
{
|
16
|
+
"path": "../uix-core"
|
17
|
+
}
|
18
|
+
]
|
19
|
+
}
|