Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support callFunction API #850

Merged
merged 18 commits into from
Jun 5, 2020
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions demo/call-function/call-function.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
function performComplexMath() {
return Math.random() * 1000;
}

function getRemoteData() {
return Promise.resolve({ big: 'tuna' });
}

function immediatelyThrow() {
throw new Error('Immediately threw');
}

function reject() {
return Promise.reject('Unsupported operation.');
}

function returnsUndefined() {
return Promise.resolve(undefined);
}

function add(n1, n2) {
return n1 + n2;
}

function concat() {
let combined = [];
for (let i = 0; i < arguments.length; i++) {
combined = combined.concat(arguments[i]);
}
return combined;
}

[performComplexMath, getRemoteData, immediatelyThrow, reject, add, concat, returnsUndefined].map((fn) => {
exportFunction(fn.name, fn);
});

// Manual test for .onerror, by scheduling an unhandled error
// 2s in via prompt which isn't valid in a Worker.
setTimeout(() => prompt(), 2000);
51 changes: 51 additions & 0 deletions demo/call-function/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Call function</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script src="/dist/amp/main.mjs" type="module"></script>
</head>
<body>
<div src="call-function.js" id="upgrade-me"></div>
<script type="module">
import { upgradeElement } from "/dist/amp/main.mjs";
upgradeElement(
document.getElementById("upgrade-me"),
"/dist/amp/worker/worker.mjs"
).then((worker) => {
worker
.callFunction("performComplexMath")
.then((result) => console.log(`Complex math result: ${result}`));
worker
.callFunction("getRemoteData")
.then((result) => console.log(`Remote data: ${result}`));
worker
.callFunction("add", 40, 2)
.then((result) => console.log(`Answer to it all: ${result}`));

worker
.callFunction("concat", [1, 2, 3], ["4", "5"])
.then((result) =>
console.log(`concat([1,2,3], ["4", "5"]) is: ${result}`)
);

worker.callFunction('returnsUndefined').then(result => {
console.log(`undefined --> ${result}`)
})

worker.callFunction("reject").catch((err) => console.error(err));
worker
.callFunction("tooCoolToExist")
.catch((err) => console.error(err));
worker
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you add an await example to this demo?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sure thing!

.callFunction("immediatelyThrow")
.catch((err) => console.error(err));

worker.onerror = (err) => {
console.error("Catching an unhandled error: ", err);
};
});
</script>
</body>
</html>
1 change: 1 addition & 0 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ <h3>Basic</h3>
<li><a href='canvas/'>Canvas</a></li>
<li><a href='webassembly/'>WebAssembly</a></li>
<li><a href='default-input-listener/'>Default Input Listener</a></li>
<li><a href='call-function/'>Call Function</a></li>
</ul>

<h3>Frameworks</h3>
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ampproject/worker-dom",
"version": "0.24.0",
"version": "0.25.0",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need to manually update the version?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can I automatically update the version?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The release script will do this automatically (check out RELEASING.md).

"description": "A facsimile of a modern DOM implementation intended to run in a Web Worker.",
"main": "dist/main",
"files": [
Expand Down Expand Up @@ -95,7 +95,7 @@
"brotli": "12.6 kB"
},
"./dist/main.mjs": {
"brotli": "3.5 kB"
"brotli": "3.68 kB"
},
"./dist/main.js": {
"brotli": "4.1 kB"
Expand Down
93 changes: 93 additions & 0 deletions src/main-thread/commands/function.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* Copyright 2020 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { CommandExecutorInterface } from './interface';
import { TransferrableMutationType, FunctionMutationIndex } from '../../transfer/TransferrableMutation';
import { ResolveOrReject } from '../../transfer/Messages';

let fnCallCount = 0;

/**
* A mapping between each request to callFunction and its Promise.
*/
const promiseMap: {
[id: number]: {
promise: Promise<any>;
resolve: (arg: any) => void;
reject: (arg: any) => void;
};
} = {};

/**
* Each invocation of `ExportedWorker.prototype.callFunction` needs to be registered with a unique index
* and promise. The index is given to the underlying Worker and returned by it as well. That enables the main-thread to
* correlate postMessage responses with their original requests and resolve/reject the correct Promise.
*/
export function registerPromise(): { promise: Promise<any>; index: number } {
// TS won't realize that the constructor promise assigns the handlers, so we `any` them.
let resolve: any;
let reject: any;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});

// Wraparound to 0 in case someone attempts to register over 9 quadrillion promises.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL. This is likely not necessary, but funny nonetheless.

if (fnCallCount >= Number.MAX_VALUE) {
fnCallCount = 0;
}
const index = fnCallCount++;

promiseMap[index] = { promise, resolve, reject };
return { promise, index };
}

export const FunctionProcessor: CommandExecutorInterface = (strings, nodeContext, workerContext, objectContext, config) => {
const allowedExecution = config.executorsAllowed.includes(TransferrableMutationType.FUNCTION_CALL);

return {
execute(mutations: Uint16Array, startPosition: number): number {
if (allowedExecution) {
const status = mutations[startPosition + FunctionMutationIndex.Status];
const index = mutations[startPosition + FunctionMutationIndex.Index];
const value = mutations[startPosition + FunctionMutationIndex.Value];

const parsed = strings.hasIndex(value) ? JSON.parse(strings.get(value)) : undefined;
if (status === ResolveOrReject.RESOLVE) {
promiseMap[index].resolve(parsed);
} else {
promiseMap[index].reject(parsed);
}
delete promiseMap[index];
}
return startPosition + FunctionMutationIndex.End;
},

print(mutations: Uint16Array, startPosition: number): {} {
const status = mutations[startPosition + FunctionMutationIndex.Status];
const index = mutations[startPosition + FunctionMutationIndex.Index];
const value = mutations[startPosition + FunctionMutationIndex.Value];

return {
type: 'FUNCTION_INVOCATION',
status,
index,
value: strings.get(value),
allowedExecution,
};
},
};
};
67 changes: 67 additions & 0 deletions src/main-thread/exported-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Copyright 2020 The AMP HTML Authors. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS-IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { WorkerContext } from './worker';
import { WorkerDOMConfiguration } from './configuration';
import { registerPromise } from './commands/function';
import { FunctionCallToWorker, MessageType } from '../transfer/Messages';
import { TransferrableKeys } from '../transfer/TransferrableKeys';
import { TransferrableMutationType } from '../transfer/TransferrableMutation';

/**
* An ExportedWorker is returned by the upgradeElement API.
* For the most part, it delegates to the underlying Worker.
*
* It notably removes `postMessage` support and add `callFunction`.
*/
export class ExportedWorker {
samouri marked this conversation as resolved.
Show resolved Hide resolved
workerContext_: WorkerContext;
config: WorkerDOMConfiguration;

constructor(workerContext: WorkerContext, config: WorkerDOMConfiguration) {
this.workerContext_ = workerContext;
this.config = config;
}

/**
* Calls a function in the worker and returns a promise with the result.
* @param functionIdentifer
* @param functionArguments
*/
callFunction(functionIdentifer: string, ...functionArguments: any[]): Promise<any> {
if (!this.config.executorsAllowed.includes(TransferrableMutationType.FUNCTION_CALL)) {
throw new Error(`[worker-dom]: Error calling ${functionIdentifer}. You must enable the FUNCTION_CALL executor within the config.`);
}

const { promise, index } = registerPromise();
const msg: FunctionCallToWorker = {
[TransferrableKeys.type]: MessageType.FUNCTION,
[TransferrableKeys.functionIdentifier]: functionIdentifer,
[TransferrableKeys.functionArguments]: JSON.stringify(functionArguments),
[TransferrableKeys.index]: index,
};
this.workerContext_.messageToWorker(msg);
return promise;
}

set onerror(handler: any) {
this.workerContext_.worker.onerror = handler;
}

terminate(): void {
this.workerContext_.worker.terminate();
}
}
14 changes: 12 additions & 2 deletions src/main-thread/index.amp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import { fetchAndInstall, install } from './install';
import { WorkerDOMConfiguration, LongTaskFunction } from './configuration';
import { toLower } from '../utils';
import { ExportedWorker } from './exported-worker';

/**
* AMP Element Children need to be filtered from Hydration, to avoid Author Code from manipulating it.
Expand All @@ -39,7 +40,12 @@ const hydrateFilter = (element: RenderableElement) => {
* @param baseElement
* @param domURL
*/
export function upgradeElement(baseElement: Element, domURL: string, longTask?: LongTaskFunction, sanitizer?: Sanitizer): Promise<Worker | null> {
export function upgradeElement(
baseElement: Element,
domURL: string,
longTask?: LongTaskFunction,
sanitizer?: Sanitizer,
): Promise<ExportedWorker | null> {
const authorURL = baseElement.getAttribute('src');
if (authorURL) {
return fetchAndInstall(baseElement as HTMLElement, {
Expand All @@ -57,7 +63,11 @@ export function upgradeElement(baseElement: Element, domURL: string, longTask?:
* @param baseElement
* @param fetchPromise Promise that resolves containing worker script, and author script.
*/
export function upgrade(baseElement: Element, fetchPromise: Promise<[string, string]>, config: WorkerDOMConfiguration): Promise<Worker | null> {
export function upgrade(
baseElement: Element,
fetchPromise: Promise<[string, string]>,
config: WorkerDOMConfiguration,
): Promise<ExportedWorker | null> {
config.hydrateFilter = hydrateFilter;
return install(fetchPromise, baseElement as HTMLElement, config);
}
3 changes: 2 additions & 1 deletion src/main-thread/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@
*/

import { fetchAndInstall } from './install';
import { ExportedWorker } from './exported-worker';

export function upgradeElement(baseElement: Element, domURL: string): Promise<Worker | null> {
export function upgradeElement(baseElement: Element, domURL: string): Promise<ExportedWorker | null> {
const authorURL = baseElement.getAttribute('src');
if (authorURL) {
return fetchAndInstall(baseElement as HTMLElement, {
Expand Down
7 changes: 4 additions & 3 deletions src/main-thread/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { TransferrableKeys } from '../transfer/TransferrableKeys';
import { InboundWorkerDOMConfiguration, normalizeConfiguration } from './configuration';
import { WorkerContext } from './worker';
import { ObjectContext } from './object-context';
import { ExportedWorker } from './exported-worker';

const ALLOWABLE_MESSAGE_TYPES = [MessageType.MUTATE, MessageType.HYDRATE];

Expand All @@ -33,7 +34,7 @@ const ALLOWABLE_MESSAGE_TYPES = [MessageType.MUTATE, MessageType.HYDRATE];
* @param sanitizer
* @param debug
*/
export function fetchAndInstall(baseElement: HTMLElement, config: InboundWorkerDOMConfiguration): Promise<Worker | null> {
export function fetchAndInstall(baseElement: HTMLElement, config: InboundWorkerDOMConfiguration): Promise<ExportedWorker | null> {
const fetchPromise = Promise.all([
// TODO(KB): Fetch Polyfill for IE11.
fetch(config.domURL).then((response) => response.text()),
Expand All @@ -51,7 +52,7 @@ export function install(
fetchPromise: Promise<[string, string]>,
baseElement: HTMLElement,
config: InboundWorkerDOMConfiguration,
): Promise<Worker | null> {
): Promise<ExportedWorker | null> {
const stringContext = new StringContext();
const objectContext = new ObjectContext();
const nodeContext = new NodeContext(stringContext, baseElement);
Expand Down Expand Up @@ -79,7 +80,7 @@ export function install(
}
};

return workerContext.worker;
return new ExportedWorker(workerContext, normalizedConfig);
}
return null;
});
Expand Down
2 changes: 2 additions & 0 deletions src/main-thread/mutator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { ObjectCreationProcessor } from './commands/object-creation';
import { ObjectContext } from './object-context';
import { ImageBitmapProcessor } from './commands/image-bitmap';
import { StorageProcessor } from './commands/storage';
import { FunctionProcessor } from './commands/function';

export class MutatorProcessor {
private stringContext: StringContext;
Expand Down Expand Up @@ -86,6 +87,7 @@ export class MutatorProcessor {
[TransferrableMutationType.OBJECT_CREATION]: ObjectCreationProcessor.apply(null, args),
[TransferrableMutationType.IMAGE_BITMAP_INSTANCE]: ImageBitmapProcessor.apply(null, args),
[TransferrableMutationType.STORAGE]: StorageProcessor.apply(null, args),
[TransferrableMutationType.FUNCTION_CALL]: FunctionProcessor.apply(null, args),
};
}

Expand Down
4 changes: 4 additions & 0 deletions src/main-thread/strings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export class StringContext {
return this.strings[index] || '';
}

hasIndex(index: number) {
return this.strings[index] !== undefined;
}

/**
* Stores a string in mapping and returns the index of the location.
* @param value string to store
Expand Down
Loading