SharedStorage
Cross-site storage that a page can write but never read - the Privacy Sandbox's answer to frequency capping and A/B assignment without a cross-site identifier.
@inject Bit.Butil.SharedStorage sharedStorageMDN reference
SelectUrl) rendered inside a fenced frame. Run gets
no result at all - not even a boolean about what it computed.
True when the runtime exposes window.sharedStorage. During prerender/SSR the check returns false rather than throwing.
@inject Bit.Butil.SharedStorage sharedStorage
var supported = await sharedStorage.IsSupported();Set writes a value, optionally leaving an existing one alone. Append adds to one. Delete and Clear remove. Keys and values are both capped at a few hundred characters, and a value over the limit fails rather than truncating.
await sharedStorage.Set("campaign-seen", "1", ignoreIfPresent: true);
await sharedStorage.Append("impressions", ",42");
await sharedStorage.Delete("campaign-seen");The worklet module is the only code that ever sees a stored value. It must be same-origin, and a page may add one only once. Inside it, a register('name', …) call declares the operations Run and SelectUrl can call.
Bit.Butil.SharedStorage sharedStorage
{
// A page may add one module only, ever - a second call is refused for the life of the document.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender is false) return;
var added = await sharedStorage.AddModule("/shared-storage-worklet.js");
// false when there is no API here, the module 404'd, or one was already added
}
}// The only code that ever sees a stored value. It runs in an isolated scope with no network access
// and no way to reach the page, which is what makes reading cross-site data safe to allow at all.
class FrequencyCap {
async run(data) {
// sharedStorage here is the worklet's own object, with the get() the page does not have.
const seen = await sharedStorage.get('seen');
await sharedStorage.set('seen', String((Number(seen) || 0) + 1));
// It decides, but it cannot report back: nothing returned from here reaches the page.
}
}
class PickCreative {
async run(urls, data) {
const seen = Number(await sharedStorage.get('seen')) || 0;
// The return value is an index into the urls array, and the page is never told which one.
return seen > 3 ? 1 : 0;
}
}
// The names Run and SelectUrl address. A name that was never registered makes the call return
// false rather than throwing anywhere the page can see.
register('frequency-cap', FrequencyCap);
register('pick-creative', PickCreative);Run starts a registered operation and tells you only whether it started. SelectUrl runs an operation that picks one of the given URLs by index; which one won is not reported to the page - the result is only usable by handing it to a fenced frame, whose whole point is that the embedding page cannot inspect it.
await sharedStorage.Run("frequency-cap", new { campaignId = 42 });
await sharedStorage.SelectUrl("pick-creative",
["/creative-a", "/creative-b"],
new { slot = "hero" });API reference
ValueTask<bool> IsSupported()ValueTask<bool> Set(string key, string value, bool ignoreIfPresent = false)ValueTask<bool> Append(string key, string value)ValueTask<bool> Delete(string key)ValueTask<bool> Clear()ValueTask<bool> AddModule(string url)ValueTask<bool> Run(string operation, object? data = null, bool keepAlive = false)ValueTask<bool> SelectUrl(string operation, string[] urls, object? data = null, bool resolveToConfig = false)