sync update from LAN repo (source dhk-1 @ 3b1ec5b4)
This commit is contained in:
1 parent
da6521b6cb
commit
264e2200d6
995 files changed
+148105
-13399
No files matched your search
@@ -1,6 +1,13 @@
|
||||
importScripts("config.js", "shared.js");
|
||||
|
||||
const ALARM_NAME = "deer-browser-task-poll";
|
||||
const SERVICE_DISCOVERY_TTL_MS = 10 * 60 * 1000;
|
||||
const STATUS_CACHE_TTL_MS = 60 * 1000;
|
||||
const STATUS_RETRY_MS = 5 * 60 * 1000;
|
||||
const STATUS_MANUAL_MIN_INTERVAL_MS = 30 * 1000;
|
||||
const statusCache = new Map();
|
||||
const statusRequests = new Map();
|
||||
const discoveryRequests = new Map();
|
||||
const LEGACY_LOCAL_API_BASE_URL = "http://localhost:8001";
|
||||
const LEGACY_LOCAL_WEB_BASE_URL = "http://localhost:5174";
|
||||
let pollTimer = null;
|
||||
@@ -18,6 +25,9 @@ async function getSettings() {
|
||||
const discovered = await chrome.storage.local.get({
|
||||
discoveredApiBaseUrl: "",
|
||||
discoveredWebBaseUrl: "",
|
||||
lastServiceSyncAt: "",
|
||||
lastServiceSyncSource: "",
|
||||
lastServiceSyncContext: "",
|
||||
});
|
||||
if (settings.autoDiscoverServiceUrl) {
|
||||
settings.apiBaseUrl = discovered.discoveredApiBaseUrl || settings.apiBaseUrl;
|
||||
@@ -33,6 +43,9 @@ async function getSettings() {
|
||||
if (!settings.workerId) {
|
||||
updates.workerId = deerMakeWorkerId();
|
||||
}
|
||||
settings.lastServiceSyncAt = discovered.lastServiceSyncAt || "";
|
||||
settings.lastServiceSyncSource = discovered.lastServiceSyncSource || "";
|
||||
settings.lastServiceSyncContext = discovered.lastServiceSyncContext || "";
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await chrome.storage.sync.set(updates);
|
||||
Object.assign(settings, updates);
|
||||
@@ -100,28 +113,77 @@ function apiCandidatesForWebBase(webBase, currentApiBase) {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function browserContextStatus(apiBaseUrl) {
|
||||
try {
|
||||
const response = await fetch(`${apiBaseUrl}/api/browser-context/status`, {
|
||||
credentials: "include",
|
||||
});
|
||||
// The gateway may enforce authentication globally before this otherwise
|
||||
// public discovery endpoint is reached. A 401/403 still proves that this
|
||||
// candidate is the zncm API paired with the open frontend tab.
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
return { enabled: true, authenticationRequired: true };
|
||||
}
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && typeof data.enabled === "boolean") {
|
||||
return data;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
async function serviceCacheKey(settings, apiBaseUrl, webBaseUrl = "") {
|
||||
const cookie = await getCookieToken({ ...settings, apiBaseUrl });
|
||||
const auth = JSON.stringify([settings.accessToken || "", cookie?.token || ""]);
|
||||
// Persist only a credential fingerprint with the discovery timestamp. A new
|
||||
// login or cookie must not reuse another account's cached status/backoff.
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(auth));
|
||||
const fingerprint = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
return JSON.stringify([normalizeServiceBase(apiBaseUrl), normalizeServiceBase(webBaseUrl), fingerprint]);
|
||||
}
|
||||
|
||||
function statusRetryMs(response) {
|
||||
const value = response.headers?.get("Retry-After");
|
||||
if (!value) return STATUS_RETRY_MS;
|
||||
const seconds = Number(value);
|
||||
const delay = Number.isFinite(seconds) ? seconds * 1000 : Date.parse(value) - Date.now();
|
||||
return Number.isFinite(delay) ? Math.max(STATUS_RETRY_MS, Math.min(delay, 60 * 60 * 1000)) : STATUS_RETRY_MS;
|
||||
}
|
||||
|
||||
async function browserContextStatus(apiBaseUrl, settings = {}, force = false) {
|
||||
const base = normalizeServiceBase(apiBaseUrl);
|
||||
const key = await serviceCacheKey(settings, base);
|
||||
const cached = statusCache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < (force ? cached.manualRetryAt : cached.expiresAt)) {
|
||||
return cached.value;
|
||||
}
|
||||
if (statusRequests.has(key)) return statusRequests.get(key);
|
||||
|
||||
const request = (async () => {
|
||||
let value = null;
|
||||
let ttl = STATUS_RETRY_MS;
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
const response = await fetch(`${base}/api/browser-context/status`, {
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
ttl = statusRetryMs(response);
|
||||
if (response.status === 403 && data?.detail?.code === "browser_status_disabled") {
|
||||
value = { enabled: false, statusAccessDisabled: true };
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
// Authentication still establishes a candidate's API identity. Do not
|
||||
// mistake the explicit administrative status switch for a login error.
|
||||
value = { enabled: true, authenticationRequired: true };
|
||||
} else if (response.ok && data && typeof data.enabled === "boolean") {
|
||||
value = data;
|
||||
if (data.enabled) ttl = STATUS_CACHE_TTL_MS;
|
||||
}
|
||||
} catch {
|
||||
// Failed candidates are negative-cached too: an unreachable deployment
|
||||
// must not cause every open tab's three-second wake-up to probe it again.
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
const checkedAt = Date.now();
|
||||
statusCache.set(key, {
|
||||
value,
|
||||
expiresAt: checkedAt + ttl,
|
||||
manualRetryAt: checkedAt + (ttl === STATUS_CACHE_TTL_MS ? STATUS_MANUAL_MIN_INTERVAL_MS : ttl),
|
||||
});
|
||||
if (statusCache.size > 100) statusCache.delete(statusCache.keys().next().value);
|
||||
return value;
|
||||
})();
|
||||
statusRequests.set(key, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
statusRequests.delete(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function tabLooksLikeZncm(tab) {
|
||||
@@ -173,9 +235,37 @@ async function findOpenZncmTabs(settings) {
|
||||
}
|
||||
|
||||
async function discoverServiceBases(settings, force = false) {
|
||||
if (!settings.autoDiscoverServiceUrl && !force) return settings;
|
||||
const key = await serviceCacheKey(settings, settings.apiBaseUrl, settings.webBaseUrl);
|
||||
if (discoveryRequests.has(key)) return discoveryRequests.get(key);
|
||||
const request = discoverServiceBasesOnce(settings, force, key);
|
||||
discoveryRequests.set(key, request);
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
discoveryRequests.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverServiceBasesOnce(settings, force, contextKey) {
|
||||
if (!settings.autoDiscoverServiceUrl && !force) {
|
||||
return settings;
|
||||
}
|
||||
const lastSync = Date.parse(settings.lastServiceSyncAt || "");
|
||||
const discoveryAge = Date.now() - lastSync;
|
||||
const discoveryTtl = /browser_context_disabled|api_not_verified/.test(settings.lastServiceSyncSource || "")
|
||||
? STATUS_RETRY_MS
|
||||
: SERVICE_DISCOVERY_TTL_MS;
|
||||
if (
|
||||
!force &&
|
||||
Number.isFinite(lastSync) &&
|
||||
settings.lastServiceSyncSource &&
|
||||
settings.lastServiceSyncContext === contextKey &&
|
||||
discoveryAge >= 0 &&
|
||||
discoveryAge < discoveryTtl
|
||||
) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
let webBaseUrl = normalizeServiceBase(settings.webBaseUrl || DEER_CLIPPER_DEFAULTS.webBaseUrl);
|
||||
let discoveredWebBaseUrl = webBaseUrl;
|
||||
@@ -192,7 +282,7 @@ async function discoverServiceBases(settings, force = false) {
|
||||
let apiBaseUrl = normalizeServiceBase(settings.apiBaseUrl || DEER_CLIPPER_DEFAULTS.apiBaseUrl);
|
||||
let verified = false;
|
||||
for (const candidate of apiCandidatesForWebBase(discoveredWebBaseUrl, apiBaseUrl)) {
|
||||
const status = await browserContextStatus(candidate);
|
||||
const status = await browserContextStatus(candidate, settings, force);
|
||||
if (status) {
|
||||
apiBaseUrl = candidate;
|
||||
webBaseUrl = discoveredWebBaseUrl;
|
||||
@@ -201,7 +291,7 @@ async function discoverServiceBases(settings, force = false) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!verified && force) {
|
||||
if (!verified) {
|
||||
source = `${source}:api_not_verified`;
|
||||
}
|
||||
|
||||
@@ -213,22 +303,16 @@ async function discoverServiceBases(settings, force = false) {
|
||||
lastServiceSyncSource: source,
|
||||
};
|
||||
|
||||
if (
|
||||
force ||
|
||||
settings.apiBaseUrl !== next.apiBaseUrl ||
|
||||
settings.webBaseUrl !== next.webBaseUrl ||
|
||||
settings.lastServiceSyncSource !== next.lastServiceSyncSource
|
||||
) {
|
||||
// Discovered endpoints are runtime state, not user-authored preferences.
|
||||
// Keep them in storage.local so Chrome sync write quotas cannot break task
|
||||
// polling. The options page may still persist explicit user choices.
|
||||
await chrome.storage.local.set({
|
||||
discoveredApiBaseUrl: next.apiBaseUrl,
|
||||
discoveredWebBaseUrl: next.webBaseUrl,
|
||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||
});
|
||||
}
|
||||
next.lastServiceSyncContext = await serviceCacheKey(next, next.apiBaseUrl, next.webBaseUrl);
|
||||
// Refresh the timestamp even when the address/source did not change. Without
|
||||
// this write the first expired TTL causes status probes on every later poll.
|
||||
await chrome.storage.local.set({
|
||||
discoveredApiBaseUrl: next.apiBaseUrl,
|
||||
discoveredWebBaseUrl: next.webBaseUrl,
|
||||
lastServiceSyncAt: next.lastServiceSyncAt,
|
||||
lastServiceSyncSource: next.lastServiceSyncSource,
|
||||
lastServiceSyncContext: next.lastServiceSyncContext,
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,12 +3,18 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const vm = require("node:vm");
|
||||
const { webcrypto } = require("node:crypto");
|
||||
|
||||
const BACKGROUND_PATH = path.join(__dirname, "background.js");
|
||||
|
||||
function createHarness() {
|
||||
function createHarness(options = {}) {
|
||||
let storageChangedListener;
|
||||
let alarmCreateCount = 0;
|
||||
let clock = options.now ?? Date.now();
|
||||
let fetchHandler = options.fetch;
|
||||
let cookieToken = "";
|
||||
const localData = options.localData || {};
|
||||
const fetchUrls = [];
|
||||
const settings = {
|
||||
apiBaseUrl: "http://localhost:8001",
|
||||
webBaseUrl: "http://localhost:5174",
|
||||
@@ -18,6 +24,7 @@ function createHarness() {
|
||||
taskPollingEnabled: true,
|
||||
workerId: "test-worker",
|
||||
taskPollIntervalSeconds: 5,
|
||||
...options.settings,
|
||||
};
|
||||
|
||||
const chrome = {
|
||||
@@ -28,7 +35,7 @@ function createHarness() {
|
||||
},
|
||||
onAlarm: { addListener: () => {} },
|
||||
},
|
||||
cookies: { get: async () => null },
|
||||
cookies: { get: async () => cookieToken ? { value: cookieToken } : null },
|
||||
runtime: {
|
||||
onInstalled: { addListener: () => {} },
|
||||
onMessage: { addListener: () => {} },
|
||||
@@ -36,8 +43,8 @@ function createHarness() {
|
||||
},
|
||||
storage: {
|
||||
local: {
|
||||
get: async (defaults) => ({ ...defaults }),
|
||||
set: async () => {},
|
||||
get: async (defaults) => ({ ...defaults, ...localData }),
|
||||
set: async (updates) => Object.assign(localData, updates),
|
||||
},
|
||||
session: { get: async () => ({ actSessions: {} }), set: async () => {} },
|
||||
sync: {
|
||||
@@ -56,13 +63,26 @@ function createHarness() {
|
||||
const context = {
|
||||
chrome,
|
||||
console,
|
||||
fetch: async () => {
|
||||
throw new Error("offline test");
|
||||
fetch: async (url, init) => {
|
||||
fetchUrls.push(String(url));
|
||||
if (fetchHandler) return fetchHandler(url, init);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ enabled: true }),
|
||||
};
|
||||
},
|
||||
importScripts: () => {},
|
||||
setTimeout: () => 1,
|
||||
clearTimeout: () => {},
|
||||
URL,
|
||||
AbortController,
|
||||
TextEncoder,
|
||||
crypto: webcrypto,
|
||||
Date: class extends Date {
|
||||
constructor(...args) { super(...(args.length ? args : [clock])); }
|
||||
static now() { return clock; }
|
||||
},
|
||||
DEER_CLIPPER_DEFAULTS: settings,
|
||||
deerNormalizeBaseUrl: (value) => String(value || "").replace(/\/+$/, ""),
|
||||
deerMakeWorkerId: () => "test-worker",
|
||||
@@ -79,6 +99,17 @@ function createHarness() {
|
||||
storageChangedListener(changes, "sync");
|
||||
},
|
||||
getAlarmCreateCount: () => alarmCreateCount,
|
||||
getFetchUrls: () => [...fetchUrls],
|
||||
getSettings: () => context.getSettings(),
|
||||
setSettings: (updates) => Object.assign(settings, updates),
|
||||
getLocalData: () => ({ ...localData }),
|
||||
advanceTime: (ms) => { clock += ms; },
|
||||
getTime: () => clock,
|
||||
setFetch: (handler) => { fetchHandler = handler; },
|
||||
setCookie: (token) => { cookieToken = token; },
|
||||
status: (url, settings = {}, force = false) => context.browserContextStatus(url, settings, force),
|
||||
discoverServiceBases: (settings, force = false) =>
|
||||
context.discoverServiceBases(settings, force),
|
||||
resetAlarmCreateCount: () => {
|
||||
alarmCreateCount = 0;
|
||||
},
|
||||
@@ -128,3 +159,120 @@ test("service discovery prefers the API paired with the open zncm tab", async ()
|
||||
].filter((value, index, values) => values.indexOf(value) === index),
|
||||
);
|
||||
});
|
||||
|
||||
test("automatic service discovery checks status once per ten minutes, not on every task poll", async () => {
|
||||
const harness = createHarness();
|
||||
const settings = await harness.getSettings();
|
||||
settings.autoDiscoverServiceUrl = true;
|
||||
|
||||
await harness.discoverServiceBases(settings);
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
|
||||
await harness.discoverServiceBases(await harness.getSettings());
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
|
||||
await harness.discoverServiceBases(await harness.getSettings(), true);
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
harness.advanceTime(30_000);
|
||||
await harness.discoverServiceBases(await harness.getSettings(), true);
|
||||
assert.equal(harness.getFetchUrls().length, 2);
|
||||
});
|
||||
|
||||
test("unchanged service discovery refreshes its timestamp after TTL expiry", async () => {
|
||||
const harness = createHarness();
|
||||
await settle();
|
||||
harness.setSettings({ autoDiscoverServiceUrl: true });
|
||||
await harness.discoverServiceBases(await harness.getSettings());
|
||||
const firstSync = harness.getLocalData().lastServiceSyncAt;
|
||||
harness.advanceTime(10 * 60_000 + 1);
|
||||
await harness.discoverServiceBases(await harness.getSettings());
|
||||
assert.notEqual(harness.getLocalData().lastServiceSyncAt, firstSync);
|
||||
assert.equal(harness.getFetchUrls().length, 2);
|
||||
for (let i = 0; i < 20; i++) {
|
||||
harness.advanceTime(3_000);
|
||||
await harness.discoverServiceBases(await harness.getSettings());
|
||||
}
|
||||
assert.equal(harness.getFetchUrls().length, 2);
|
||||
});
|
||||
|
||||
test("concurrent status callers share a request and successful results expire", async () => {
|
||||
const harness = createHarness();
|
||||
const url = "http://example.test";
|
||||
const results = await Promise.all(Array.from({ length: 30 }, () => harness.status(url)));
|
||||
assert.ok(results.every((result) => result.enabled));
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
harness.advanceTime(59_000);
|
||||
await harness.status(`${url}/`);
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
harness.advanceTime(1_001);
|
||||
await harness.status(url);
|
||||
assert.equal(harness.getFetchUrls().length, 2);
|
||||
});
|
||||
|
||||
test("disabled status respects Retry-After even for manual calls and later recovers", async () => {
|
||||
const harness = createHarness({
|
||||
fetch: async () => ({
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: { get: () => "300" },
|
||||
json: async () => ({ detail: { code: "browser_status_disabled" } }),
|
||||
}),
|
||||
});
|
||||
const url = "http://example.test";
|
||||
const status = await harness.status(url);
|
||||
assert.equal(status.enabled, false);
|
||||
assert.equal(status.statusAccessDisabled, true);
|
||||
assert.equal(status.authenticationRequired, undefined);
|
||||
harness.advanceTime(299_999);
|
||||
await harness.status(url, {}, true);
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
harness.setFetch(async () => ({ ok: true, status: 200, json: async () => ({ enabled: true }) }));
|
||||
harness.advanceTime(1);
|
||||
assert.equal((await harness.status(url)).enabled, true);
|
||||
assert.equal(harness.getFetchUrls().length, 2);
|
||||
});
|
||||
|
||||
test("network and server failures back off for five minutes", async () => {
|
||||
for (const fetch of [
|
||||
async () => { throw new Error("offline"); },
|
||||
async () => ({ ok: false, status: 500, json: async () => ({ detail: "unavailable" }) }),
|
||||
]) {
|
||||
const harness = createHarness({ fetch });
|
||||
for (let i = 0; i < 10; i++) assert.equal(await harness.status("http://example.test", {}, true), null);
|
||||
assert.equal(harness.getFetchUrls().length, 1);
|
||||
harness.advanceTime(300_000);
|
||||
assert.equal(await harness.status("http://example.test"), null);
|
||||
assert.equal(harness.getFetchUrls().length, 2);
|
||||
}
|
||||
});
|
||||
|
||||
test("status cache separates service addresses, login tokens and cookies", async () => {
|
||||
const harness = createHarness();
|
||||
await harness.status("http://one.test", { accessToken: "account-a" });
|
||||
await harness.status("http://one.test", { accessToken: "account-b" });
|
||||
await harness.status("http://two.test", { accessToken: "account-b" });
|
||||
harness.setCookie("new-login");
|
||||
await harness.status("http://two.test", { accessToken: "account-b" });
|
||||
assert.equal(harness.getFetchUrls().length, 4);
|
||||
await harness.status("http://two.test", { accessToken: "account-b" });
|
||||
assert.equal(harness.getFetchUrls().length, 4);
|
||||
});
|
||||
|
||||
test("discovery survives worker restarts but refreshes after account changes", async () => {
|
||||
const harness = createHarness();
|
||||
await settle();
|
||||
harness.setSettings({ autoDiscoverServiceUrl: true, accessToken: "account-a" });
|
||||
await harness.discoverServiceBases(await harness.getSettings());
|
||||
const localData = harness.getLocalData();
|
||||
assert.ok(!JSON.stringify(localData).includes("account-a"));
|
||||
const restarted = createHarness({
|
||||
localData,
|
||||
now: harness.getTime(),
|
||||
settings: { autoDiscoverServiceUrl: true, accessToken: "account-a" },
|
||||
});
|
||||
await restarted.discoverServiceBases(await restarted.getSettings());
|
||||
assert.equal(restarted.getFetchUrls().length, 0);
|
||||
restarted.setSettings({ accessToken: "account-b" });
|
||||
await restarted.discoverServiceBases(await restarted.getSettings());
|
||||
assert.equal(restarted.getFetchUrls().length, 1);
|
||||
});
|
||||
Reference in new issue
Block a user