-
Notifications
You must be signed in to change notification settings - Fork 274
/
Copy pathservice-worker.ts
349 lines (324 loc) · 10.7 KB
/
service-worker.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
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
/// <reference lib="WebWorker" />
declare const self: ServiceWorkerGlobalScope;
import { getURLScope, isURLScoped, removeURLScope } from '@php-wasm/scopes';
import { applyRewriteRules } from '@php-wasm/universal';
import {
awaitReply,
convertFetchEventToPHPRequest,
initializeServiceWorker,
cloneRequest,
broadcastMessageExpectReply,
} from '@php-wasm/web-service-worker';
import { wordPressRewriteRules } from '@wp-playground/wordpress';
import { reportServiceWorkerMetrics } from '@php-wasm/logger';
import { OfflineModeCache } from './src/lib/offline-mode-cache';
if (!(self as any).document) {
// Workaround: vite translates import.meta.url
// to document.currentScript which fails inside of
// a service worker because document is undefined
// @ts-ignore
// eslint-disable-next-line no-global-assign
self.document = {};
}
/**
* Ensures the very first Playground load is controlled by this service worker.
*
* This is necessary because service workers don't control any pages loaded
* before they are activated. This includes the page that actually registers
* the service worker. You need to reload it before `navigator.serviceWorker.controller`
* is set and the fetch() requests are intercepted here.
*
* However, the initial Playground load already downloads a few large assets,
* like a 12MB wordpress-static.zip file. We need to cache them these requests.
* Otherwise they'll be fetched again on the next page load.
*
* client.claim() only affects pages loaded before the initial servie worker
* registration. It shouldn't have unwanted side effects in our case. All these
* pages would get controlled eventually anyway.
*
* See:
* * The service worker lifecycle https://web.dev/articles/service-worker-lifecycle
* * Clients.claim() docs https://developer.mozilla.org/en-US/docs/Web/API/Clients/claim
*/
self.addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim());
});
/**
* Handle fetch() caching:
*
* * Put the initial fetch response in the cache
* * Serve the subsequent requests from the cache
*/
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
/**
* Don't cache requests to the service worker script itself.
*/
if (url.pathname.startsWith(self.location.pathname)) {
return;
}
/**
* Don't cache requests to scoped URLs or if the referrer URL is scoped.
*
* These requests are made to the PHP Worker Thread and are not static assets.
*/
if (isURLScoped(url)) {
return;
}
let referrerUrl;
try {
referrerUrl = new URL(event.request.referrer);
} catch (e) {
// ignore
}
if (referrerUrl && isURLScoped(referrerUrl)) {
return;
}
/**
* Respond with cached assets if available.
* If the asset is not cached, fetch it from the network and cache it.
*/
event.respondWith(
cachePromise.then((cache) => cache.cachedFetch(event.request))
);
});
reportServiceWorkerMetrics(self);
const cachePromise = OfflineModeCache.getInstance().then((cache) => {
/**
* For offline mode to work we need to cache all required assets.
*
* These assets are listed in the `/assets-required-for-offline-mode.json` file
* and contain JavaScript, CSS, and other assets required to load the site without
* making any network requests.
*/
cache.cacheOfflineModeAssets();
/**
* Remove outdated files from the cache.
*
* We cache data based on `buildVersion` which is updated whenever Playground is built.
* So when a new version of Playground is deployed, the service worker will remove the old cache and cache the new assets.
*
* If your build version doesn't change while developing locally check `buildVersionPlugin` for more details on how it's generated.
*/
cache.removeOutdatedFiles();
return cache;
});
initializeServiceWorker({
handleRequest(event) {
const fullUrl = new URL(event.request.url);
let scope = getURLScope(fullUrl);
if (!scope) {
try {
scope = getURLScope(new URL(event.request.referrer));
} catch (e) {
// Ignore
}
}
const unscopedUrl = removeURLScope(fullUrl);
const isReservedUrl =
unscopedUrl.pathname.startsWith('/plugin-proxy') ||
unscopedUrl.pathname.startsWith('/client/index.js');
if (isReservedUrl) {
return;
}
event.preventDefault();
async function asyncHandler() {
if (fullUrl.pathname.endsWith('/wp-includes/empty.html')) {
return emptyHtml();
}
const workerResponse = await convertFetchEventToPHPRequest(event);
if (
workerResponse.status === 404 &&
workerResponse.headers.get('x-backfill-from') === 'remote-host'
) {
const { staticAssetsDirectory } = await getScopedWpDetails(
scope!
);
if (!staticAssetsDirectory) {
const plain404Response = workerResponse.clone();
plain404Response.headers.delete('x-backfill-from');
return plain404Response;
}
// If we get a 404 for a static file, try to fetch it from
// the from the static assets directory at the remote server.
const requestedUrl = new URL(event.request.url);
const resolvedUrl = removeURLScope(requestedUrl);
resolvedUrl.pathname = applyRewriteRules(
resolvedUrl.pathname,
wordPressRewriteRules
);
if (
// Vite dev server requests
!resolvedUrl.pathname.startsWith('/@fs') &&
!resolvedUrl.pathname.startsWith('/assets')
) {
resolvedUrl.pathname = `/${staticAssetsDirectory}${resolvedUrl.pathname}`;
}
const request = await cloneRequest(event.request, {
url: resolvedUrl,
// Omit credentials to avoid causing cache aborts due to presence of cookies
credentials: 'omit',
});
return fetch(request).catch((e) => {
if (e?.name === 'TypeError') {
// This could be an ERR_HTTP2_PROTOCOL_ERROR that sometimes
// happen on playground.wordpress.net. Let's add a randomized
// delay and retry once
return new Promise((resolve) => {
setTimeout(() => {
resolve(fetch(request));
}, Math.random() * 1500);
}) as Promise<Response>;
}
// Otherwise let's just re-throw the error
throw e;
});
}
// Path the block-editor.js file to ensure the site editor's iframe
// inherits the service worker.
// @see controlledIframe below for more details.
if (
// WordPress Core version of block-editor.js
unscopedUrl.pathname.endsWith(
'/wp-includes/js/dist/block-editor.js'
) ||
unscopedUrl.pathname.endsWith(
'/wp-includes/js/dist/block-editor.min.js'
) ||
// Gutenberg version of block-editor.js
unscopedUrl.pathname.endsWith('/build/block-editor/index.js') ||
unscopedUrl.pathname.endsWith(
'/build/block-editor/index.min.js'
)
) {
const script = await workerResponse.text();
const newScript = `${controlledIframe} ${script.replace(
/\(\s*"iframe",/,
'(__playground_ControlledIframe,'
)}`;
return new Response(newScript, {
status: workerResponse.status,
statusText: workerResponse.statusText,
headers: workerResponse.headers,
});
}
return workerResponse;
}
return asyncHandler();
},
});
/**
* Pair the site editor's nested iframe to the Service Worker.
*
* Without the patch below, the site editor initiates network requests that
* aren't routed through the service worker. That's a known browser issue:
*
* * https://bugs.chromium.org/p/chromium/issues/detail?id=880768
* * https://bugzilla.mozilla.org/show_bug.cgi?id=1293277
* * https://github.com/w3c/ServiceWorker/issues/765
*
* The problem with iframes using srcDoc and src="about:blank" as they
* fail to inherit the root site's service worker.
*
* Gutenberg loads the site editor using <iframe srcDoc="<!doctype html">
* to force the standards mode and not the quirks mode:
*
* https://github.com/WordPress/gutenberg/pull/38855
*
* This commit patches the site editor to achieve the same result via
* <iframe src="/doctype.html"> and a doctype.html file containing just
* `<!doctype html>`. This allows the iframe to inherit the service worker
* and correctly load all the css, js, fonts, images, and other assets.
*
* Ideally this issue would be fixed directly in Gutenberg and the patch
* below would be removed.
*
* See https://github.com/WordPress/wordpress-playground/issues/42 for more details
*
* ## Why does this code live in the service worker?
*
* There's many ways to install the Gutenberg plugin:
*
* * Install plugin step
* * Import a site
* * Install Gutenberg from the plugin directory
* * Upload a Gutenberg zip
*
* It's too difficult to patch Gutenberg in all these cases, so we blanket-patch
* all the scripts requested over the network whose names seem to indicate they're
* related to the Gutenberg plugin.
*/
const controlledIframe = `
window.__playground_ControlledIframe = window.wp.element.forwardRef(function (props, ref) {
const source = window.wp.element.useMemo(function () {
/**
* A synchronous function to read a blob URL as text.
*
* @param {string} url
* @returns {string}
*/
const __playground_readBlobAsText = function (url) {
try {
let xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.overrideMimeType('text/plain;charset=utf-8');
xhr.send();
return xhr.responseText;
} catch(e) {
return '';
} finally {
URL.revokeObjectURL(url);
}
};
if (props.srcDoc) {
// WordPress <= 6.2 uses a srcDoc that only contains a doctype.
return '/wp-includes/empty.html';
} else if (props.src && props.src.startsWith('blob:')) {
// WordPress 6.3 uses a blob URL with doctype and a list of static assets.
// Let's pass the document content to empty.html and render it there.
return '/wp-includes/empty.html#' + encodeURIComponent(__playground_readBlobAsText(props.src));
} else {
// WordPress >= 6.4 uses a plain HTTPS URL that needs no correction.
return props.src;
}
}, [props.src]);
return (
window.wp.element.createElement('iframe', {
...props,
ref: ref,
src: source,
// Make sure there's no srcDoc, as it would interfere with the src.
srcDoc: undefined
})
)
});`;
/**
* The empty HTML file loaded by the patched editor iframe.
*/
function emptyHtml() {
return new Response(
'<!doctype html><script>const hash = window.location.hash.substring(1); if ( hash ) document.write(decodeURIComponent(hash))</script>',
{
status: 200,
headers: {
'content-type': 'text/html',
},
}
);
}
type WPModuleDetails = {
staticAssetsDirectory?: string;
};
const scopeToWpModule: Record<string, WPModuleDetails> = {};
async function getScopedWpDetails(scope: string): Promise<WPModuleDetails> {
if (!scopeToWpModule[scope]) {
const requestId = await broadcastMessageExpectReply(
{
method: 'getWordPressModuleDetails',
},
scope
);
scopeToWpModule[scope] = await awaitReply(self, requestId);
}
return scopeToWpModule[scope];
}