From a467d0684ed26cdd45b022a5a70d63fa8ec6b437 Mon Sep 17 00:00:00 2001 From: Sam Maddock Date: Fri, 24 Jan 2025 08:33:44 -0500 Subject: [PATCH] feat: ServiceWorkerMain (#45232) * feat: ServiceWorkerMain * refactor: disconnect remote * handle version_info_ nullptr case * initiate finish request when possible and enumerate errors * explicit name for test method * oops * fix: wait for redundant version to stop before destroying * docs: clarify when undefined is returned * chore: remove extra semicolons --- docs/README.md | 1 + docs/api/service-worker-main.md | 34 ++ docs/api/service-workers.md | 61 ++- docs/api/structures/service-worker-info.md | 1 + docs/breaking-changes.md | 29 +- filenames.auto.gni | 2 + filenames.gni | 4 + lib/browser/api/module-list.ts | 1 + lib/browser/api/service-worker-main.ts | 17 + lib/browser/init.ts | 3 + .../electron_api_service_worker_context.cc | 149 +++++++- .../api/electron_api_service_worker_context.h | 48 ++- .../api/electron_api_service_worker_main.cc | 359 ++++++++++++++++++ .../api/electron_api_service_worker_main.h | 178 +++++++++ .../service_worker_converter.cc | 25 ++ .../gin_converters/service_worker_converter.h | 21 + shell/common/node_bindings.cc | 67 ++-- spec/api-service-worker-main-spec.ts | 291 ++++++++++++++ spec/fixtures/api/service-workers/index.html | 3 +- .../api/service-workers/sw-script-error.js | 1 + .../api/service-workers/sw-unregister-self.js | 3 + typings/internal-ambient.d.ts | 5 + typings/internal-electron.d.ts | 12 + 23 files changed, 1265 insertions(+), 50 deletions(-) create mode 100644 docs/api/service-worker-main.md create mode 100644 lib/browser/api/service-worker-main.ts create mode 100644 shell/browser/api/electron_api_service_worker_main.cc create mode 100644 shell/browser/api/electron_api_service_worker_main.h create mode 100644 shell/common/gin_converters/service_worker_converter.cc create mode 100644 shell/common/gin_converters/service_worker_converter.h create mode 100644 spec/api-service-worker-main-spec.ts create mode 100644 spec/fixtures/api/service-workers/sw-script-error.js create mode 100644 spec/fixtures/api/service-workers/sw-unregister-self.js diff --git a/docs/README.md b/docs/README.md index 65fa5815e753..98ae1ad9a79a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -127,6 +127,7 @@ These individual tutorials expand on topics discussed in the guide above. * [pushNotifications](api/push-notifications.md) * [safeStorage](api/safe-storage.md) * [screen](api/screen.md) +* [ServiceWorkerMain](api/service-worker-main.md) * [session](api/session.md) * [ShareMenu](api/share-menu.md) * [systemPreferences](api/system-preferences.md) diff --git a/docs/api/service-worker-main.md b/docs/api/service-worker-main.md new file mode 100644 index 000000000000..a1c3889661ca --- /dev/null +++ b/docs/api/service-worker-main.md @@ -0,0 +1,34 @@ +# ServiceWorkerMain + +> An instance of a Service Worker representing a version of a script for a given scope. + +Process: [Main](../glossary.md#main-process) + +## Class: ServiceWorkerMain + +Process: [Main](../glossary.md#main-process)
+_This class is not exported from the `'electron'` module. It is only available as a return value of other methods in the Electron API._ + +### Instance Methods + +#### `serviceWorker.isDestroyed()` _Experimental_ + +Returns `boolean` - Whether the service worker has been destroyed. + +#### `serviceWorker.startTask()` _Experimental_ + +Returns `Object`: + +- `end` Function - Method to call when the task has ended. If never called, the service won't terminate while otherwise idle. + +Initiate a task to keep the service worker alive until ended. + +### Instance Properties + +#### `serviceWorker.scope` _Readonly_ _Experimental_ + +A `string` representing the scope URL of the service worker. + +#### `serviceWorker.versionId` _Readonly_ _Experimental_ + +A `number` representing the ID of the specific version of the service worker script in its scope. diff --git a/docs/api/service-workers.md b/docs/api/service-workers.md index f1fa64f62560..0d3796a93687 100644 --- a/docs/api/service-workers.md +++ b/docs/api/service-workers.md @@ -56,6 +56,17 @@ Returns: Emitted when a service worker has been registered. Can occur after a call to [`navigator.serviceWorker.register('/sw.js')`](https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register) successfully resolves or when a Chrome extension is loaded. +#### Event: 'running-status-changed' _Experimental_ + +Returns: + +* `details` Event\<\> + * `versionId` number - ID of the updated service worker version + * `runningStatus` string - Running status. + Possible values include `starting`, `running`, `stopping`, or `stopped`. + +Emitted when a service worker's running status has changed. + ### Instance Methods The following methods are available on instances of `ServiceWorkers`: @@ -64,10 +75,56 @@ The following methods are available on instances of `ServiceWorkers`: Returns `Record` - A [ServiceWorkerInfo](structures/service-worker-info.md) object where the keys are the service worker version ID and the values are the information about that service worker. -#### `serviceWorkers.getFromVersionID(versionId)` +#### `serviceWorkers.getInfoFromVersionID(versionId)` -* `versionId` number +* `versionId` number - ID of the service worker version Returns [`ServiceWorkerInfo`](structures/service-worker-info.md) - Information about this service worker If the service worker does not exist or is not running this method will throw an exception. + +#### `serviceWorkers.getFromVersionID(versionId)` _Deprecated_ + +* `versionId` number - ID of the service worker version + +Returns [`ServiceWorkerInfo`](structures/service-worker-info.md) - Information about this service worker + +If the service worker does not exist or is not running this method will throw an exception. + +**Deprecated:** Use the new `serviceWorkers.getInfoFromVersionID` API. + +#### `serviceWorkers.getWorkerFromVersionID(versionId)` _Experimental_ + +* `versionId` number - ID of the service worker version + +Returns [`ServiceWorkerMain | undefined`](service-worker-main.md) - Instance of the service worker associated with the given version ID. If there's no associated version, or its running status has changed to 'stopped', this will return `undefined`. + +#### `serviceWorkers.startWorkerForScope(scope)` _Experimental_ + +* `scope` string - The scope of the service worker to start. + +Returns `Promise` - Resolves with the service worker when it's started. + +Starts the service worker or does nothing if already running. + + + +```js +const { app, session } = require('electron') +const { serviceWorkers } = session.defaultSession + +// Collect service workers scopes +const workerScopes = Object.values(serviceWorkers.getAllRunning()).map((info) => info.scope) + +app.on('browser-window-created', async (event, window) => { + for (const scope of workerScopes) { + try { + // Ensure worker is started + await serviceWorkers.startWorkerForScope(scope) + } catch (error) { + console.error(`Failed to start service worker for ${scope}`) + console.error(error) + } + } +}) +``` diff --git a/docs/api/structures/service-worker-info.md b/docs/api/structures/service-worker-info.md index 37dd691d9624..c1192a6ccdce 100644 --- a/docs/api/structures/service-worker-info.md +++ b/docs/api/structures/service-worker-info.md @@ -3,3 +3,4 @@ * `scriptUrl` string - The full URL to the script that this service worker runs * `scope` string - The base URL that this service worker is active for. * `renderProcessId` number - The virtual ID of the process that this service worker is running in. This is not an OS level PID. This aligns with the ID set used for `webContents.getProcessId()`. +* `versionId` number - ID of the service worker version diff --git a/docs/breaking-changes.md b/docs/breaking-changes.md index 82c11f63e3be..67862f71f874 100644 --- a/docs/breaking-changes.md +++ b/docs/breaking-changes.md @@ -14,6 +14,21 @@ This document uses the following convention to categorize breaking changes: ## Planned Breaking API Changes (35.0) +### Deprecated: `getFromVersionID` on `session.serviceWorkers` + +The `session.serviceWorkers.fromVersionID(versionId)` API has been deprecated +in favor of `session.serviceWorkers.getInfoFromVersionID(versionId)`. This was +changed to make it more clear which object is returned with the introduction +of the `session.serviceWorkers.getWorkerFromVersionID(versionId)` API. + +```js +// Deprecated +session.serviceWorkers.fromVersionID(versionId) + +// Replace with +session.serviceWorkers.getInfoFromVersionID(versionId) +``` + ### Deprecated: `setPreloads`, `getPreloads` on `Session` `registerPreloadScript`, `unregisterPreloadScript`, and `getPreloadScripts` are introduced as a @@ -21,7 +36,7 @@ replacement for the deprecated methods. These new APIs allow third-party librari preload scripts without replacing existing scripts. Also, the new `type` option allows for additional preload targets beyond `frame`. -```ts +```js // Deprecated session.setPreloads([path.join(__dirname, 'preload.js')]) @@ -75,15 +90,15 @@ immediately upon being received. Otherwise, it's not guaranteed to point to the same webpage as when received. To avoid misaligned expectations, Electron will return `null` in the case of late access where the webpage has changed. -```ts +```js ipcMain.on('unload-event', (event) => { - event.senderFrame; // ✅ accessed immediately -}); + event.senderFrame // ✅ accessed immediately +}) ipcMain.on('unload-event', async (event) => { - await crossOriginNavigationPromise; - event.senderFrame; // ❌ returns `null` due to late access -}); + await crossOriginNavigationPromise + event.senderFrame // ❌ returns `null` due to late access +}) ``` ### Behavior Changed: custom protocol URL handling on Windows diff --git a/filenames.auto.gni b/filenames.auto.gni index cfc4e6484b00..388e3b3db87e 100644 --- a/filenames.auto.gni +++ b/filenames.auto.gni @@ -45,6 +45,7 @@ auto_filenames = { "docs/api/push-notifications.md", "docs/api/safe-storage.md", "docs/api/screen.md", + "docs/api/service-worker-main.md", "docs/api/service-workers.md", "docs/api/session.md", "docs/api/share-menu.md", @@ -243,6 +244,7 @@ auto_filenames = { "lib/browser/api/push-notifications.ts", "lib/browser/api/safe-storage.ts", "lib/browser/api/screen.ts", + "lib/browser/api/service-worker-main.ts", "lib/browser/api/session.ts", "lib/browser/api/share-menu.ts", "lib/browser/api/system-preferences.ts", diff --git a/filenames.gni b/filenames.gni index 8dd8b4d346e9..b2a13956fdd2 100644 --- a/filenames.gni +++ b/filenames.gni @@ -296,6 +296,8 @@ filenames = { "shell/browser/api/electron_api_screen.h", "shell/browser/api/electron_api_service_worker_context.cc", "shell/browser/api/electron_api_service_worker_context.h", + "shell/browser/api/electron_api_service_worker_main.cc", + "shell/browser/api/electron_api_service_worker_main.h", "shell/browser/api/electron_api_session.cc", "shell/browser/api/electron_api_session.h", "shell/browser/api/electron_api_system_preferences.cc", @@ -614,6 +616,8 @@ filenames = { "shell/common/gin_converters/osr_converter.cc", "shell/common/gin_converters/osr_converter.h", "shell/common/gin_converters/serial_port_info_converter.h", + "shell/common/gin_converters/service_worker_converter.cc", + "shell/common/gin_converters/service_worker_converter.h", "shell/common/gin_converters/std_converter.h", "shell/common/gin_converters/time_converter.cc", "shell/common/gin_converters/time_converter.h", diff --git a/lib/browser/api/module-list.ts b/lib/browser/api/module-list.ts index 98831ed64a8a..81547d159a60 100644 --- a/lib/browser/api/module-list.ts +++ b/lib/browser/api/module-list.ts @@ -29,6 +29,7 @@ export const browserModuleList: ElectronInternal.ModuleEntry[] = [ { name: 'protocol', loader: () => require('./protocol') }, { name: 'safeStorage', loader: () => require('./safe-storage') }, { name: 'screen', loader: () => require('./screen') }, + { name: 'ServiceWorkerMain', loader: () => require('./service-worker-main') }, { name: 'session', loader: () => require('./session') }, { name: 'ShareMenu', loader: () => require('./share-menu') }, { name: 'systemPreferences', loader: () => require('./system-preferences') }, diff --git a/lib/browser/api/service-worker-main.ts b/lib/browser/api/service-worker-main.ts new file mode 100644 index 000000000000..60d2f1e7d7f1 --- /dev/null +++ b/lib/browser/api/service-worker-main.ts @@ -0,0 +1,17 @@ +const { ServiceWorkerMain } = process._linkedBinding('electron_browser_service_worker_main'); + +ServiceWorkerMain.prototype.startTask = function () { + // TODO(samuelmaddock): maybe make timeout configurable in the future + const hasTimeout = false; + const { id, ok } = this._startExternalRequest(hasTimeout); + + if (!ok) { + throw new Error('Unable to start service worker task.'); + } + + return { + end: () => this._finishExternalRequest(id) + }; +}; + +module.exports = ServiceWorkerMain; diff --git a/lib/browser/init.ts b/lib/browser/init.ts index 50327911aacf..3f50212555db 100644 --- a/lib/browser/init.ts +++ b/lib/browser/init.ts @@ -146,6 +146,9 @@ require('@electron/internal/browser/devtools'); // Load protocol module to ensure it is populated on app ready require('@electron/internal/browser/api/protocol'); +// Load service-worker-main module to ensure it is populated on app ready +require('@electron/internal/browser/api/service-worker-main'); + // Load web-contents module to ensure it is populated on app ready require('@electron/internal/browser/api/web-contents'); diff --git a/shell/browser/api/electron_api_service_worker_context.cc b/shell/browser/api/electron_api_service_worker_context.cc index 951cf159702c..81ba67edbeb9 100644 --- a/shell/browser/api/electron_api_service_worker_context.cc +++ b/shell/browser/api/electron_api_service_worker_context.cc @@ -13,11 +13,18 @@ #include "gin/data_object_builder.h" #include "gin/handle.h" #include "gin/object_template_builder.h" +#include "shell/browser/api/electron_api_service_worker_main.h" #include "shell/browser/electron_browser_context.h" #include "shell/browser/javascript_environment.h" #include "shell/common/gin_converters/gurl_converter.h" +#include "shell/common/gin_converters/service_worker_converter.h" #include "shell/common/gin_converters/value_converter.h" #include "shell/common/gin_helper/dictionary.h" +#include "shell/common/gin_helper/promise.h" +#include "shell/common/node_util.h" + +using ServiceWorkerStatus = + content::ServiceWorkerRunningInfo::ServiceWorkerVersionStatus; namespace electron::api { @@ -72,8 +79,8 @@ gin::WrapperInfo ServiceWorkerContext::kWrapperInfo = {gin::kEmbedderNativeGin}; ServiceWorkerContext::ServiceWorkerContext( v8::Isolate* isolate, ElectronBrowserContext* browser_context) { - service_worker_context_ = - browser_context->GetDefaultStoragePartition()->GetServiceWorkerContext(); + storage_partition_ = browser_context->GetDefaultStoragePartition(); + service_worker_context_ = storage_partition_->GetServiceWorkerContext(); service_worker_context_->AddObserver(this); } @@ -81,6 +88,23 @@ ServiceWorkerContext::~ServiceWorkerContext() { service_worker_context_->RemoveObserver(this); } +void ServiceWorkerContext::OnRunningStatusChanged( + int64_t version_id, + blink::EmbeddedWorkerStatus running_status) { + ServiceWorkerMain* worker = + ServiceWorkerMain::FromVersionID(version_id, storage_partition_); + if (worker) + worker->OnRunningStatusChanged(running_status); + + v8::Isolate* isolate = JavascriptEnvironment::GetIsolate(); + v8::HandleScope scope(isolate); + EmitWithoutEvent("running-status-changed", + gin::DataObjectBuilder(isolate) + .Set("versionId", version_id) + .Set("runningStatus", running_status) + .Build()); +} + void ServiceWorkerContext::OnReportConsoleMessage( int64_t version_id, const GURL& scope, @@ -105,6 +129,32 @@ void ServiceWorkerContext::OnRegistrationCompleted(const GURL& scope) { gin::DataObjectBuilder(isolate).Set("scope", scope).Build()); } +void ServiceWorkerContext::OnVersionRedundant(int64_t version_id, + const GURL& scope) { + ServiceWorkerMain* worker = + ServiceWorkerMain::FromVersionID(version_id, storage_partition_); + if (worker) + worker->OnVersionRedundant(); +} + +void ServiceWorkerContext::OnVersionStartingRunning(int64_t version_id) { + OnRunningStatusChanged(version_id, blink::EmbeddedWorkerStatus::kStarting); +} + +void ServiceWorkerContext::OnVersionStartedRunning( + int64_t version_id, + const content::ServiceWorkerRunningInfo& running_info) { + OnRunningStatusChanged(version_id, blink::EmbeddedWorkerStatus::kRunning); +} + +void ServiceWorkerContext::OnVersionStoppingRunning(int64_t version_id) { + OnRunningStatusChanged(version_id, blink::EmbeddedWorkerStatus::kStopping); +} + +void ServiceWorkerContext::OnVersionStoppedRunning(int64_t version_id) { + OnRunningStatusChanged(version_id, blink::EmbeddedWorkerStatus::kStopped); +} + void ServiceWorkerContext::OnDestruct(content::ServiceWorkerContext* context) { if (context == service_worker_context_) { delete this; @@ -124,7 +174,7 @@ v8::Local ServiceWorkerContext::GetAllRunningWorkerInfo( return builder.Build(); } -v8::Local ServiceWorkerContext::GetWorkerInfoFromID( +v8::Local ServiceWorkerContext::GetInfoFromVersionID( gin_helper::ErrorThrower thrower, int64_t version_id) { const base::flat_map& info_map = @@ -138,6 +188,87 @@ v8::Local ServiceWorkerContext::GetWorkerInfoFromID( std::move(iter->second)); } +v8::Local ServiceWorkerContext::GetFromVersionID( + gin_helper::ErrorThrower thrower, + int64_t version_id) { + util::EmitWarning(thrower.isolate(), + "The session.serviceWorkers.getFromVersionID API is " + "deprecated, use " + "session.serviceWorkers.getInfoFromVersionID instead.", + "ServiceWorkersDeprecateGetFromVersionID"); + + return GetInfoFromVersionID(thrower, version_id); +} + +v8::Local ServiceWorkerContext::GetWorkerFromVersionID( + v8::Isolate* isolate, + int64_t version_id) { + return ServiceWorkerMain::From(isolate, service_worker_context_, + storage_partition_, version_id) + .ToV8(); +} + +gin::Handle +ServiceWorkerContext::GetWorkerFromVersionIDIfExists(v8::Isolate* isolate, + int64_t version_id) { + ServiceWorkerMain* worker = + ServiceWorkerMain::FromVersionID(version_id, storage_partition_); + if (!worker) + return gin::Handle(); + return gin::CreateHandle(isolate, worker); +} + +v8::Local ServiceWorkerContext::StartWorkerForScope( + v8::Isolate* isolate, + GURL scope) { + auto shared_promise = + std::make_shared>>(isolate); + v8::Local handle = shared_promise->GetHandle(); + + blink::StorageKey storage_key = + blink::StorageKey::CreateFirstParty(url::Origin::Create(scope)); + service_worker_context_->StartWorkerForScope( + scope, storage_key, + base::BindOnce(&ServiceWorkerContext::DidStartWorkerForScope, + weak_ptr_factory_.GetWeakPtr(), shared_promise), + base::BindOnce(&ServiceWorkerContext::DidFailToStartWorkerForScope, + weak_ptr_factory_.GetWeakPtr(), shared_promise)); + + return handle; +} + +void ServiceWorkerContext::DidStartWorkerForScope( + std::shared_ptr>> shared_promise, + int64_t version_id, + int process_id, + int thread_id) { + v8::Isolate* isolate = shared_promise->isolate(); + v8::HandleScope handle_scope(isolate); + v8::Local service_worker_main = + GetWorkerFromVersionID(isolate, version_id); + shared_promise->Resolve(service_worker_main); + shared_promise.reset(); +} + +void ServiceWorkerContext::DidFailToStartWorkerForScope( + std::shared_ptr>> shared_promise, + content::StatusCodeResponse status) { + shared_promise->RejectWithErrorMessage("Failed to start service worker."); + shared_promise.reset(); +} + +v8::Local ServiceWorkerContext::StopAllWorkers( + v8::Isolate* isolate) { + auto promise = gin_helper::Promise(isolate); + v8::Local handle = promise.GetHandle(); + + service_worker_context_->StopAllServiceWorkers(base::BindOnce( + [](gin_helper::Promise promise) { promise.Resolve(); }, + std::move(promise))); + + return handle; +} + // static gin::Handle ServiceWorkerContext::Create( v8::Isolate* isolate, @@ -153,8 +284,16 @@ gin::ObjectTemplateBuilder ServiceWorkerContext::GetObjectTemplateBuilder( ServiceWorkerContext>::GetObjectTemplateBuilder(isolate) .SetMethod("getAllRunning", &ServiceWorkerContext::GetAllRunningWorkerInfo) - .SetMethod("getFromVersionID", - &ServiceWorkerContext::GetWorkerInfoFromID); + .SetMethod("getFromVersionID", &ServiceWorkerContext::GetFromVersionID) + .SetMethod("getInfoFromVersionID", + &ServiceWorkerContext::GetInfoFromVersionID) + .SetMethod("getWorkerFromVersionID", + &ServiceWorkerContext::GetWorkerFromVersionID) + .SetMethod("_getWorkerFromVersionIDIfExists", + &ServiceWorkerContext::GetWorkerFromVersionIDIfExists) + .SetMethod("startWorkerForScope", + &ServiceWorkerContext::StartWorkerForScope) + .SetMethod("_stopAllWorkers", &ServiceWorkerContext::StopAllWorkers); } const char* ServiceWorkerContext::GetTypeName() { diff --git a/shell/browser/api/electron_api_service_worker_context.h b/shell/browser/api/electron_api_service_worker_context.h index 286c3bcecc55..9e3669e22d3b 100644 --- a/shell/browser/api/electron_api_service_worker_context.h +++ b/shell/browser/api/electron_api_service_worker_context.h @@ -10,18 +10,30 @@ #include "content/public/browser/service_worker_context_observer.h" #include "gin/wrappable.h" #include "shell/browser/event_emitter_mixin.h" +#include "third_party/blink/public/common/service_worker/embedded_worker_status.h" + +namespace content { +class StoragePartition; +} namespace gin { template class Handle; } // namespace gin +namespace gin_helper { +template +class Promise; +} // namespace gin_helper + namespace electron { class ElectronBrowserContext; namespace api { +class ServiceWorkerMain; + class ServiceWorkerContext final : public gin::Wrappable, public gin_helper::EventEmitterMixin, @@ -32,14 +44,39 @@ class ServiceWorkerContext final ElectronBrowserContext* browser_context); v8::Local GetAllRunningWorkerInfo(v8::Isolate* isolate); - v8::Local GetWorkerInfoFromID(gin_helper::ErrorThrower thrower, - int64_t version_id); + v8::Local GetInfoFromVersionID(gin_helper::ErrorThrower thrower, + int64_t version_id); + v8::Local GetFromVersionID(gin_helper::ErrorThrower thrower, + int64_t version_id); + v8::Local GetWorkerFromVersionID(v8::Isolate* isolate, + int64_t version_id); + gin::Handle GetWorkerFromVersionIDIfExists( + v8::Isolate* isolate, + int64_t version_id); + v8::Local StartWorkerForScope(v8::Isolate* isolate, GURL scope); + void DidStartWorkerForScope( + std::shared_ptr>> shared_promise, + int64_t version_id, + int process_id, + int thread_id); + void DidFailToStartWorkerForScope( + std::shared_ptr>> shared_promise, + content::StatusCodeResponse status); + void StopWorkersForScope(GURL scope); + v8::Local StopAllWorkers(v8::Isolate* isolate); // content::ServiceWorkerContextObserver void OnReportConsoleMessage(int64_t version_id, const GURL& scope, const content::ConsoleMessage& message) override; void OnRegistrationCompleted(const GURL& scope) override; + void OnVersionStartingRunning(int64_t version_id) override; + void OnVersionStartedRunning( + int64_t version_id, + const content::ServiceWorkerRunningInfo& running_info) override; + void OnVersionStoppingRunning(int64_t version_id) override; + void OnVersionStoppedRunning(int64_t version_id) override; + void OnVersionRedundant(int64_t version_id, const GURL& scope) override; void OnDestruct(content::ServiceWorkerContext* context) override; // gin::Wrappable @@ -58,8 +95,15 @@ class ServiceWorkerContext final ~ServiceWorkerContext() override; private: + void OnRunningStatusChanged(int64_t version_id, + blink::EmbeddedWorkerStatus running_status); + raw_ptr service_worker_context_; + // Service worker registration and versions are unique to a storage partition. + // Keep a reference to the storage partition to be used for lookups. + raw_ptr storage_partition_; + base::WeakPtrFactory weak_ptr_factory_{this}; }; diff --git a/shell/browser/api/electron_api_service_worker_main.cc b/shell/browser/api/electron_api_service_worker_main.cc new file mode 100644 index 000000000000..8d24d872e054 --- /dev/null +++ b/shell/browser/api/electron_api_service_worker_main.cc @@ -0,0 +1,359 @@ +// Copyright (c) 2025 Salesforce, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/browser/api/electron_api_service_worker_main.h" + +#include +#include +#include +#include + +#include "base/logging.h" +#include "base/no_destructor.h" +#include "content/browser/service_worker/service_worker_context_wrapper.h" // nogncheck +#include "content/browser/service_worker/service_worker_version.h" // nogncheck +#include "electron/shell/common/api/api.mojom.h" +#include "gin/handle.h" +#include "gin/object_template_builder.h" +#include "services/service_manager/public/cpp/interface_provider.h" +#include "shell/browser/api/message_port.h" +#include "shell/browser/browser.h" +#include "shell/browser/javascript_environment.h" +#include "shell/common/gin_converters/blink_converter.h" +#include "shell/common/gin_converters/gurl_converter.h" +#include "shell/common/gin_converters/value_converter.h" +#include "shell/common/gin_helper/dictionary.h" +#include "shell/common/gin_helper/error_thrower.h" +#include "shell/common/gin_helper/object_template_builder.h" +#include "shell/common/gin_helper/promise.h" +#include "shell/common/node_includes.h" +#include "shell/common/v8_util.h" + +namespace { + +// Use private API to get the live version of the service worker. This will +// exist while in starting, stopping, or stopped running status. +content::ServiceWorkerVersion* GetLiveVersion( + content::ServiceWorkerContext* service_worker_context, + int64_t version_id) { + auto* wrapper = static_cast( + service_worker_context); + return wrapper->GetLiveVersion(version_id); +} + +// Get a public ServiceWorkerVersionBaseInfo object directly from the service +// worker. +std::optional GetLiveVersionInfo( + content::ServiceWorkerContext* service_worker_context, + int64_t version_id) { + auto* version = GetLiveVersion(service_worker_context, version_id); + if (version) { + return version->GetInfo(); + } + return std::nullopt; +} + +} // namespace + +namespace electron::api { + +// ServiceWorkerKey -> ServiceWorkerMain* +typedef std::unordered_map + VersionIdMap; + +VersionIdMap& GetVersionIdMap() { + static base::NoDestructor instance; + return *instance; +} + +ServiceWorkerMain* FromServiceWorkerKey(const ServiceWorkerKey& key) { + VersionIdMap& version_map = GetVersionIdMap(); + auto iter = version_map.find(key); + auto* service_worker = iter == version_map.end() ? nullptr : iter->second; + return service_worker; +} + +// static +ServiceWorkerMain* ServiceWorkerMain::FromVersionID( + int64_t version_id, + const content::StoragePartition* storage_partition) { + ServiceWorkerKey key(version_id, storage_partition); + return FromServiceWorkerKey(key); +} + +gin::WrapperInfo ServiceWorkerMain::kWrapperInfo = {gin::kEmbedderNativeGin}; + +ServiceWorkerMain::ServiceWorkerMain(content::ServiceWorkerContext* sw_context, + int64_t version_id, + const ServiceWorkerKey& key) + : version_id_(version_id), key_(key), service_worker_context_(sw_context) { + GetVersionIdMap().emplace(key_, this); + InvalidateVersionInfo(); +} + +ServiceWorkerMain::~ServiceWorkerMain() { + Destroy(); +} + +void ServiceWorkerMain::Destroy() { + version_destroyed_ = true; + InvalidateVersionInfo(); + MaybeDisconnectRemote(); + GetVersionIdMap().erase(key_); + Unpin(); +} + +void ServiceWorkerMain::MaybeDisconnectRemote() { + if (remote_.is_bound() && + (version_destroyed_ || + (!service_worker_context_->IsLiveStartingServiceWorker(version_id_) && + !service_worker_context_->IsLiveRunningServiceWorker(version_id_)))) { + remote_.reset(); + } +} + +mojom::ElectronRenderer* ServiceWorkerMain::GetRendererApi() { + if (!remote_.is_bound()) { + if (!service_worker_context_->IsLiveRunningServiceWorker(version_id_)) { + return nullptr; + } + + service_worker_context_->GetRemoteAssociatedInterfaces(version_id_) + .GetInterface(&remote_); + } + return remote_.get(); +} + +void ServiceWorkerMain::Send(v8::Isolate* isolate, + bool internal, + const std::string& channel, + v8::Local args) { + blink::CloneableMessage message; + if (!gin::ConvertFromV8(isolate, args, &message)) { + isolate->ThrowException(v8::Exception::Error( + gin::StringToV8(isolate, "Failed to serialize arguments"))); + return; + } + + auto* renderer_api_remote = GetRendererApi(); + if (!renderer_api_remote) { + return; + } + + renderer_api_remote->Message(internal, channel, std::move(message)); +} + +void ServiceWorkerMain::InvalidateVersionInfo() { + if (version_info_ != nullptr) { + version_info_.reset(); + } + + if (version_destroyed_) + return; + + auto version_info = GetLiveVersionInfo(service_worker_context_, version_id_); + if (version_info) { + version_info_ = + std::make_unique(*version_info); + } else { + // When ServiceWorkerContextCore::RemoveLiveVersion is called, it posts a + // task to notify that the service worker has stopped. At this point, the + // live version will no longer exist. + Destroy(); + } +} + +void ServiceWorkerMain::OnRunningStatusChanged( + blink::EmbeddedWorkerStatus running_status) { + // Disconnect remote when content::ServiceWorkerHost has terminated. + MaybeDisconnectRemote(); + + InvalidateVersionInfo(); + + // Redundant worker has been marked for deletion. Now that it's stopped, let's + // destroy our wrapper. + if (redundant_ && running_status == blink::EmbeddedWorkerStatus::kStopped) { + Destroy(); + } +} + +void ServiceWorkerMain::OnVersionRedundant() { + // Redundant service workers have been either unregistered or replaced. A new + // ServiceWorkerMain will need to be created. + // Set internal state to mark it for deletion once it has fully stopped. + redundant_ = true; +} + +bool ServiceWorkerMain::IsDestroyed() const { + return version_destroyed_; +} + +const blink::StorageKey ServiceWorkerMain::GetStorageKey() { + GURL scope = version_info_ ? version_info()->scope : GURL::EmptyGURL(); + return blink::StorageKey::CreateFirstParty(url::Origin::Create(scope)); +} + +gin_helper::Dictionary ServiceWorkerMain::StartExternalRequest( + v8::Isolate* isolate, + bool has_timeout) { + auto details = gin_helper::Dictionary::CreateEmpty(isolate); + + if (version_destroyed_) { + isolate->ThrowException(v8::Exception::TypeError( + gin::StringToV8(isolate, "ServiceWorkerMain is destroyed"))); + return details; + } + + auto request_uuid = base::Uuid::GenerateRandomV4(); + auto timeout_type = + has_timeout + ? content::ServiceWorkerExternalRequestTimeoutType::kDefault + : content::ServiceWorkerExternalRequestTimeoutType::kDoesNotTimeout; + + content::ServiceWorkerExternalRequestResult start_result = + service_worker_context_->StartingExternalRequest( + version_id_, timeout_type, request_uuid); + + details.Set("id", request_uuid.AsLowercaseString()); + details.Set("ok", + start_result == content::ServiceWorkerExternalRequestResult::kOk); + + return details; +} + +void ServiceWorkerMain::FinishExternalRequest(v8::Isolate* isolate, + std::string uuid) { + base::Uuid request_uuid = base::Uuid::ParseLowercase(uuid); + if (!request_uuid.is_valid()) { + isolate->ThrowException(v8::Exception::TypeError( + gin::StringToV8(isolate, "Invalid external request UUID"))); + return; + } + + DCHECK(service_worker_context_); + if (!service_worker_context_) + return; + + content::ServiceWorkerExternalRequestResult result = + service_worker_context_->FinishedExternalRequest(version_id_, + request_uuid); + + std::string error; + switch (result) { + case content::ServiceWorkerExternalRequestResult::kOk: + break; + case content::ServiceWorkerExternalRequestResult::kBadRequestId: + error = "Unknown external request UUID"; + break; + case content::ServiceWorkerExternalRequestResult::kWorkerNotRunning: + error = "Service worker is no longer running"; + break; + case content::ServiceWorkerExternalRequestResult::kWorkerNotFound: + error = "Service worker was not found"; + break; + case content::ServiceWorkerExternalRequestResult::kNullContext: + default: + error = "Service worker context is unavailable and may be shutting down"; + break; + } + + if (!error.empty()) { + isolate->ThrowException( + v8::Exception::TypeError(gin::StringToV8(isolate, error))); + } +} + +size_t ServiceWorkerMain::CountExternalRequestsForTest() { + if (version_destroyed_) + return 0; + auto& storage_key = GetStorageKey(); + return service_worker_context_->CountExternalRequestsForTest(storage_key); +} + +int64_t ServiceWorkerMain::VersionID() const { + return version_id_; +} + +GURL ServiceWorkerMain::ScopeURL() const { + if (version_destroyed_) + return GURL::EmptyGURL(); + return version_info()->scope; +} + +// static +gin::Handle ServiceWorkerMain::New(v8::Isolate* isolate) { + return gin::Handle(); +} + +// static +gin::Handle ServiceWorkerMain::From( + v8::Isolate* isolate, + content::ServiceWorkerContext* sw_context, + const content::StoragePartition* storage_partition, + int64_t version_id) { + ServiceWorkerKey service_worker_key(version_id, storage_partition); + + auto* service_worker = FromServiceWorkerKey(service_worker_key); + if (service_worker) + return gin::CreateHandle(isolate, service_worker); + + // Ensure ServiceWorkerVersion exists and is not redundant (pending deletion) + auto* live_version = GetLiveVersion(sw_context, version_id); + if (!live_version || live_version->is_redundant()) { + return gin::Handle(); + } + + auto handle = gin::CreateHandle( + isolate, + new ServiceWorkerMain(sw_context, version_id, service_worker_key)); + + // Prevent garbage collection of worker until it has been deleted internally. + handle->Pin(isolate); + + return handle; +} + +// static +void ServiceWorkerMain::FillObjectTemplate( + v8::Isolate* isolate, + v8::Local templ) { + gin_helper::ObjectTemplateBuilder(isolate, templ) + .SetMethod("_send", &ServiceWorkerMain::Send) + .SetMethod("isDestroyed", &ServiceWorkerMain::IsDestroyed) + .SetMethod("_startExternalRequest", + &ServiceWorkerMain::StartExternalRequest) + .SetMethod("_finishExternalRequest", + &ServiceWorkerMain::FinishExternalRequest) + .SetMethod("_countExternalRequests", + &ServiceWorkerMain::CountExternalRequestsForTest) + .SetProperty("versionId", &ServiceWorkerMain::VersionID) + .SetProperty("scope", &ServiceWorkerMain::ScopeURL) + .Build(); +} + +const char* ServiceWorkerMain::GetTypeName() { + return GetClassName(); +} + +} // namespace electron::api + +namespace { + +using electron::api::ServiceWorkerMain; + +void Initialize(v8::Local exports, + v8::Local unused, + v8::Local context, + void* priv) { + v8::Isolate* isolate = context->GetIsolate(); + gin_helper::Dictionary dict(isolate, exports); + dict.Set("ServiceWorkerMain", ServiceWorkerMain::GetConstructor(context)); +} + +} // namespace + +NODE_LINKED_BINDING_CONTEXT_AWARE(electron_browser_service_worker_main, + Initialize) diff --git a/shell/browser/api/electron_api_service_worker_main.h b/shell/browser/api/electron_api_service_worker_main.h new file mode 100644 index 000000000000..995f7ad5428b --- /dev/null +++ b/shell/browser/api/electron_api_service_worker_main.h @@ -0,0 +1,178 @@ +// Copyright (c) 2025 Salesforce, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_BROWSER_API_ELECTRON_API_SERVICE_WORKER_MAIN_H_ +#define ELECTRON_SHELL_BROWSER_API_ELECTRON_API_SERVICE_WORKER_MAIN_H_ + +#include +#include +#include + +#include "base/memory/raw_ptr.h" +#include "base/memory/weak_ptr.h" +#include "base/process/process.h" +#include "content/public/browser/global_routing_id.h" +#include "content/public/browser/service_worker_context.h" +#include "content/public/browser/service_worker_version_base_info.h" +#include "gin/wrappable.h" +#include "mojo/public/cpp/bindings/associated_receiver.h" +#include "mojo/public/cpp/bindings/associated_remote.h" +#include "mojo/public/cpp/bindings/pending_receiver.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "shell/browser/event_emitter_mixin.h" +#include "shell/common/api/api.mojom.h" +#include "shell/common/gin_helper/constructible.h" +#include "shell/common/gin_helper/pinnable.h" +#include "third_party/blink/public/common/service_worker/embedded_worker_status.h" + +class GURL; + +namespace content { +class StoragePartition; +} + +namespace gin { +class Arguments; +} // namespace gin + +namespace gin_helper { +class Dictionary; +template +class Handle; +template +class Promise; +} // namespace gin_helper + +namespace electron::api { + +// Key to uniquely identify a ServiceWorkerMain by its Version ID within the +// associated StoragePartition. +struct ServiceWorkerKey { + int64_t version_id; + raw_ptr storage_partition; + + ServiceWorkerKey(int64_t id, const content::StoragePartition* partition) + : version_id(id), storage_partition(partition) {} + + bool operator<(const ServiceWorkerKey& other) const { + return std::tie(version_id, storage_partition) < + std::tie(other.version_id, other.storage_partition); + } + + bool operator==(const ServiceWorkerKey& other) const { + return version_id == other.version_id && + storage_partition == other.storage_partition; + } + + struct Hasher { + std::size_t operator()(const ServiceWorkerKey& key) const { + return std::hash()( + key.storage_partition) ^ + std::hash()(key.version_id); + } + }; +}; + +// Creates a wrapper to align with the lifecycle of the non-public +// content::ServiceWorkerVersion. Object instances are pinned for the lifetime +// of the underlying SW such that registered IPC handlers continue to dispatch. +// +// Instances are uniquely identified by pairing their version ID and the +// StoragePartition in which they're registered. In Electron, this is always +// the default StoragePartition for the associated BrowserContext. +class ServiceWorkerMain final + : public gin::Wrappable, + public gin_helper::EventEmitterMixin, + public gin_helper::Pinnable, + public gin_helper::Constructible { + public: + // Create a new ServiceWorkerMain and return the V8 wrapper of it. + static gin::Handle New(v8::Isolate* isolate); + + static gin::Handle From( + v8::Isolate* isolate, + content::ServiceWorkerContext* sw_context, + const content::StoragePartition* storage_partition, + int64_t version_id); + static ServiceWorkerMain* FromVersionID( + int64_t version_id, + const content::StoragePartition* storage_partition); + + // gin_helper::Constructible + static void FillObjectTemplate(v8::Isolate*, v8::Local); + static const char* GetClassName() { return "ServiceWorkerMain"; } + + // gin::Wrappable + static gin::WrapperInfo kWrapperInfo; + const char* GetTypeName() override; + + // disable copy + ServiceWorkerMain(const ServiceWorkerMain&) = delete; + ServiceWorkerMain& operator=(const ServiceWorkerMain&) = delete; + + void OnRunningStatusChanged(blink::EmbeddedWorkerStatus running_status); + void OnVersionRedundant(); + + protected: + explicit ServiceWorkerMain(content::ServiceWorkerContext* sw_context, + int64_t version_id, + const ServiceWorkerKey& key); + ~ServiceWorkerMain() override; + + private: + void Destroy(); + void MaybeDisconnectRemote(); + const blink::StorageKey GetStorageKey(); + + // Increments external requests for the service worker to keep it alive. + gin_helper::Dictionary StartExternalRequest(v8::Isolate* isolate, + bool has_timeout); + void FinishExternalRequest(v8::Isolate* isolate, std::string uuid); + size_t CountExternalRequestsForTest(); + + // Get or create a Mojo connection to the renderer process. + mojom::ElectronRenderer* GetRendererApi(); + + // Send a message to the renderer process. + void Send(v8::Isolate* isolate, + bool internal, + const std::string& channel, + v8::Local args); + + void InvalidateVersionInfo(); + const content::ServiceWorkerVersionBaseInfo* version_info() const { + return version_info_.get(); + } + + bool IsDestroyed() const; + + int64_t VersionID() const; + GURL ScopeURL() const; + + // Version ID unique only to the StoragePartition. + int64_t version_id_; + + // Unique identifier pairing the Version ID and StoragePartition. + ServiceWorkerKey key_; + + // Whether the Service Worker version has been destroyed. + bool version_destroyed_ = false; + + // Whether the Service Worker version's state is redundant. + bool redundant_ = false; + + // Store copy of version info so it's accessible when not running. + std::unique_ptr version_info_; + + raw_ptr service_worker_context_; + mojo::AssociatedRemote remote_; + + std::unique_ptr> start_worker_promise_; + + base::WeakPtrFactory weak_factory_{this}; +}; + +} // namespace electron::api + +#endif // ELECTRON_SHELL_BROWSER_API_ELECTRON_API_SERVICE_WORKER_MAIN_H_ diff --git a/shell/common/gin_converters/service_worker_converter.cc b/shell/common/gin_converters/service_worker_converter.cc new file mode 100644 index 000000000000..56eaee64d53e --- /dev/null +++ b/shell/common/gin_converters/service_worker_converter.cc @@ -0,0 +1,25 @@ +// Copyright (c) 2025 Salesforce, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#include "shell/common/gin_converters/service_worker_converter.h" + +#include "base/containers/fixed_flat_map.h" + +namespace gin { + +// static +v8::Local Converter::ToV8( + v8::Isolate* isolate, + const blink::EmbeddedWorkerStatus& val) { + static constexpr auto Lookup = + base::MakeFixedFlatMap({ + {blink::EmbeddedWorkerStatus::kStarting, "starting"}, + {blink::EmbeddedWorkerStatus::kRunning, "running"}, + {blink::EmbeddedWorkerStatus::kStopping, "stopping"}, + {blink::EmbeddedWorkerStatus::kStopped, "stopped"}, + }); + return StringToV8(isolate, Lookup.at(val)); +} + +} // namespace gin diff --git a/shell/common/gin_converters/service_worker_converter.h b/shell/common/gin_converters/service_worker_converter.h new file mode 100644 index 000000000000..33f4ec23ef01 --- /dev/null +++ b/shell/common/gin_converters/service_worker_converter.h @@ -0,0 +1,21 @@ +// Copyright (c) 2025 Salesforce, Inc. +// Use of this source code is governed by the MIT license that can be +// found in the LICENSE file. + +#ifndef ELECTRON_SHELL_COMMON_GIN_CONVERTERS_SERVICE_WORKER_CONVERTER_H_ +#define ELECTRON_SHELL_COMMON_GIN_CONVERTERS_SERVICE_WORKER_CONVERTER_H_ + +#include "gin/converter.h" +#include "third_party/blink/public/common/service_worker/embedded_worker_status.h" + +namespace gin { + +template <> +struct Converter { + static v8::Local ToV8(v8::Isolate* isolate, + const blink::EmbeddedWorkerStatus& val); +}; + +} // namespace gin + +#endif // ELECTRON_SHELL_COMMON_GIN_CONVERTERS_SERVICE_WORKER_CONVERTER_H_ diff --git a/shell/common/node_bindings.cc b/shell/common/node_bindings.cc index 484947cee4be..bde4c3ca5d1d 100644 --- a/shell/common/node_bindings.cc +++ b/shell/common/node_bindings.cc @@ -49,39 +49,40 @@ #include "shell/common/crash_keys.h" #endif -#define ELECTRON_BROWSER_BINDINGS(V) \ - V(electron_browser_app) \ - V(electron_browser_auto_updater) \ - V(electron_browser_content_tracing) \ - V(electron_browser_crash_reporter) \ - V(electron_browser_desktop_capturer) \ - V(electron_browser_dialog) \ - V(electron_browser_event_emitter) \ - V(electron_browser_global_shortcut) \ - V(electron_browser_image_view) \ - V(electron_browser_in_app_purchase) \ - V(electron_browser_menu) \ - V(electron_browser_message_port) \ - V(electron_browser_native_theme) \ - V(electron_browser_notification) \ - V(electron_browser_power_monitor) \ - V(electron_browser_power_save_blocker) \ - V(electron_browser_protocol) \ - V(electron_browser_printing) \ - V(electron_browser_push_notifications) \ - V(electron_browser_safe_storage) \ - V(electron_browser_session) \ - V(electron_browser_screen) \ - V(electron_browser_system_preferences) \ - V(electron_browser_base_window) \ - V(electron_browser_tray) \ - V(electron_browser_utility_process) \ - V(electron_browser_view) \ - V(electron_browser_web_contents) \ - V(electron_browser_web_contents_view) \ - V(electron_browser_web_frame_main) \ - V(electron_browser_web_view_manager) \ - V(electron_browser_window) \ +#define ELECTRON_BROWSER_BINDINGS(V) \ + V(electron_browser_app) \ + V(electron_browser_auto_updater) \ + V(electron_browser_content_tracing) \ + V(electron_browser_crash_reporter) \ + V(electron_browser_desktop_capturer) \ + V(electron_browser_dialog) \ + V(electron_browser_event_emitter) \ + V(electron_browser_global_shortcut) \ + V(electron_browser_image_view) \ + V(electron_browser_in_app_purchase) \ + V(electron_browser_menu) \ + V(electron_browser_message_port) \ + V(electron_browser_native_theme) \ + V(electron_browser_notification) \ + V(electron_browser_power_monitor) \ + V(electron_browser_power_save_blocker) \ + V(electron_browser_protocol) \ + V(electron_browser_printing) \ + V(electron_browser_push_notifications) \ + V(electron_browser_safe_storage) \ + V(electron_browser_service_worker_main) \ + V(electron_browser_session) \ + V(electron_browser_screen) \ + V(electron_browser_system_preferences) \ + V(electron_browser_base_window) \ + V(electron_browser_tray) \ + V(electron_browser_utility_process) \ + V(electron_browser_view) \ + V(electron_browser_web_contents) \ + V(electron_browser_web_contents_view) \ + V(electron_browser_web_frame_main) \ + V(electron_browser_web_view_manager) \ + V(electron_browser_window) \ V(electron_common_net) #define ELECTRON_COMMON_BINDINGS(V) \ diff --git a/spec/api-service-worker-main-spec.ts b/spec/api-service-worker-main-spec.ts new file mode 100644 index 000000000000..0bc404c314d8 --- /dev/null +++ b/spec/api-service-worker-main-spec.ts @@ -0,0 +1,291 @@ +import { session, webContents as webContentsModule, WebContents } from 'electron/main'; + +import { expect } from 'chai'; + +import { once, on } from 'node:events'; +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as path from 'node:path'; + +import { listen, waitUntil } from './lib/spec-helpers'; + +// Toggle to add extra debug output +const DEBUG = !process.env.CI; + +describe('ServiceWorkerMain module', () => { + const fixtures = path.resolve(__dirname, 'fixtures'); + const webContentsInternal: typeof ElectronInternal.WebContents = webContentsModule as any; + + let ses: Electron.Session; + let serviceWorkers: Electron.ServiceWorkers; + let server: http.Server; + let baseUrl: string; + let wc: WebContents; + + beforeEach(async () => { + ses = session.fromPartition(`service-worker-main-spec-${crypto.randomUUID()}`); + serviceWorkers = ses.serviceWorkers; + + if (DEBUG) { + serviceWorkers.on('console-message', (_e, details) => { + console.log(details.message); + }); + serviceWorkers.on('running-status-changed', ({ versionId, runningStatus }) => { + console.log(`version ${versionId} is ${runningStatus}`); + }); + } + + const uuid = crypto.randomUUID(); + server = http.createServer((req, res) => { + const url = new URL(req.url!, `http://${req.headers.host}`); + // /{uuid}/{file} + const file = url.pathname!.split('/')[2]!; + + if (file.endsWith('.js')) { + res.setHeader('Content-Type', 'application/javascript'); + } + res.end(fs.readFileSync(path.resolve(fixtures, 'api', 'service-workers', file))); + }); + const { port } = await listen(server); + baseUrl = `http://localhost:${port}/${uuid}`; + + wc = webContentsInternal.create({ session: ses }); + + if (DEBUG) { + wc.on('console-message', ({ message }) => { + console.log(message); + }); + } + }); + + afterEach(async () => { + if (!wc.isDestroyed()) wc.destroy(); + server.close(); + }); + + async function loadWorkerScript (scriptUrl?: string) { + const scriptParams = scriptUrl ? `?scriptUrl=${scriptUrl}` : ''; + return wc.loadURL(`${baseUrl}/index.html${scriptParams}`); + } + + async function unregisterAllServiceWorkers () { + await wc.executeJavaScript(`(${async function () { + const registrations = await navigator.serviceWorker.getRegistrations(); + for (const registration of registrations) { + registration.unregister(); + } + }}())`); + } + + async function waitForServiceWorker (expectedRunningStatus: Electron.ServiceWorkersRunningStatusChangedEventParams['runningStatus'] = 'starting') { + const serviceWorkerPromise = new Promise((resolve) => { + function onRunningStatusChanged ({ versionId, runningStatus }: Electron.ServiceWorkersRunningStatusChangedEventParams) { + if (runningStatus === expectedRunningStatus) { + const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId)!; + serviceWorkers.off('running-status-changed', onRunningStatusChanged); + resolve(serviceWorker); + } + } + serviceWorkers.on('running-status-changed', onRunningStatusChanged); + }); + const serviceWorker = await serviceWorkerPromise; + expect(serviceWorker).to.not.be.undefined(); + return serviceWorker!; + } + + describe('serviceWorkers.getWorkerFromVersionID', () => { + it('returns undefined for non-live service worker', () => { + expect(serviceWorkers.getWorkerFromVersionID(-1)).to.be.undefined(); + expect(serviceWorkers._getWorkerFromVersionIDIfExists(-1)).to.be.undefined(); + }); + + it('returns instance for live service worker', async () => { + const runningStatusChanged = once(serviceWorkers, 'running-status-changed'); + loadWorkerScript(); + const [{ versionId }] = await runningStatusChanged; + const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId); + expect(serviceWorker).to.not.be.undefined(); + const ifExistsServiceWorker = serviceWorkers._getWorkerFromVersionIDIfExists(versionId); + expect(ifExistsServiceWorker).to.not.be.undefined(); + expect(serviceWorker).to.equal(ifExistsServiceWorker); + }); + + it('does not crash on script error', async () => { + wc.loadURL(`${baseUrl}/index.html?scriptUrl=sw-script-error.js`); + let serviceWorker; + const actualStatuses = []; + for await (const [{ versionId, runningStatus }] of on(serviceWorkers, 'running-status-changed')) { + if (!serviceWorker) { + serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId); + } + actualStatuses.push(runningStatus); + if (runningStatus === 'stopping') { + break; + } + } + expect(actualStatuses).to.deep.equal(['starting', 'stopping']); + expect(serviceWorker).to.not.be.undefined(); + }); + + it('does not find unregistered service worker', async () => { + loadWorkerScript(); + const runningServiceWorker = await waitForServiceWorker('running'); + const { versionId } = runningServiceWorker; + unregisterAllServiceWorkers(); + await waitUntil(() => runningServiceWorker.isDestroyed()); + const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId); + expect(serviceWorker).to.be.undefined(); + }); + }); + + describe('isDestroyed()', () => { + it('is not destroyed after being created', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker(); + expect(serviceWorker.isDestroyed()).to.be.false(); + }); + + it('is destroyed after being unregistered', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker(); + expect(serviceWorker.isDestroyed()).to.be.false(); + await unregisterAllServiceWorkers(); + await waitUntil(() => serviceWorker.isDestroyed()); + }); + }); + + describe('"running-status-changed" event', () => { + it('handles when content::ServiceWorkerVersion has been destroyed', async () => { + loadWorkerScript('sw-unregister-self.js'); + const serviceWorker = await waitForServiceWorker('running'); + await waitUntil(() => serviceWorker.isDestroyed()); + }); + }); + + describe('startWorkerForScope()', () => { + it('resolves with running workers', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker('running'); + const startWorkerPromise = serviceWorkers.startWorkerForScope(serviceWorker.scope); + await expect(startWorkerPromise).to.eventually.be.fulfilled(); + const otherSW = await startWorkerPromise; + expect(otherSW).to.equal(serviceWorker); + }); + + it('rejects with starting workers', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker('starting'); + const startWorkerPromise = serviceWorkers.startWorkerForScope(serviceWorker.scope); + await expect(startWorkerPromise).to.eventually.be.rejected(); + }); + + it('starts previously stopped worker', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker('running'); + const { scope } = serviceWorker; + const stoppedPromise = waitForServiceWorker('stopped'); + await serviceWorkers._stopAllWorkers(); + await stoppedPromise; + const startWorkerPromise = serviceWorkers.startWorkerForScope(scope); + await expect(startWorkerPromise).to.eventually.be.fulfilled(); + }); + + it('resolves when called twice', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker('running'); + const { scope } = serviceWorker; + const [swA, swB] = await Promise.all([ + serviceWorkers.startWorkerForScope(scope), + serviceWorkers.startWorkerForScope(scope) + ]); + expect(swA).to.equal(swB); + expect(swA).to.equal(serviceWorker); + }); + }); + + describe('startTask()', () => { + it('has no tasks in-flight initially', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker(); + expect(serviceWorker._countExternalRequests()).to.equal(0); + }); + + it('can start and end a task', async () => { + loadWorkerScript(); + + // Internally, ServiceWorkerVersion buckets tasks into requests made + // during and after startup. + // ServiceWorkerContext::CountExternalRequestsForTest only considers + // requests made while SW is in running status so we need to wait for that + // to read an accurate count. + const serviceWorker = await waitForServiceWorker('running'); + + const task = serviceWorker.startTask(); + expect(task).to.be.an('object'); + expect(task).to.have.property('end').that.is.a('function'); + expect(serviceWorker._countExternalRequests()).to.equal(1); + + task.end(); + + // Count will decrement after Promise.finally callback + await new Promise(queueMicrotask); + expect(serviceWorker._countExternalRequests()).to.equal(0); + }); + + it('can have more than one active task', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker('running'); + + const taskA = serviceWorker.startTask(); + const taskB = serviceWorker.startTask(); + expect(serviceWorker._countExternalRequests()).to.equal(2); + taskB.end(); + taskA.end(); + + // Count will decrement after Promise.finally callback + await new Promise(queueMicrotask); + expect(serviceWorker._countExternalRequests()).to.equal(0); + }); + + it('throws when starting task after destroyed', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker(); + await unregisterAllServiceWorkers(); + await waitUntil(() => serviceWorker.isDestroyed()); + expect(() => serviceWorker.startTask()).to.throw(); + }); + + it('throws when ending task after destroyed', async function () { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker(); + const task = serviceWorker.startTask(); + await unregisterAllServiceWorkers(); + await waitUntil(() => serviceWorker.isDestroyed()); + expect(() => task.end()).to.throw(); + }); + }); + + describe("'versionId' property", () => { + it('matches the expected value', async () => { + const runningStatusChanged = once(serviceWorkers, 'running-status-changed'); + wc.loadURL(`${baseUrl}/index.html`); + const [{ versionId }] = await runningStatusChanged; + const serviceWorker = serviceWorkers.getWorkerFromVersionID(versionId); + expect(serviceWorker).to.not.be.undefined(); + if (!serviceWorker) return; + expect(serviceWorker).to.have.property('versionId').that.is.a('number'); + expect(serviceWorker.versionId).to.equal(versionId); + }); + }); + + describe("'scope' property", () => { + it('matches the expected value', async () => { + loadWorkerScript(); + const serviceWorker = await waitForServiceWorker(); + expect(serviceWorker).to.not.be.undefined(); + if (!serviceWorker) return; + expect(serviceWorker).to.have.property('scope').that.is.a('string'); + expect(serviceWorker.scope).to.equal(`${baseUrl}/`); + }); + }); +}); diff --git a/spec/fixtures/api/service-workers/index.html b/spec/fixtures/api/service-workers/index.html index 81c54d201b08..58c729d5c735 100644 --- a/spec/fixtures/api/service-workers/index.html +++ b/spec/fixtures/api/service-workers/index.html @@ -2,7 +2,8 @@ diff --git a/spec/fixtures/api/service-workers/sw-script-error.js b/spec/fixtures/api/service-workers/sw-script-error.js new file mode 100644 index 000000000000..46b09e75dc58 --- /dev/null +++ b/spec/fixtures/api/service-workers/sw-script-error.js @@ -0,0 +1 @@ +throw new Error('service worker throwing on startup'); diff --git a/spec/fixtures/api/service-workers/sw-unregister-self.js b/spec/fixtures/api/service-workers/sw-unregister-self.js new file mode 100644 index 000000000000..b3287c154b8a --- /dev/null +++ b/spec/fixtures/api/service-workers/sw-unregister-self.js @@ -0,0 +1,3 @@ +self.addEventListener('install', function () { + registration.unregister(); +}); diff --git a/typings/internal-ambient.d.ts b/typings/internal-ambient.d.ts index 4b5470e775df..25196e752f8c 100644 --- a/typings/internal-ambient.d.ts +++ b/typings/internal-ambient.d.ts @@ -111,6 +111,10 @@ declare namespace NodeJS { setListeningForShutdown(listening: boolean): void; } + interface ServiceWorkerMainBinding { + ServiceWorkerMain: typeof Electron.ServiceWorkerMain; + } + interface SessionBinding { fromPartition: typeof Electron.Session.fromPartition, fromPath: typeof Electron.Session.fromPath, @@ -228,6 +232,7 @@ declare namespace NodeJS { _linkedBinding(name: 'electron_browser_safe_storage'): { safeStorage: Electron.SafeStorage }; _linkedBinding(name: 'electron_browser_session'): SessionBinding; _linkedBinding(name: 'electron_browser_screen'): { createScreen(): Electron.Screen }; + _linkedBinding(name: 'electron_browser_service_worker_main'): ServiceWorkerMainBinding; _linkedBinding(name: 'electron_browser_system_preferences'): { systemPreferences: Electron.SystemPreferences }; _linkedBinding(name: 'electron_browser_tray'): { Tray: Electron.Tray }; _linkedBinding(name: 'electron_browser_view'): { View: Electron.View }; diff --git a/typings/internal-electron.d.ts b/typings/internal-electron.d.ts index 1ab4507af12d..c7cac707b56f 100644 --- a/typings/internal-electron.d.ts +++ b/typings/internal-electron.d.ts @@ -66,6 +66,18 @@ declare namespace Electron { } } + interface ServiceWorkers { + _getWorkerFromVersionIDIfExists(versionId: number): Electron.ServiceWorkerMain | undefined; + _stopAllWorkers(): Promise; + } + + interface ServiceWorkerMain { + _startExternalRequest(hasTimeout: boolean): { id: string, ok: boolean }; + _finishExternalRequest(uuid: string): void; + _countExternalRequests(): number; + } + + interface TouchBar { _removeFromWindow: (win: BaseWindow) => void; }