Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ jobs:
- run: pnpm check
- name: Install Chromium for PDF touch regression tests
run: pnpm --filter @mdbase-reader/app exec playwright install --with-deps chromium
- name: Verify extension shared-session writes across separate bundles
run: pnpm --filter @mdbase-reader/extension test:browser
- name: Verify PDF gestures and mobile highlight persistence
run: pnpm --filter @mdbase-reader/app test:pdf-touch
- name: Verify imported SVG rendering in the HTML sandbox
Expand Down
4 changes: 3 additions & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
"package": "MDBASE_ENV=${MDBASE_ENV:-production} pnpm build && MDBASE_ENV=${MDBASE_ENV:-production} node scripts/package-extension.mjs",
"manifest:mdbase": "node scripts/write-mdbase-manifest.mjs",
"test": "pnpm manifest:mdbase && vitest run --passWithNoTests",
"typecheck": "tsc -p tsconfig.json"
"typecheck": "tsc -p tsconfig.json",
"test:browser": "node scripts/shared-session-browser.mjs"
},
"dependencies": {
"@fontsource-variable/source-serif-4": "^5.3.1",
Expand All @@ -30,6 +31,7 @@
"react-dom": "^19.3.1"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"@types/chrome": "^0.3.6",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
Expand Down
4 changes: 2 additions & 2 deletions apps/extension/scripts/extension-manifest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,8 @@ export function extensionManifest(environment) {
},
// One panel per window; it follows the window's active tab.
side_panel: { default_path: "capture.html" },
// Classic, with no SDK: the SDK runtime lives in the extension's pages.
background: { service_worker: "background.js" },
// The SDK requires a module URL, including in a non-hosting context.
background: { service_worker: "background.js", type: "module" },
options_ui: { page: "options.html", open_in_tab: true },
commands: {
_execute_action: {
Expand Down
28 changes: 28 additions & 0 deletions apps/extension/scripts/fixtures/shared-session.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* global __SESSION_HOST__ */
import { createMdbase } from "@mdbase-dev/sdk";

const origin = globalThis.location.origin;
const manifest = {
manifest_version: 1,
id: "dev.mdbase.reader.extension.session-test",
name: "Reader shared session test",
homepage: `${origin}/`,
redirect_uris: [`${origin}/`],
requirements: {
access: "full_collection",
contracts: [],
capabilities: { contract_version: 2, required: [], optional: [] },
},
};
const app = createMdbase(
{ id: manifest.id, name: manifest.name, origin, environment: "production", manifest },
{
session: { build: "reader-extension-session-test", host: __SESSION_HOST__ },
unsynced: true,
followVisibility: false,
},
);
globalThis.fixtureMdbase = app;
void app.start().catch((error) => {
globalThis.fixtureError = error.message;
});
184 changes: 184 additions & 0 deletions apps/extension/scripts/shared-session-browser.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
import { createServer } from "node:http";
import { extname, join, resolve } from "node:path";

import { chromium, expect } from "@playwright/test";
import { build } from "vite";

// Two separately bundled browser contexts, using the pinned SDK's original Worker,
// WASM and SQLite against one local unsynced replica. Registration is a fixture;
// no account, Connect service, collection grant or log service is contacted.
const root = resolve(import.meta.dirname, "..");
const scratch = resolve(root, "../../.tmp");
await mkdir(scratch, { recursive: true });
const directory = await mkdtemp(join(scratch, "extension-shared-session-"));
const dist = join(directory, "dist");
let browser;
let server;
const errors = [];
const unexpected = [];
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object")
return `{${Object.keys(value)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`)
.join(",")}}`;
return JSON.stringify(value);
}
try {
for (const [name, host] of [
["host", true],
["follower", false],
]) {
await build({
root,
configFile: false,
publicDir: false,
logLevel: "error",
define: { __SESSION_HOST__: JSON.stringify(host) },
worker: { format: "es" },
build: {
outDir: dist,
emptyOutDir: host,
target: "es2022",
rolldownOptions: {
input: resolve(root, "scripts/fixtures/shared-session.mjs"),
output: { entryFileNames: `${name}.js` },
},
},
});
}
server = createServer(async (request, response) => {
const path = new URL(request.url, "http://localhost").pathname;
if (["/host", "/follower"].includes(path)) {
response.setHeader("Content-Type", "text/html");
response.end(`<!doctype html><script type="module" src="${path}.js"></script>`);
return;
}
const file = resolve(dist, `.${path}`);
if (!file.startsWith(`${dist}/`)) {
response.writeHead(403).end();
return;
}
try {
response.setHeader(
"Content-Type",
extname(file) === ".wasm" ? "application/wasm" : "text/javascript",
);
response.end(await readFile(file));
} catch {
response.writeHead(404).end();
}
});
await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen));
const origin = `http://127.0.0.1:${server.address().port}`;
browser = await chromium.launch();
const context = await browser.newContext();
await context.route("**/*", async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.origin === origin) return route.continue();
if (url.href === "https://connect.mdbase.dev/v1/apps/register") {
const { manifest } = request.postDataJSON();
const digest = createHash("sha256").update(canonical(manifest)).digest("hex");
return route.fulfill({
json: {
application: {
id: "55555555-5555-4555-8555-555555555555",
name: manifest.name,
manifest_digest: digest,
canonical_identity: `bundle:${manifest.id}:sha256:${digest}`,
family_identity: `bundle:${manifest.id}`,
distribution: "web",
application_declaration: manifest,
},
},
});
}
unexpected.push(url.origin + url.pathname);
return route.abort();
});
const follower = await context.newPage();
const host = await context.newPage();
const workers = [];
for (const page of [host, follower]) {
page.on("pageerror", (error) => errors.push(error.message));
page.on("worker", (worker) => workers.push(worker.url()));
}
await follower.goto(`${origin}/follower`);
await expect
.poll(() => follower.evaluate(() => globalThis.fixtureMdbase?.getSnapshot().status))
.toBe("starting");
assert.equal(workers.length, 0, "a host:false context never constructs a Worker");
await host.goto(`${origin}/host`);
for (const page of [host, follower]) {
await expect
.poll(
() =>
page.evaluate(() => ({
phase: globalThis.fixtureMdbase?.getSnapshot().phase.kind,
error: globalThis.fixtureError,
problem: globalThis.fixtureMdbase?.getSnapshot().phase.problem,
})),
{ timeout: 15000 },
)
.toEqual({ phase: "unsynced", error: undefined, problem: undefined });
}
assert.equal(workers.length, 1, "one SDK Worker serves both bundles");
const ids = await Promise.all(
[host, follower].map((page) =>
page.evaluate(() => globalThis.fixtureMdbase.getSnapshot().phase.connection.id),
),
);
assert.equal(ids[0], ids[1], "both contexts open the same replica");
await host.evaluate(async () => {
const records = globalThis.fixtureMdbase.getSnapshot().phase.connection.records;
await records.create({
path: "shared.md",
frontmatter: { title: "From the host" },
body: "Shared body",
});
});
const read = await follower.evaluate(async () => {
const records = globalThis.fixtureMdbase.getSnapshot().phase.connection.records;
const record = await records.get({ path: "shared.md" }, { include: { body: true } });
await records.update(record, { patch: { title: "From the follower" } });
return { title: record.frontmatter.title, body: record.body };
});
assert.deepEqual(read, { title: "From the host", body: "Shared body" });
assert.equal(
await host.evaluate(
async () =>
(
await globalThis.fixtureMdbase
.getSnapshot()
.phase.connection.records.get({ path: "shared.md" })
).frontmatter.title,
),
"From the follower",
);
await follower.close();
assert.equal(
await host.evaluate(
async () =>
(
await globalThis.fixtureMdbase
.getSnapshot()
.phase.connection.records.get({ path: "shared.md" })
).frontmatter.title,
),
"From the follower",
);
assert.deepEqual(errors, []);
assert.deepEqual(unexpected, []);
console.log(
"PASS: two separate bundles, one original SDK Worker and replica; writes visible in both directions; follower close leaves host usable.",
);
} finally {
await browser?.close();
if (server) await new Promise((resolveClose) => server.close(resolveClose));
await rm(directory, { recursive: true, force: true });
}
15 changes: 5 additions & 10 deletions apps/extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,9 @@ import { backgroundLocaleReady } from "./background-i18n.js";
import { updatePageStatus } from "./background-page-status.js";
import { intentKey, type CaptureIntent, type InvokeMessage } from "./messages.js";
import { ensureHostMessage, ensureOffscreenDocument } from "./offscreen-document.js";
import { loadPageStatusConnect, preloadPageStatusConnect } from "./page-status-loader.js";
import { pageStatusConnect } from "./page-status-worker.js";
import { forgetTab } from "./tab-cleanup.js";

/*
* The service worker wakes for every page load and toolbar click, so this bundle stays
* small: the Connect SDK lives in page-status.js, loaded only when a page may be saved
* (see page-status-loader.ts). Connect's credentialless fetch is installed there.
*/
self.addEventListener("install", preloadPageStatusConnect);

/**
* Opens Reader's side panel in the tab's window; the panel follows the window's active
* tab. Everything here starts synchronously inside the user gesture: Chrome only allows
Expand Down Expand Up @@ -98,7 +91,9 @@ chrome.runtime.onMessage.addListener((message: unknown, sender, sendResponse) =>

chrome.tabs.onUpdated.addListener((tabId, change, tab) => {
if (change.status === "complete") {
void backgroundLocaleReady.then(() => updatePageStatus(tabId, tab.url, loadPageStatusConnect));
void backgroundLocaleReady.then(() =>
updatePageStatus(tabId, tab.url, () => pageStatusConnect),
);
}
});
const postUpdates = new Map<number, { dirty: boolean }>();
Expand Down Expand Up @@ -135,7 +130,7 @@ async function refreshPosts(tabId: number, state: { dirty: boolean }): Promise<v
do {
state.dirty = false;
const tab = await chrome.tabs.get(tabId);
await updatePageStatus(tabId, tab.url, loadPageStatusConnect);
await updatePageStatus(tabId, tab.url, () => pageStatusConnect);
} while (postUpdates.get(tabId)?.dirty);
}

Expand Down
10 changes: 3 additions & 7 deletions apps/extension/src/chrome-storage.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
/**
* The Connect SDK needs a synchronous Web Storage, but the service worker has no
* localStorage and must see the same grants as the side panel. This mirror loads
* `chrome.storage.local` once, answers synchronously, writes through, and follows
* changes made by other extension contexts.
*
* There is one mirror per storage area and context: every Connect session in it (a
* Retry creates a new one) shares it, so only one `storage.onChanged` listener exists.
* Reader's capture journal needs synchronous key/value access shared by extension pages.
* This mirror loads chrome.storage.local, writes through and follows other contexts.
* SDK session state and grants remain in the SDK's own storage.
*/
const prefix = "connect:";

Expand Down
69 changes: 32 additions & 37 deletions apps/extension/src/connect-session.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,55 @@
import { createMdbase, type Mdbase } from "@mdbase-reader/connect/native";

import { chromeStorageMirror } from "./chrome-storage.js";
import { requestSdkHost, sdkHostGone } from "./offscreen-document.js";
import { createSdkClient, type RemoteApplication, type SdkClient } from "./sdk-client.js";
import { broadcastLink } from "./sdk-rpc.js";
import { requestSdkHost } from "./offscreen-document.js";
import { extensionCollection } from "./sdk-collection.js";
import { extensionDeclaration, sessionBuild } from "./sdk-declaration.js";

import type { ReaderConnectedCollection } from "@mdbase-reader/connect";
import type { KeyValueStorage } from "@mdbase-reader/platform";

export { rememberCollection, rememberedCollection } from "./collection-memory.js";

/**
* One extension page's link to the SDK. The SDK itself runs once, in the offscreen host
* (offscreen.ts); every page and the service worker share it, so none waits on another.
*/
/** One page's SDK entry and Reader repositories over its selected connection. */
export interface ExtensionSession {
readonly application: RemoteApplication;
/** Reader's repositories over the host's current READY connection, or null. */
readonly application: Mdbase;
connectedCollection(): ReaderConnectedCollection | null;
/** False while the SDK host restarts; the application snapshot is then the last one. */
hostAvailable(): boolean;
/** Reader's capture journal, in extension storage so any page can recover it. */
readonly journalStorage: KeyValueStorage;
dispose(): void;
}

type CreateClient = () => Promise<SdkClient>;

/** Sign-in opens mdbase in a tab, from the page whose action started it. */
const createClient: CreateClient = async () => {
const link = broadcastLink();
const client = await createSdkClient(link, {
ensureHost: requestSdkHost,
hostGone: sdkHostGone,
openSignIn: (url) => void chrome.tabs.create({ url }).catch(() => undefined),
});
return {
...client,
dispose() {
client.dispose();
link.close();
export async function createExtensionSession(): Promise<ExtensionSession> {
const [storage] = await Promise.all([chromeStorageMirror(), requestSdkHost()]);
const application = createMdbase(extensionDeclaration(), {
session: { build: sessionBuild, host: false },
followVisibility: true,
signIn: {
open: async (url) => {
await chrome.tabs.create({ url });
},
},
};
};

export async function createExtensionSession(
create: CreateClient = createClient,
): Promise<ExtensionSession> {
const [storage, client] = await Promise.all([chromeStorageMirror(), create()]);
});
// The SDK re-enters starting when its host disappears (entry.ts leaderLost).
// Recreate Chrome's document; the SDK handles reconnecting its own session.
const stopHostRequest = application.subscribe(() => {
if (application.getSnapshot().status === "starting") {
void requestSdkHost().catch(() => undefined);
}
});
const collection = extensionCollection(application);
return {
application: client.application,
connectedCollection: () => client.connectedCollection(),
hostAvailable: () => client.hostAvailable(),
application,
connectedCollection: () => collection.connectedCollection(),
journalStorage: {
get: (key) => Promise.resolve(storage.getItem(key)),
set: (key, value) => Promise.resolve(storage.setItem(key, value)),
remove: (key) => Promise.resolve(storage.removeItem(key)),
},
dispose: () => client.dispose(),
dispose() {
stopHostRequest();
collection.dispose();
void application.close();
},
};
}
Loading