@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/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
+ }