From 1e514ca64cc38ea4c82171f592c16e4e9796b8ea Mon Sep 17 00:00:00 2001 From: ryuhei shima Date: Sat, 7 Mar 2026 19:06:00 +0900 Subject: [PATCH] inspector: auto collect webstorage data --- lib/internal/inspector/webstorage.js | 107 ++++++++++++++++++ lib/internal/webstorage.js | 14 ++- src/inspector/dom_storage_agent.cc | 43 +++++++- src/inspector/dom_storage_agent.h | 4 + src/node_webstorage.cc | 36 ++++++ src/node_webstorage.h | 2 + test/parallel/test-inspector-dom-storage.js | 115 +++++++++++++++++++- 7 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 lib/internal/inspector/webstorage.js diff --git a/lib/internal/inspector/webstorage.js b/lib/internal/inspector/webstorage.js new file mode 100644 index 00000000000000..2bb6f5e6f8c743 --- /dev/null +++ b/lib/internal/inspector/webstorage.js @@ -0,0 +1,107 @@ +'use strict'; + +const { Storage } = internalBinding('webstorage'); +const { DOMStorage } = require('inspector'); +const path = require('path'); +const { getOptionValue } = require('internal/options'); + +class InspectorLocalStorage extends Storage { + setItem(key, value) { + const oldValue = this.getItem(key); + super.setItem(key, value); + if (oldValue == null) { + itemAdded(key, value, true); + } else { + itemUpdated(key, oldValue, value, true); + } + } + + removeItem(key) { + super.removeItem(key); + itemRemoved(key, true); + } + + clear() { + super.clear(); + itemsCleared(true); + } +} + +const InspectorSessionStorage = class extends Storage { + setItem(key, value) { + const oldValue = this.getItem(key); + super.setItem(key, value); + if (oldValue == null) { + itemAdded(key, value, false); + } else { + itemUpdated(key, oldValue, value, false); + } + } + + removeItem(key) { + super.removeItem(key); + itemRemoved(key, false); + } + + clear() { + super.clear(); + itemsCleared(false); + } +}; + +function itemAdded(key, value, isLocalStorage) { + DOMStorage.domStorageItemAdded({ + key, + newValue: value, + storageId: { + securityOrigin: '', + isLocalStorage, + storageKey: getStorageKey(), + }, + }); +} + +function itemUpdated(key, oldValue, newValue, isLocalStorage) { + DOMStorage.domStorageItemUpdated({ + key, + oldValue, + newValue, + storageId: { + securityOrigin: '', + isLocalStorage, + storageKey: getStorageKey(), + }, + }); +} + +function itemRemoved(key, isLocalStorage) { + DOMStorage.domStorageItemRemoved({ + key, + storageId: { + securityOrigin: '', + isLocalStorage, + storageKey: getStorageKey(), + }, + }); +} + +function itemsCleared(isLocalStorage) { + DOMStorage.domStorageItemsCleared({ + storageId: { + securityOrigin: '', + isLocalStorage, + storageKey: getStorageKey(), + }, + }); +} + +function getStorageKey() { + const localStorageFile = getOptionValue('--localstorage-file'); + const resolvedAbsolutePath = path.resolve(localStorageFile); + return 'file://' + resolvedAbsolutePath; +} + +module.exports = { + InspectorLocalStorage, + InspectorSessionStorage, +}; diff --git a/lib/internal/webstorage.js b/lib/internal/webstorage.js index 47c71676995f09..5d4978d9187982 100644 --- a/lib/internal/webstorage.js +++ b/lib/internal/webstorage.js @@ -5,6 +5,7 @@ const { const { getOptionValue } = require('internal/options'); const { kConstructorKey, Storage } = internalBinding('webstorage'); const { getValidatedPath } = require('internal/fs/utils'); +const { InspectorLocalStorage, InspectorSessionStorage } = require('internal/inspector/webstorage'); const kInMemoryPath = ':memory:'; module.exports = { Storage }; @@ -36,9 +37,12 @@ ObjectDefineProperties(module.exports, { return undefined; } - lazyLocalStorage = new Storage(kConstructorKey, getValidatedPath(localStorageLocation)); + if (getOptionValue('--experimental-storage-inspection')) { + lazyLocalStorage = new InspectorLocalStorage(kConstructorKey, getValidatedPath(localStorageLocation), true); + } else { + lazyLocalStorage = new Storage(kConstructorKey, getValidatedPath(localStorageLocation)); + } } - return lazyLocalStorage; }, }, @@ -48,7 +52,11 @@ ObjectDefineProperties(module.exports, { enumerable: true, get() { if (lazySessionStorage === undefined) { - lazySessionStorage = new Storage(kConstructorKey, kInMemoryPath); + if (getOptionValue('--experimental-storage-inspection')) { + lazySessionStorage = new InspectorSessionStorage(kConstructorKey, kInMemoryPath, false); + } else { + lazySessionStorage = new Storage(kConstructorKey, kInMemoryPath); + } } return lazySessionStorage; diff --git a/src/inspector/dom_storage_agent.cc b/src/inspector/dom_storage_agent.cc index d300266548ca87..bdf7360003853b 100644 --- a/src/inspector/dom_storage_agent.cc +++ b/src/inspector/dom_storage_agent.cc @@ -85,11 +85,26 @@ protocol::DispatchResponse DOMStorageAgent::getDOMStorageItems( "DOMStorage domain is not enabled"); } bool is_local_storage = storageId->getIsLocalStorage(); - const std::unordered_map& storage_map = - is_local_storage ? local_storage_map_ : session_storage_map_; + std::unique_ptr> storage_map = + is_local_storage + ? std::make_unique>( + local_storage_map_) + : std::make_unique>( + session_storage_map_); + if (storage_map->empty()) { + auto web_storage_obj = getWebStorage(is_local_storage); + if (web_storage_obj) { + std::unordered_map all_items = + web_storage_obj.value()->GetAll(); + storage_map = + std::make_unique>( + std::move(all_items)); + } + } + auto result = std::make_unique>>(); - for (const auto& pair : storage_map) { + for (const auto& pair : *storage_map) { auto item = std::make_unique>(); item->push_back(pair.first); item->push_back(pair.second); @@ -241,6 +256,28 @@ void DOMStorageAgent::registerStorage(Local context, } } +std::optional DOMStorageAgent::getWebStorage( + bool is_local_storage) { + std::string var_name = is_local_storage ? "localStorage" : "sessionStorage"; + v8::Isolate* isolate = env_->isolate(); + v8::HandleScope handle_scope(isolate); + v8::Local global = env_->context()->Global(); + v8::Local web_storage_val; + if (!global + ->Get(env_->context(), + v8::String::NewFromUtf8(env_->isolate(), var_name.c_str()) + .ToLocalChecked()) + .ToLocal(&web_storage_val) || + !web_storage_val->IsObject()) { + return std::nullopt; + } else { + node::webstorage::Storage* storage; + ASSIGN_OR_RETURN_UNWRAP( + &storage, web_storage_val.As(), std::nullopt); + return storage; + } +} + bool DOMStorageAgent::canEmit(const std::string& domain) { return domain == "DOMStorage"; } diff --git a/src/inspector/dom_storage_agent.h b/src/inspector/dom_storage_agent.h index 954f041d40ef58..f4c37793ef2a99 100644 --- a/src/inspector/dom_storage_agent.h +++ b/src/inspector/dom_storage_agent.h @@ -1,9 +1,11 @@ #ifndef SRC_INSPECTOR_DOM_STORAGE_AGENT_H_ #define SRC_INSPECTOR_DOM_STORAGE_AGENT_H_ +#include #include #include "env.h" #include "node/inspector/protocol/DOMStorage.h" +#include "node_webstorage.h" #include "notification_emitter.h" #include "v8.h" @@ -50,6 +52,8 @@ class DOMStorageAgent : public protocol::DOMStorage::Backend, DOMStorageAgent& operator=(const DOMStorageAgent&) = delete; private: + std::optional getWebStorage( + bool is_local_storage); std::unique_ptr frontend_; std::unordered_map local_storage_map_ = {}; std::unordered_map session_storage_map_ = {}; diff --git a/src/node_webstorage.cc b/src/node_webstorage.cc index 224f49e8596cf1..ff6d2775cddf66 100644 --- a/src/node_webstorage.cc +++ b/src/node_webstorage.cc @@ -1,4 +1,6 @@ #include "node_webstorage.h" +#include +#include #include "base_object-inl.h" #include "debug_utils-inl.h" #include "env-inl.h" @@ -278,6 +280,40 @@ MaybeLocal Storage::Enumerate() { return Array::New(env()->isolate(), values.data(), values.size()); } +std::unordered_map Storage::GetAll() { + if (!Open().IsJust()) { + return {}; + } + + static constexpr std::string_view sql = + "SELECT key, value FROM nodejs_webstorage"; + sqlite3_stmt* s = nullptr; + int r = sqlite3_prepare_v2(db_.get(), sql.data(), sql.size(), &s, nullptr); + auto stmt = stmt_unique_ptr(s); + std::unordered_map result; + while ((r = sqlite3_step(stmt.get())) == SQLITE_ROW) { + CHECK(sqlite3_column_type(stmt.get(), 0) == SQLITE_BLOB); + CHECK(sqlite3_column_type(stmt.get(), 1) == SQLITE_BLOB); + auto key_size = sqlite3_column_bytes(stmt.get(), 0) / sizeof(uint16_t); + auto value_size = sqlite3_column_bytes(stmt.get(), 1) / sizeof(uint16_t); + auto key_uint16( + reinterpret_cast(sqlite3_column_blob(stmt.get(), 0))); + auto value_uint16( + reinterpret_cast(sqlite3_column_blob(stmt.get(), 1))); + std::string key; + for (size_t i = 0; i < key_size; ++i) { + key.push_back(static_cast(key_uint16[i])); + } + std::string value; + for (size_t i = 0; i < value_size; ++i) { + value.push_back(static_cast(value_uint16[i])); + } + + result.emplace(std::move(key), std::move(value)); + } + return result; +} + MaybeLocal Storage::Length() { if (!Open().IsJust()) { return {}; diff --git a/src/node_webstorage.h b/src/node_webstorage.h index c2548d32e993fd..66ea7882b5fba9 100644 --- a/src/node_webstorage.h +++ b/src/node_webstorage.h @@ -3,6 +3,7 @@ #if defined(NODE_WANT_INTERNALS) && NODE_WANT_INTERNALS +#include #include "base_object.h" #include "node_mem.h" #include "sqlite3.h" @@ -40,6 +41,7 @@ class Storage : public BaseObject { v8::MaybeLocal LoadKey(const int index); v8::Maybe Remove(v8::Local key); v8::Maybe Store(v8::Local key, v8::Local value); + std::unordered_map GetAll(); SET_MEMORY_INFO_NAME(Storage) SET_SELF_SIZE(Storage) diff --git a/test/parallel/test-inspector-dom-storage.js b/test/parallel/test-inspector-dom-storage.js index f1cc3bbff3d076..2eb4291b73e663 100644 --- a/test/parallel/test-inspector-dom-storage.js +++ b/test/parallel/test-inspector-dom-storage.js @@ -8,8 +8,7 @@ const { DOMStorage, Session } = require('node:inspector/promises'); const { pathToFileURL } = require('node:url'); const path = require('node:path'); - -async function test() { +async function testRegisterStorage() { const session = new Session(); await session.connect(); @@ -26,6 +25,7 @@ async function test() { await checkStorage(true); await checkStorage(false); + session.disconnect(); async function checkStorage(isLocalStorage) { DOMStorage.registerStorage({ @@ -43,7 +43,9 @@ async function test() { securityOrigin: 'node-inspector://default-dom-storage', }, }); - const sortedEntries = result.entries.sort((a, b) => a[0].localeCompare(b[0])); + const sortedEntries = result.entries.sort((a, b) => + a[0].localeCompare(b[0]), + ); assert.deepStrictEqual(sortedEntries, [ ['1', '2'], ['key1', 'value1'], @@ -53,4 +55,111 @@ async function test() { } } +async function testGetData() { + await test(true); + await test(false); + + async function test(isLocalStorage) { + const webStorage = isLocalStorage ? localStorage : sessionStorage; + const session = new Session(); + webStorage.clear(); + await session.connect(); + + const storageKey = await session.post('Storage.getStorageKey'); + await session.post('DOMStorage.enable'); + + webStorage.setItem('key1', 'value'); + webStorage.setItem('key2', 1); + webStorage.setItem('key3', JSON.stringify({ a: 1 })); + + const result = await session.post('DOMStorage.getDOMStorageItems', { + storageId: { + isLocalStorage, + securityOrigin: '', + storageKey: storageKey.storageKey, + }, + }); + assert.strictEqual(result.entries.length, 3); + const entries = Object.fromEntries(result.entries); + assert.strictEqual(entries.key1, 'value'); + assert.strictEqual(entries.key2, '1'); + assert.strictEqual(entries.key3, JSON.stringify({ a: 1 })); + session.disconnect(); + } +} + +async function testEvents() { + await test(true); + await test(false); + async function test(isLocalStorage) { + const webStorage = isLocalStorage ? localStorage : sessionStorage; + webStorage.clear(); + const session = new Session(); + await session.connect(); + await session.post('DOMStorage.enable'); + const storageKey = await session.post('Storage.getStorageKey'); + session.on( + 'DOMStorage.domStorageItemAdded', + common.mustCall(({ params }) => { + assert.strictEqual(params.key, 'key'); + assert.strictEqual(params.newValue, 'value'); + assert.deepStrictEqual(params.storageId, { + securityOrigin: '', + isLocalStorage, + storageKey: storageKey.storageKey, + }); + }), + ); + webStorage.setItem('key', 'value'); + + session.on( + 'DOMStorage.domStorageItemUpdated', + common.mustCall(({ params }) => { + assert.strictEqual(params.key, 'key'); + assert.strictEqual(params.oldValue, 'value'); + assert.strictEqual(params.newValue, 'newValue'); + assert.deepStrictEqual(params.storageId, { + securityOrigin: '', + isLocalStorage, + storageKey: storageKey.storageKey, + }); + }), + ); + + webStorage.setItem('key', 'newValue'); + + session.on( + 'DOMStorage.domStorageItemRemoved', + common.mustCall(({ params }) => { + assert.strictEqual(params.key, 'key'); + assert.deepStrictEqual(params.storageId, { + securityOrigin: '', + isLocalStorage, + storageKey: storageKey.storageKey, + }); + }), + ); + + webStorage.removeItem('key'); + + session.on( + 'DOMStorage.domStorageItemsCleared', + common.mustCall(({ params }) => { + assert.deepStrictEqual(params.storageId, { + securityOrigin: '', + isLocalStorage, + storageKey: storageKey.storageKey, + }); + }), + ); + webStorage.clear(); + session.disconnect(); + } +} + +async function test() { + await testRegisterStorage(); + await testGetData(); + await testEvents(); +} test().then(common.mustCall());