-
Notifications
You must be signed in to change notification settings - Fork 15
/
JSDOMRunner.ts
274 lines (235 loc) · 8.81 KB
/
JSDOMRunner.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
import vm from "vm";
import { LogMessage, RemoteEvent } from "@mml-io/observable-dom-common";
import { AbortablePromise, DOMWindow, JSDOM, ResourceLoader, VirtualConsole } from "jsdom";
import * as nodeFetch from "node-fetch";
import nodeFetchFn from "node-fetch";
import { DOMRunnerFactory, DOMRunnerInterface, DOMRunnerMessage } from "./ObservableDOM";
const ErrDOMWindowNotInitialized = "DOMWindow not initialized";
// TODO - remove this monkeypatching if it's possible to avoid the race conditions in naive MutationObserver usage
const monkeyPatchedMutationRecordCallbacks = new Set<() => void>();
function installMutationObserverMonkeyPatch() {
/*
This monkey patch replaces the `createImpl` exported function implementation in the `MutationRecord` class in JSDOM
to insert an iteration through callbacks that are therefore fired before a subsequent MutationRecord is created.
This provides an opportunity to invoke the MutationObservers with a single MutationRecord rather than multiple.
This is necessary as (at least intuitive) usage of the MutationObserver API does not enable creating accurate
incremental diffs as the handling of all-but-the-last MutationRecord in a list requires collecting state from the
DOM that has been since been mutated further. (e.g. if an attribute is changed twice in a row the first event cannot
discover the intermediate value of the attribute as it can only query the latest DOM state). Whilst this simple case
is solvable by walking backwards through the list of MutationRecords and using `oldValue` there are cases where adding
child elements with the correct attributes is not possible when handling intermediate diffs.
*/
// eslint-disable-next-line @typescript-eslint/no-var-requires
const MutationRecordExports = require("jsdom/lib/jsdom/living/generated/MutationRecord");
const originalCreateImpl = MutationRecordExports.createImpl;
// This overwrites the function property on the exports that mutation-observers.js uses to create MutationRecords.
MutationRecordExports.createImpl = (...args: any[]) => {
for (const callback of monkeyPatchedMutationRecordCallbacks) {
callback();
}
return originalCreateImpl.call(null, ...args);
};
}
let monkeyPatchInstalled = false;
export const JSDOMRunnerFactory: DOMRunnerFactory = (
htmlPath: string,
htmlContents: string,
params: object,
callback: (mutationList: DOMRunnerMessage) => void,
): DOMRunnerInterface => {
return new JSDOMRunner(htmlPath, htmlContents, params, callback);
};
// This is used to stop JSDOM trying to load resources
class RejectionResourceLoader extends ResourceLoader {
public fetch(url: string): AbortablePromise<Buffer> | null {
console.error("RejectionResourceLoader.fetch", url);
return null;
}
}
export class JSDOMRunner {
private monkeyPatchMutationRecordCallback: () => void;
public domWindow: DOMWindow | null = null;
private jsdom: JSDOM;
private callback: (message: DOMRunnerMessage) => void;
private mutationObserver: MutationObserver | null = null;
private htmlPath: string;
private documentStartTime = Date.now();
private isLoaded = false;
private logBuffer: LogMessage[] = [];
constructor(
htmlPath: string,
htmlContents: string,
params: object,
callback: (domRunnerMessage: DOMRunnerMessage) => void,
) {
this.htmlPath = htmlPath;
this.callback = callback;
if (!monkeyPatchInstalled) {
installMutationObserverMonkeyPatch();
monkeyPatchInstalled = true;
}
this.monkeyPatchMutationRecordCallback = () => {
/*
This is called before every creation of a MutationRecord so that it can be used to process an existing record to
avoid handling multiple MutationRecords at a time (see comment at the top of this file).
*/
const records = this.mutationObserver?.takeRecords();
if (records && records.length > 1) {
throw new Error(
"The monkey patching should have prevented more than one record being handled at a time",
);
} else if (records && records.length > 0) {
this.callback({
mutationList: records,
});
}
};
monkeyPatchedMutationRecordCallbacks.add(this.monkeyPatchMutationRecordCallback);
this.jsdom = new JSDOM(htmlContents, {
runScripts: "dangerously",
resources: new RejectionResourceLoader(),
url: this.htmlPath,
virtualConsole: this.createVirtualConsole(),
beforeParse: (window) => {
this.domWindow = window;
this.domWindow.fetch = nodeFetchFn as unknown as typeof fetch;
this.domWindow.Headers = nodeFetch.Headers as unknown as typeof Headers;
this.domWindow.Request = nodeFetch.Request as unknown as typeof Request;
this.domWindow.Response = nodeFetch.Response as unknown as typeof Response;
// This is a polyfill for https://developer.mozilla.org/en-US/docs/Web/API/Document/timeline
const timeline = {};
Object.defineProperty(timeline, "currentTime", {
get: () => {
return this.getDocumentTime();
},
});
(window.document as any).timeline = timeline;
// JSON stringify and parse to avoid potential reference leaks from the params object
window.params = JSON.parse(JSON.stringify(params));
this.mutationObserver = new window.MutationObserver((mutationList) => {
this.callback({
mutationList,
});
});
window.addEventListener("load", () => {
this.mutationObserver?.observe(window.document, {
attributes: true,
childList: true,
subtree: true,
characterData: true,
});
this.isLoaded = true;
this.callback({
loaded: true,
});
this.flushLogBuffer();
});
},
});
}
private flushLogBuffer() {
for (const logMessage of this.logBuffer) {
this.callback({
logMessage,
});
}
this.logBuffer = [];
}
private log(message: LogMessage) {
if (!this.isLoaded) {
this.logBuffer.push(message);
return;
}
this.callback({
logMessage: message,
});
}
public getDocument(): Document {
if (!this.domWindow) {
throw new Error(ErrDOMWindowNotInitialized);
}
return this.domWindow.document;
}
public getWindow(): any {
return this.domWindow;
}
public dispose() {
const records = this.mutationObserver?.takeRecords();
this.callback({
mutationList: records,
});
monkeyPatchedMutationRecordCallbacks.delete(this.monkeyPatchMutationRecordCallback);
this.mutationObserver?.disconnect();
this.jsdom.window.close();
}
public getDocumentTime() {
return Date.now() - this.documentStartTime;
}
public dispatchRemoteEventFromConnectionId(
connectionId: number,
domNode: Element,
remoteEvent: RemoteEvent,
) {
if (!this.domWindow) {
throw new Error(ErrDOMWindowNotInitialized);
}
const bubbles = remoteEvent.bubbles || false;
const remoteEventObject = new this.domWindow.CustomEvent(remoteEvent.name, {
bubbles,
detail: { ...remoteEvent.params, connectionId },
});
const eventTypeLowerCase = remoteEvent.name.toLowerCase();
// TODO - check if there are other events that automatically wire up similarly to click->onclick and avoid those too
if (eventTypeLowerCase !== "click") {
const handlerAttributeName = "on" + eventTypeLowerCase;
const handlerAttributeValue = domNode.getAttribute(handlerAttributeName);
if (handlerAttributeValue) {
// This event is defined as an HTML event attribute.
const script = handlerAttributeValue;
const vmContext = this.jsdom.getInternalVMContext();
try {
const invoke = vm.runInContext(`(function(event){ ${script} })`, vmContext);
Reflect.apply(invoke, domNode, [remoteEventObject]);
} catch (e) {
console.error("Error running event handler:", e);
}
}
}
// Dispatch the event via JavaScript.
domNode.dispatchEvent(remoteEventObject);
}
private createVirtualConsole(): VirtualConsole {
const virtualConsole = new VirtualConsole();
virtualConsole.on("jsdomError", (...args) => {
this.log({
level: "system",
content: args,
});
});
virtualConsole.on("error", (...args) => {
this.log({
level: "error",
content: args,
});
});
virtualConsole.on("warn", (...args) => {
this.log({
level: "warn",
content: args,
});
});
virtualConsole.on("log", (...args) => {
this.log({
level: "log",
content: args,
});
});
virtualConsole.on("info", (...args) => {
this.log({
level: "info",
content: args,
});
});
return virtualConsole;
}
}