Skip to content
Merged
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
29 changes: 25 additions & 4 deletions src/background.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { broadcastDevStatus, broadcastEntryPageState, broadcastRequestActive } from './background/broadcasts'
import { handleCacheHit } from './background/cacheHit'
import { forgetProvenHosts, rememberProvenHost } from './background/hosts'
import { ingestEntry } from './background/ingest'
import { appendAndBroadcast } from './background/record'
import {
Expand All @@ -16,7 +17,7 @@ import {
setTabMaxEntries,
} from './background/runtimeStore'
import { resolveClientVisitBatchId, synthesizeCacheHitEntry, synthesizeClientVisitEntry } from './background/synthesize'
import { ensureTabRule, migrateTabRule, primeExistingTabs, removeTabRule } from './background/tabRules'
import { ensureTabRule, migrateTabRule, removeTabRule, syncAllTabRules } from './background/tabRules'
import { browser } from './browser'
import { DEVTOOLS_ID_HEADER, REEMIT_PAGE_STATE_MESSAGE } from './constants'
import { isBackgroundMessage } from './guards'
Expand Down Expand Up @@ -48,8 +49,8 @@ workerScope.addEventListener('unhandledrejection', (event) => {
console.error('[inertia-devtools] service worker unhandled rejection:', event)
})

browser.runtime.onInstalled.addListener(() => void primeExistingTabs())
browser.runtime.onStartup.addListener(() => void primeExistingTabs())
browser.runtime.onInstalled.addListener(() => void syncAllTabRules())
browser.runtime.onStartup.addListener(() => void syncAllTabRules())
browser.tabs.onCreated.addListener((tab) => {
if (typeof tab.id === 'number') {
void ensureTabRule(tab.id)
Expand All @@ -76,7 +77,10 @@ browser.webRequest.onHeadersReceived.addListener(
return
}

void ingestEntry(tabId, new URL(url).origin, idHeader.value)
const origin = new URL(url).origin

void noteProvenHost(origin)
void ingestEntry(tabId, origin, idHeader.value)
},
{ urls: ['<all_urls>'] },
['responseHeaders'],
Expand All @@ -88,6 +92,10 @@ self.__inertiaDevtools = {
getPageStates: (tabId) => getPageStatesForTab(tabId),
clearAll: () => clearAll(),
ingest: (tabId, origin, id) => ingestEntry(tabId, origin, id),
forgetHosts: async () => {
await forgetProvenHosts()
await syncAllTabRules()
},
}

function replyOk(sendResponse: (response: { ok: true }) => void): boolean {
Expand All @@ -96,6 +104,18 @@ function replyOk(sendResponse: (response: { ok: true }) => void): boolean {
return false
}

/**
* Unlock the tab header for a host that just proved it runs the recorder.
*
* Its first response is therefore unstamped; every request made after the rule lands carries the
* tab UUID.
*/
async function noteProvenHost(origin: string): Promise<void> {
if (await rememberProvenHost(origin)) {
await syncAllTabRules()
}
}

/**
* Pair an incoming page-state snapshot with its owning timeline entry before notifying panels.
*/
Expand Down Expand Up @@ -158,6 +178,7 @@ function recordClientVisit(tabId: number, visit: ClientVisitSnapshot): void {
function handleContentMessage(tabId: number, message: ContentToBackgroundMessage): void {
switch (message.type) {
case 'content:initial-id':
void noteProvenHost(message.origin)
void ingestEntry(tabId, message.origin, message.id)
return

Expand Down
71 changes: 71 additions & 0 deletions src/background/hosts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { browser } from '../browser'
import { DEVTOOLS_HOSTS_STORAGE_KEY } from '../constants'

/**
* Hosts that served an `x-inertia-devtools-id` header or rendered the id tag, and so proved they
* run the recorder. The tab header is a stable identifier, so it is only ever stamped on these.
*
* Persisted because the proof outlives the session rules it scopes, and cached because a rule is
* rewritten for every tab Chrome opens.
*/
let cachedHosts: Set<string> | null = null

async function loadHosts(): Promise<Set<string>> {
if (cachedHosts) {
return cachedHosts
}

const stored = await browser.storage.local.get(DEVTOOLS_HOSTS_STORAGE_KEY)
const hosts: unknown[] = Array.isArray(stored[DEVTOOLS_HOSTS_STORAGE_KEY]) ? stored[DEVTOOLS_HOSTS_STORAGE_KEY] : []

cachedHosts = new Set(hosts.filter((host): host is string => typeof host === 'string' && host.length > 0))

return cachedHosts
}

export async function getProvenHosts(): Promise<string[]> {
return [...(await loadHosts())]
}

/**
* Reduce a page-controlled origin to the hostname a DNR rule may name, or null when it is neither
* http nor https.
*/
function hostnameOf(origin: string): string | null {
try {
const url = new URL(origin)

return url.protocol === 'http:' || url.protocol === 'https:' ? url.hostname : null
} catch {
return null
}
}

/**
* Record a host that proved it runs the recorder, resolving true when the set actually grew
* and the tab rules therefore have to be rewritten to cover it.
*/
export async function rememberProvenHost(origin: string): Promise<boolean> {
const hostname = hostnameOf(origin)

if (!hostname) {
return false
}

const hosts = await loadHosts()

if (hosts.has(hostname)) {
return false
}

hosts.add(hostname)
await browser.storage.local.set({ [DEVTOOLS_HOSTS_STORAGE_KEY]: [...hosts] })

return true
}

/** Forget every proven host, so no tab rule survives. Used by the E2E hook in `background.ts`. */
export async function forgetProvenHosts(): Promise<void> {
cachedHosts = new Set()
await browser.storage.local.remove(DEVTOOLS_HOSTS_STORAGE_KEY)
}
28 changes: 21 additions & 7 deletions src/background/tabRules.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { browser } from '../browser'
import { DEVTOOLS_TAB_HEADER, TAB_STORAGE_KEY_PREFIX } from '../constants'
import { getProvenHosts } from './hosts'
import { clearTab, migrateTab } from './runtimeStore'

function tabStorageKey(tabId: number): string {
Expand All @@ -15,9 +16,22 @@ export async function readTabUuid(tabId: number): Promise<string | null> {

/**
* Install the per-tab DNR rule that stamps outgoing requests with the stable tab UUID.
*
* The rule only covers hosts that have proven they run the recorder — `requestDomains` is the
* closest DNR gets to a host allowlist, so it widens to their subdomains too.
*/
export async function writeDnrRule(tabId: number, uuid: string): Promise<void> {
const provenHosts = await getProvenHosts()

try {
// Until some host proves it runs the recorder there is no rule at all: a UUID stamped on every
// request would be a stable identifier handed to every site the user visits.
if (provenHosts.length === 0) {
await browser.declarativeNetRequest.updateSessionRules({ removeRuleIds: [tabId] })

return
}

await browser.declarativeNetRequest.updateSessionRules({
removeRuleIds: [tabId],
addRules: [
Expand All @@ -26,6 +40,7 @@ export async function writeDnrRule(tabId: number, uuid: string): Promise<void> {
priority: 1,
condition: {
tabIds: [tabId],
requestDomains: provenHosts,
resourceTypes: [
chrome.declarativeNetRequest.ResourceType.XMLHTTPREQUEST,
chrome.declarativeNetRequest.ResourceType.MAIN_FRAME,
Expand All @@ -52,15 +67,13 @@ export async function writeDnrRule(tabId: number, uuid: string): Promise<void> {

/**
* Ensure a tab has a stable UUID and matching header-injection rule before traffic starts.
*
* The rule is rewritten rather than assumed live: session rules die on browser restart, and the
* proven-host set they are scoped to grows as hosts reveal a recorder.
*/
export async function ensureTabRule(tabId: number): Promise<string> {
const existing = await readTabUuid(tabId)

if (existing) {
return existing
}
const uuid = (await readTabUuid(tabId)) ?? crypto.randomUUID()

const uuid = crypto.randomUUID()
await browser.storage.local.set({ [tabStorageKey(tabId)]: uuid })
await writeDnrRule(tabId, uuid)

Expand Down Expand Up @@ -103,7 +116,8 @@ export async function migrateTabRule(addedTabId: number, removedTabId: number):
migrateTab(removedTabId, addedTabId)
}

export async function primeExistingTabs(): Promise<void> {
/** Reinstall every tab rule: on worker start, and when a newly proven host widens their scope. */
export async function syncAllTabRules(): Promise<void> {
const tabs = await browser.tabs.query({})

for (const tab of tabs) {
Expand Down
1 change: 1 addition & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ export const DEVTOOLS_ID_HEADER = 'x-inertia-devtools-id'
export const DEVTOOLS_TAB_HEADER = 'x-inertia-devtools-tab'
export const INITIAL_ID_TAG_SELECTOR = 'script[data-inertia-devtools-id][type="application/json"]'
export const TAB_STORAGE_KEY_PREFIX = 'tab-'
export const DEVTOOLS_HOSTS_STORAGE_KEY = 'devtools-hosts'
export const SESSION_TAB_ID_KEY = 'devtoolsTabId'
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,4 +170,5 @@ export type DevToolsTestHooks = {
getPageStates: (tabId: number) => Record<string, PageStateSnapshot>
clearAll: () => void
ingest: (tabId: number, origin: string, id: string) => Promise<void>
forgetHosts: () => Promise<void>
}
2 changes: 1 addition & 1 deletion tests/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { test as base, chromium, type BrowserContext, type Page, type Worker } from '@playwright/test'
import type { Entry, PageStateSnapshot } from '../src/types'
import type { Entry, PageStateSnapshot } from '../../src/types'

const here = dirname(fileURLToPath(import.meta.url))

Expand Down
Loading
Loading