Skip to content

Commit

Permalink
Implement Web storage - localStorage, sessionStorage, StorageEvent
Browse files Browse the repository at this point in the history
  • Loading branch information
Zirro authored and domenic committed Jun 18, 2018
1 parent 7b4db76 commit 3afbc0f
Show file tree
Hide file tree
Showing 10 changed files with 223 additions and 4 deletions.
47 changes: 47 additions & 0 deletions lib/jsdom/browser/Window.js
Expand Up @@ -23,6 +23,7 @@ const External = require("../living/generated/External");
const Navigator = require("../living/generated/Navigator");
const Performance = require("../living/generated/Performance");
const Screen = require("../living/generated/Screen");
const Storage = require("../living/generated/Storage");
const createAbortController = require("../living/generated/AbortController").createInterface;
const createAbortSignal = require("../living/generated/AbortSignal").createInterface;
const reportException = require("../living/helpers/runtime-script-errors");
Expand Down Expand Up @@ -142,6 +143,38 @@ function Window(options) {

this._pretendToBeVisual = options.pretendToBeVisual;

// Some properties (such as localStorage and sessionStorage) share data
// between windows in the same origin. This object is intended
// to contain such data.
if (options.commonForOrigin && options.commonForOrigin[this._document.origin]) {
this._commonForOrigin = options.commonForOrigin;
} else {
this._commonForOrigin = {
[this._document.origin]: {
localStorageArea: new Map(),
sessionStorageArea: new Map(),
windowsInSameOrigin: [this]
}
};
}

this._currentOriginData = this._commonForOrigin[this._document.origin];

///// WEB STORAGE

this._localStorage = Storage.create([], {
associatedWindow: this,
storageArea: this._currentOriginData.localStorageArea,
type: "localStorage",
url: this._document.documentURI
});
this._sessionStorage = Storage.create([], {
associatedWindow: this,
storageArea: this._currentOriginData.sessionStorageArea,
type: "sessionStorage",
url: this._document.documentURI
});

///// GETTERS

const locationbar = BarProp.create();
Expand Down Expand Up @@ -215,6 +248,20 @@ function Window(options) {
},
get screen() {
return screen;
},
get localStorage() {
if (this._document.origin === "null") {
throw new DOMException("localStorage is not available for opaque origins", "SecurityError");
}

return this._localStorage;
},
get sessionStorage() {
if (this._document.origin === "null") {
throw new DOMException("sessionStorage is not available for opaque origins", "SecurityError");
}

return this._sessionStorage;
}
});

Expand Down
26 changes: 26 additions & 0 deletions lib/jsdom/living/events/StorageEvent-impl.js
@@ -0,0 +1,26 @@
"use strict";

const EventImpl = require("./Event-impl").implementation;

const StorageEventInit = require("../generated/StorageEventInit");

// https://html.spec.whatwg.org/multipage/webstorage.html#the-storageevent-interface
class StorageEventImpl extends EventImpl {
initStorageEvent(type, bubbles, cancelable, key, oldValue, newValue, url, storageArea) {
if (this._dispatchFlag) {
return;
}

this.initEvent(type, bubbles, cancelable);
this.key = key;
this.oldValue = oldValue;
this.newValue = newValue;
this.url = url;
this.storageArea = storageArea;
}
}
StorageEventImpl.defaultInit = StorageEventInit.convert(undefined);

module.exports = {
implementation: StorageEventImpl
};
17 changes: 17 additions & 0 deletions lib/jsdom/living/events/StorageEvent.webidl
@@ -0,0 +1,17 @@
[Exposed=Window,
Constructor(DOMString type, optional StorageEventInit eventInitDict)]
interface StorageEvent : Event {
readonly attribute DOMString? key;
readonly attribute DOMString? oldValue;
readonly attribute DOMString? newValue;
readonly attribute USVString url;
readonly attribute Storage? storageArea;
};

dictionary StorageEventInit : EventInit {
DOMString? key = null;
DOMString? oldValue = null;
DOMString? newValue = null;
USVString url = "";
Storage? storageArea = null;
};
3 changes: 3 additions & 0 deletions lib/jsdom/living/index.js
Expand Up @@ -38,6 +38,7 @@ exports.MouseEvent = require("./generated/MouseEvent").interface;
exports.KeyboardEvent = require("./generated/KeyboardEvent").interface;
exports.TouchEvent = require("./generated/TouchEvent").interface;
exports.ProgressEvent = require("./generated/ProgressEvent").interface;
exports.StorageEvent = require("./generated/StorageEvent").interface;
exports.CompositionEvent = require("./generated/CompositionEvent").interface;
exports.WheelEvent = require("./generated/WheelEvent").interface;
exports.EventTarget = require("./generated/EventTarget").interface;
Expand All @@ -62,6 +63,8 @@ exports.XMLHttpRequestUpload = require("./generated/XMLHttpRequestUpload").inter
exports.NodeIterator = require("./generated/NodeIterator").interface;
exports.TreeWalker = require("./generated/TreeWalker").interface;

exports.Storage = require("./generated/Storage").interface;

require("./register-elements")(exports);

// These need to be cleaned up...
Expand Down
7 changes: 6 additions & 1 deletion lib/jsdom/living/nodes/HTMLFrameElement-impl.js
Expand Up @@ -45,7 +45,8 @@ function loadFrame(frame) {
agentOptions: parentDoc._agentOptions,
strictSSL: parentDoc._strictSSL,
proxy: parentDoc._proxy,
runScripts: parentDoc._defaultView._runScripts
runScripts: parentDoc._defaultView._runScripts,
commonForOrigin: parentDoc._defaultView._commonForOrigin
});
const contentDoc = frame._contentDocument = idlUtils.implForWrapper(wnd._document);
applyDocumentFeatures(contentDoc, parentDoc._implementation._features);
Expand All @@ -57,6 +58,10 @@ function loadFrame(frame) {
contentWindow._frameElement = frame;
contentWindow._virtualConsole = parent._virtualConsole;

if (parentDoc.origin === contentDoc.origin) {
contentWindow._currentOriginData.windowsInSameOrigin.push(contentWindow);
}

// Handle about:blank with a simulated load of an empty document.
if (serializedURL === "about:blank") {
// Cannot be done inside the enqueued callback; the documentElement etc. need to be immediately available.
Expand Down
98 changes: 98 additions & 0 deletions lib/jsdom/living/webstorage/Storage-impl.js
@@ -0,0 +1,98 @@
"use strict";

const DOMException = require("domexception");
const StorageEvent = require("../generated/StorageEvent");
const idlUtils = require("../generated/utils");

// https://html.spec.whatwg.org/multipage/webstorage.html#the-storage-interface
class StorageImpl {
constructor(args, { associatedWindow, storageArea, url, type }) {
this._associatedWindow = associatedWindow;
this._items = storageArea;
this._url = url;
this._type = type;

// The spec suggests a default storage quota of 5 MB
this._quota = 5000;

This comment has been minimized.

Copy link
@fstoerkle

fstoerkle Jun 18, 2018

As I understand, 5000 would suggest that the default storage quota is 5 KB, right?
Shouldn't that be 5_000_000 bytes?

This comment has been minimized.

Copy link
@fstoerkle

This comment has been minimized.

Copy link
@Zirro

Zirro Jun 18, 2018

Author Member

Thanks for mentioning this. It's been a while since I worked on the code, but I believe I used to multiply this value by 1000 at a later point in the code. That part must've been lost during refactoring at some point, and the tests only seem to check that there is a limit at all.

Will confirm and potentially fix later today.

}

_dispatchStorageEvent(key, oldValue, newValue) {
return this._associatedWindow._currentOriginData.windowsInSameOrigin
.filter(target => target !== this._associatedWindow)
.forEach(target => target.dispatchEvent(StorageEvent.create([
"storage",
{
bubbles: false,
cancelable: false,
key,
oldValue,
newValue,
url: this._url,
storageArea: target["_" + this._type]
}
])));
}

get length() {
return this._items.size;
}

key(n) {
if (n >= this._items.size) {
return null;
}
return [...this._items.keys()][n];
}

getItem(key) {
if (this._items.has(key)) {
return this._items.get(key);
}
return null;
}

setItem(key, value) {
const oldValue = this._items.get(key) || null;

if (oldValue === value) {
return;
}

// Concatenate all keys and values to measure their size against the quota
let itemsConcat = key + value;
this._items.forEach((v, k) => {
itemsConcat += v + k;
});
if (Buffer.byteLength(itemsConcat) > this._quota) {
throw new DOMException(`The ${this._quota} byte storage quota has been exceeded.`, "QuotaExceededError");
}

setTimeout(this._dispatchStorageEvent.bind(this), 0, key, oldValue, value);

this._items.set(key, value);
}

removeItem(key) {
if (this._items.has(key)) {
setTimeout(this._dispatchStorageEvent.bind(this), 0, key, this._items.get(key), null);

this._items.delete(key);
}
}

clear() {
if (this._items.size > 0) {
setTimeout(this._dispatchStorageEvent.bind(this), 0, null, null, null);

this._items.clear();
}
}

get [idlUtils.supportedPropertyNames]() {
return this._items.keys();
}
}

module.exports = {
implementation: StorageImpl
};
9 changes: 9 additions & 0 deletions lib/jsdom/living/webstorage/Storage.webidl
@@ -0,0 +1,9 @@
[Exposed=Window]
interface Storage {
readonly attribute unsigned long length;
DOMString? key(unsigned long index);
[WebIDL2JSValueAsUnsupported=null] getter DOMString? getItem(DOMString key);
setter void setItem(DOMString key, DOMString value);
deleter void removeItem(DOMString key);
void clear();
};
2 changes: 1 addition & 1 deletion scripts/webidl/convert.js
Expand Up @@ -32,7 +32,7 @@ addDir("../../lib/jsdom/living/aborting");
addDir("../../lib/jsdom/living/websockets");
addDir("../../lib/jsdom/living/hr-time");
addDir("../../lib/jsdom/living/constraint-validation");

addDir("../../lib/jsdom/living/webstorage");

const outputDir = path.resolve(__dirname, "../../lib/jsdom/living/generated/");

Expand Down
3 changes: 3 additions & 0 deletions test/web-platform-tests/run-wpts.js
Expand Up @@ -14,6 +14,7 @@ const validReasons = new Set([
"mutates-globals",
"needs-await",
"needs-node8",
"needs-node10",
"fails-node10",
"timeout-node6" // For tests that timeout in Node.js v6, but pass in later versions
]);
Expand All @@ -26,6 +27,7 @@ try {
}

const hasNode8 = Number(process.versions.node.split(".")[0]) >= 8;
const hasNode10 = Number(process.versions.node.split(".")[0]) >= 10;
const isNode10 = Number(process.versions.node.split(".")[0]) === 10;

const manifestFilename = path.resolve(__dirname, "wpt-manifest.json");
Expand Down Expand Up @@ -65,6 +67,7 @@ describe("web-platform-tests", () => {
const expectFail = (reason === "fail") ||
(reason === "needs-await" && !supportsAwait) ||
(reason === "needs-node8" && !hasNode8) ||
(reason === "needs-node10" && !hasNode10) ||
(reason === "fails-node10" && isNode10);

if (matchingPattern && shouldSkip) {
Expand Down
15 changes: 13 additions & 2 deletions test/web-platform-tests/to-run.yaml
Expand Up @@ -338,8 +338,8 @@ browsing-context.html: [fail, Unknown]
nested-browsing-contexts/frameElement.html: [timeout, Unknown]
nested-browsing-contexts/window-parent-null.html: [fail, Unknown]
nested-browsing-contexts/window-top-null.html: [fail, Unknown]
noreferrer-null-opener.html: [timeout, Needs localStorage]
noreferrer-window-name.html: [timeout, Needs localStorage]
noreferrer-null-opener.html: [timeout, Unknown]
noreferrer-window-name.html: [timeout, Depends on URL.createObjectURL]
targeting-cross-origin-nested-browsing-contexts.html: [timeout, Unknown]

---
Expand Down Expand Up @@ -838,6 +838,17 @@ unload-a-document/*: [timeout, Requires window.open]

---

DIR: webstorage

idlharness.html: [fail, Depends on fetch]
storage_enumerate.html: [needs-node8, Uses Object.values()]
storage_local_window_open.html: [timeout, Depends on window.open()]
storage_session_window_noopener.html: [fail, Depends on BroadcastChannel]
storage_session_window_open.html: [timeout, Depends on window.open()]
storage_string_conversion.html: [needs-node10, function.toString() does not use correct formatting in earlier versions, https://github.com/nodejs/node/issues/20459]

---

DIR: xhr

abort-after-stop.htm: [fail, https://github.com/w3c/web-platform-tests/issues/6942]
Expand Down

0 comments on commit 3afbc0f

Please sign in to comment.