Skip to content
Draft
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
14 changes: 9 additions & 5 deletions apps/docs/src/content/contributing/chrome-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,20 @@ With access granted, it looks for the devtools server under these paths, in orde

Under each path it fetches `__devframe/__connection.json`, then `__connection.json`, with no credentials, no cache, no redirects and a 1.5 second timeout. The first response that is OK and parses as JSON wins.

If none answers, the status view lists every URL it tried and links to the setup section of the README.
It records the status of each request, or "no answer" when the request fails or times out. If none answers, the status view lists every URL it tried with its status and links to the setup section of the README. If any request got `401` or `403`, the status view says the server refused the request, shows up to 200 characters of the response text and links to the 403 notes on the Vite page instead. Both views have a **Try again** button that starts the search over.

### Waiting for the page id

The overlay sets `window.__ngDevtoolsPageId` once it claims the page id, and removes it when it is disposed. After it finds the server, the panel evaluates that global every 250 milliseconds for up to five seconds. If the global never appears, it reads the `ng-devtools-page-id` value from `sessionStorage` once, for overlays that do not set the global, and loads the UI with whatever it got.

### Loading the UI

The panel loads `ui/index.html` with two query parameters:

| Parameter | Value |
| --------- | -------------------------------------------------------------------------------------- |
| `baseURL` | The path that served the connection file, on the origin of the page. |
| `pageId` | The `ng-devtools-page-id` value the overlay keeps in `sessionStorage`, when it is set. |
| Parameter | Value |
| --------- | ---------------------------------------------------------------------------------------- |
| `baseURL` | The path that served the connection file, on the origin of the page. |
| `pageId` | The page id from [Waiting for the page id](#waiting-for-the-page-id), when there is one. |

Outside the extension, the UI accepts a `baseURL` only on its own origin. Inside the extension, it accepts any `http` or `https` URL. The panel only passes hosts the extension can reach.

Expand Down
18 changes: 15 additions & 3 deletions apps/docs/src/content/getting-started/chrome-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,11 @@ The panel looks for the devtools server on the origin of the inspected page. It

Under each path it asks for `__devframe/__connection.json`, then `__connection.json`. It connects the UI to the first path that answers with a connection file. Each request times out after 1.5 seconds.

If no path answers, the panel says "No devtools server answered", lists every URL it tried and links to the setup instructions.
If no path answers, the panel says "No devtools server answered" and lists every URL it tried, each with the HTTP status it got or "no answer". It links to the setup instructions.

If any URL got `401` or `403`, the panel says the server refused the request instead, and shows the start of the response text. The Vite plugin answers `403` to requests that do not come from your machine, for example when you open the app by its LAN IP. The panel then links to [Answers only your machine](./vite.md#answers-only-your-machine).

Both messages have a **Try again** button. Click it after you start or fix the server, and the panel looks for the server again without a page reload.

The panel only connects to pages served over `http` or `https`. On other pages it says so and stops.

Expand All @@ -91,7 +95,9 @@ The extension can reach loopback hosts from the start. For any other host, such

### The inspected tab

The overlay gives each page an id. The panel passes the id of the page it inspects to the UI. If several tabs run the same app, the panel shows the tab you inspect, not the one that reported last.
The overlay gives each page an id and exposes it on the page as `window.__ngDevtoolsPageId`. The panel passes the id of the page it inspects to the UI. If several tabs run the same app, the panel shows the tab you inspect, not the one that reported last.

The overlay claims the id after it connects to the server, so it can come later than the server answers. The panel waits up to five seconds for the id. If no id appears in that time, it loads the UI without one and shows the page that reported last.

### Navigation

Expand Down Expand Up @@ -135,7 +141,13 @@ The content scripts are wider. Two of them run on every page. They check for an
The page is not on a loopback host. Click <strong>Allow access</strong> to let the extension reach that host. Chrome asks you to confirm.
</ngmd-accordion-item>
<ngmd-accordion-item title="The panel lists the URLs it tried">
None of them served a connection file. Check that the server of the page mounts the devtools and that the server accepts the request. See <a href="../security.md">Access and redaction</a>.
None of them served a connection file. The status next to each URL shows what the server answered. Check that the server of the page mounts the devtools and that the server accepts the request, then click <strong>Try again</strong>. See <a href="../security.md">Access and redaction</a>.
</ngmd-accordion-item>
<ngmd-accordion-item title="The panel says the server refused the request">
The server answered <code>401</code> or <code>403</code>. The Vite plugin refuses requests that do not come from your machine. Open the app on <code>localhost</code>, or see <a href="./vite.md#answers-only-your-machine">Answers only your machine</a>.
</ngmd-accordion-item>
<ngmd-accordion-item title="The panel shows another tab">
The overlay on the inspected page did not report its page id within five seconds, so the panel loaded without it. Check that the overlay starts on that page, then close and reopen DevTools.
</ngmd-accordion-item>
<ngmd-accordion-item title="Selecting an element does not select a component">
Open the <strong>Components</strong> tab first, and check that the overlay is loaded. Elements outside any component select nothing.
Expand Down
68 changes: 57 additions & 11 deletions extension/panel-bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,24 @@ const status = document.getElementById('status');
const statusMessage = document.getElementById('status-message');
const triedList = document.getElementById('status-tried');
const allowButton = document.getElementById('status-allow');
const retryButton = document.getElementById('status-retry');
const docsLink = document.getElementById('status-docs');
const SETUP_DOCS = { href: docsLink.href, text: docsLink.textContent };
const REFUSED_DOCS = {
href: 'https://github.com/santoshyadavdev/angular-devtools/blob/main/apps/docs/src/content/getting-started/vite.md#answers-only-your-machine',
text: 'Why the devtools server refuses requests',
};

// Where devframe may be mounted.
const PATHS = ['/__ng-devtools/', '/__devframes/ng-devtools/', '/__devframe/', '/'];
const CONNECTION_FILES = ['__devframe/__connection.json', '__connection.json'];
const PROBE_TIMEOUT_MS = 1500;
const REFUSED_TEXT_LIMIT = 200;
const PAGE_ID_WAIT_MS = 5000;
const PAGE_ID_POLL_MS = 250;
const DETECTING = 'Detecting Angular app…';
const PAGE_ID = `(() => {
const PAGE_ID = `typeof window.__ngDevtoolsPageId === 'string' ? window.__ngDevtoolsPageId : null`;
const STORED_PAGE_ID = `(() => {
try {
return sessionStorage.getItem('ng-devtools-page-id');
} catch {
Expand Down Expand Up @@ -65,40 +75,71 @@ async function detectConnection() {
const candidates = PATHS.flatMap((base) =>
CONNECTION_FILES.map((file) => ({ base, url: new URL(base + file, page).href })),
).filter((candidate, index, all) => all.findIndex(({ url }) => url === candidate.url) === index);
const found = await findConnection(candidates);
const { found, probes } = await findConnection(candidates);
if (run !== detection) return;
if (!found) {
showStatus(`No devtools server answered on ${page.origin}. Tried:`, {
tried: candidates.map(({ url }) => url),
});
const refused = probes.find(({ status }) => status === 401 || status === 403);
const tried = probes.map(({ url, status }) => `${url} (${status ?? 'no answer'})`);
if (refused) {
const reason = refused.text ? ` It said: "${refused.text}"` : '';
showStatus(
`The devtools server on ${page.origin} refused the request (${refused.status}).${reason} Tried:`,
{ tried, retry: true, docs: REFUSED_DOCS },
);
} else {
showStatus(`No devtools server answered on ${page.origin}. Tried:`, { tried, retry: true });
}
return;
}

const pageId = await evalInPage(PAGE_ID);
const pageId = await waitForPageId(run);
if (run === detection) loadPanel(new URL(found.base, page), pageId);
}

// The first candidate that answers with a connection file, or null.
// The overlay sets the id once it claims it, which can be well after the app renders.
async function waitForPageId(run) {
for (let waited = 0; ; waited += PAGE_ID_POLL_MS) {
const id = await evalInPage(PAGE_ID);
if (run !== detection) return null;
if (typeof id === 'string' && id) return id;
if (waited >= PAGE_ID_WAIT_MS) return evalInPage(STORED_PAGE_ID);
await new Promise((resolve) => setTimeout(resolve, PAGE_ID_POLL_MS));
}
}

// The first candidate that answers with a connection file, and the status of each probe.
async function findConnection(candidates) {
const probes = [];
for (const candidate of candidates) {
const probe = { url: candidate.url, status: null, text: '' };
probes.push(probe);
try {
const response = await fetch(candidate.url, {
credentials: 'omit',
cache: 'no-store',
redirect: 'error',
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
});
if (!response.ok) continue;
probe.status = response.status;
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
probe.text = (await response.text()).trim().slice(0, REFUSED_TEXT_LIMIT);
}
continue;
}
await response.json();
return candidate;
return { found: candidate, probes };
} catch {
// Not mounted here; try the next one.
}
}
return null;
return { found: null, probes };
}

function showStatus(message, { tried = [], allow = null, help = true } = {}) {
function showStatus(
message,
{ tried = [], allow = null, retry = false, docs = SETUP_DOCS, help = true } = {},
) {
frame.style.display = 'none';
status.classList.remove('hidden');
statusMessage.textContent = message;
Expand All @@ -108,9 +149,14 @@ function showStatus(message, { tried = [], allow = null, help = true } = {}) {
triedList.hidden = !tried.length;
allowButton.onclick = allow;
allowButton.hidden = !allow;
retryButton.hidden = !retry;
docsLink.href = docs.href;
docsLink.textContent = docs.text;
docsLink.hidden = !help;
}

retryButton.addEventListener('click', () => detectConnection());

function loadPanel(baseURL, pageId) {
const src = new URL(chrome.runtime.getURL('ui/index.html'));
src.searchParams.set('baseURL', baseURL.href);
Expand Down
1 change: 1 addition & 0 deletions extension/panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
<p id="status-message">Detecting Angular app…</p>
<ul id="status-tried" aria-label="URLs tried" hidden></ul>
<button id="status-allow" type="button" hidden>Allow access</button>
<button id="status-retry" type="button" hidden>Try again</button>
<a
id="status-docs"
href="https://github.com/santoshyadavdev/angular-devtools#get-started"
Expand Down
133 changes: 126 additions & 7 deletions packages/ng-devtools/src/__tests__/extension-panel-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ type Listener = () => void;

interface Setup {
origin?: string;
pageId?: string | null;
pageId?: () => string | null;
storedPageId?: string | null;
granted?: boolean;
grant?: boolean;
fetch?: (url: string) => Promise<Response>;
Expand Down Expand Up @@ -43,9 +44,11 @@ function open(setup: Setup = {}) {
callback(
expression === 'location.origin'
? origin
: expression.includes('ng-devtools-page-id')
? (setup.pageId ?? null)
: null,
: expression.includes('__ngDevtoolsPageId')
? (setup.pageId?.() ?? null)
: expression.includes('ng-devtools-page-id')
? (setup.storedPageId ?? null)
: null,
),
),
},
Expand Down Expand Up @@ -73,6 +76,7 @@ function open(setup: Setup = {}) {
tried: () => [...$('status-tried').querySelectorAll('li')].map((li) => li.textContent),
triedList: $('status-tried'),
allow: $<HTMLButtonElement>('status-allow'),
retry: $<HTMLButtonElement>('status-retry'),
docs: $<HTMLAnchorElement>('status-docs'),
frame: $<HTMLIFrameElement>('devtools-frame'),
};
Expand All @@ -83,6 +87,8 @@ const found = (path: string) => (url: string) =>
? Promise.resolve(new Response('{}', { status: 200 }))
: Promise.resolve(new Response('', { status: 404 }));

const urlOf = (line: string | null) => line?.replace(/ \(.*\)$/, '');

describe('extension panel bridge', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
Expand Down Expand Up @@ -112,6 +118,7 @@ describe('extension panel bridge', () => {
granted: false,
grant: true,
fetch: found('/__ng-devtools/__devframe/__connection.json'),
pageId: () => 'page-1',
});
await vi.advanceTimersByTimeAsync(500);
expect(panel.message()).toBe(
Expand Down Expand Up @@ -140,7 +147,7 @@ describe('extension panel bridge', () => {
const panel = open();
await vi.advanceTimersByTimeAsync(500);
expect(panel.message()).toBe('No devtools server answered on http://localhost:4200. Tried:');
const tried = panel.tried();
const tried = panel.tried().map(urlOf);
expect(tried.length).toBeGreaterThan(1);
expect(new Set(tried).size).toBe(tried.length);
expect(tried).toContain('http://localhost:4200/__devframe/__connection.json');
Expand All @@ -152,7 +159,7 @@ describe('extension panel bridge', () => {

it('loads the panel with the base URL and page id of the server it found', async () => {
const panel = open({
pageId: 'page-1',
pageId: () => 'page-1',
fetch: found('/__devframes/ng-devtools/__connection.json'),
});
await vi.advanceTimersByTimeAsync(500);
Expand All @@ -166,16 +173,128 @@ describe('extension panel bridge', () => {
expect(panel.status.classList.contains('hidden')).toBe(true);
});

it('leaves the page id out when the page has none', async () => {
it('leaves the page id out when the page has none after five seconds', async () => {
const panel = open({ fetch: found('/__ng-devtools/__devframe/__connection.json') });
await vi.advanceTimersByTimeAsync(500);
expect(panel.frame.style.display).toBe('none');
expect(panel.message()).toBe('Detecting Angular app…');
await vi.advanceTimersByTimeAsync(5000);
expect(panel.frame.style.display).toBe('block');
expect(new URL(panel.frame.src).searchParams.has('pageId')).toBe(false);
});

it('waits for a page id the overlay claims after the server answers', async () => {
let pageId: string | null = null;
const panel = open({
pageId: () => pageId,
storedPageId: 'other-tab',
fetch: found('/__ng-devtools/__devframe/__connection.json'),
});
await vi.advanceTimersByTimeAsync(2000);
expect(panel.frame.style.display).toBe('none');
pageId = 'late-page';
await vi.advanceTimersByTimeAsync(250);
expect(panel.frame.style.display).toBe('block');
expect(new URL(panel.frame.src).searchParams.get('pageId')).toBe('late-page');
});

it('stops waiting for a page id when the page navigates', async () => {
let pageId: string | null = null;
const panel = open({
pageId: () => pageId,
fetch: found('/__ng-devtools/__devframe/__connection.json'),
});
await vi.advanceTimersByTimeAsync(1000);
panel.navigate();
pageId = 'next-page';
await vi.advanceTimersByTimeAsync(1000);
expect(new URL(panel.frame.src).searchParams.get('pageId')).toBe('next-page');
await vi.advanceTimersByTimeAsync(5000);
expect(panel.fetch).toHaveBeenCalledTimes(2);
});

it('falls back to the stored page id of an overlay that sets no global', async () => {
const panel = open({
storedPageId: 'stored-page',
fetch: found('/__ng-devtools/__devframe/__connection.json'),
});
await vi.advanceTimersByTimeAsync(5500);
expect(new URL(panel.frame.src).searchParams.get('pageId')).toBe('stored-page');
});

it('shows the status of each probe and tries again on request', async () => {
let up = false;
const panel = open({
fetch: (url) =>
up
? found('/__ng-devtools/__devframe/__connection.json')(url)
: url.endsWith('/__ng-devtools/__devframe/__connection.json')
? Promise.resolve(new Response('', { status: 404 }))
: offline(),
pageId: () => 'page-1',
});
await vi.advanceTimersByTimeAsync(500);
expect(panel.tried()[0]).toBe(
'http://localhost:4200/__ng-devtools/__devframe/__connection.json (404)',
);
expect(panel.tried()[1]).toBe(
'http://localhost:4200/__ng-devtools/__connection.json (no answer)',
);
expect(panel.retry.hidden).toBe(false);
expect(panel.retry.textContent).toBe('Try again');

up = true;
panel.retry.click();
expect(panel.message()).toBe('Detecting Angular app…');
expect(panel.retry.hidden).toBe(true);
await vi.advanceTimersByTimeAsync(0);
expect(panel.frame.style.display).toBe('block');
expect(new URL(panel.frame.src).searchParams.get('pageId')).toBe('page-1');
});

it('says the server refused the request when a probe gets a 403', async () => {
const panel = open({
origin: 'http://192.168.1.20:5173',
fetch: (url) =>
url.endsWith('/__devframes/ng-devtools/__connection.json')
? Promise.resolve(
new Response('ng-devtools only answers requests from this machine.', { status: 403 }),
)
: Promise.resolve(new Response('', { status: 404 })),
});
await vi.advanceTimersByTimeAsync(500);
expect(panel.message()).toBe(
'The devtools server on http://192.168.1.20:5173 refused the request (403). It said: "ng-devtools only answers requests from this machine." Tried:',
);
expect(panel.tried()).toContain(
'http://192.168.1.20:5173/__devframes/ng-devtools/__connection.json (403)',
);
expect(panel.retry.hidden).toBe(false);
expect(panel.docs.hidden).toBe(false);
expect(panel.docs.textContent).toBe('Why the devtools server refuses requests');
expect(panel.docs.href).toMatch(/getting-started\/vite\.md#answers-only-your-machine$/);
});

it('restores the setup link after a refusal turns into no answer', async () => {
let refuse = true;
const panel = open({
fetch: () => (refuse ? Promise.resolve(new Response('', { status: 401 })) : offline()),
});
await vi.advanceTimersByTimeAsync(500);
expect(panel.message()).toMatch(/refused the request \(401\)\. Tried:$/);
refuse = false;
panel.retry.click();
await vi.advanceTimersByTimeAsync(0);
expect(panel.message()).toBe('No devtools server answered on http://localhost:4200. Tried:');
expect(panel.docs.textContent).toBe('Set up the devtools server');
expect(panel.docs.href).toBe('https://github.com/santoshyadavdev/angular-devtools#get-started');
});

it('drops a detection that finishes after a navigation and detects again', async () => {
let answer: (response: Response) => void = () => {};
let slow = true;
const panel = open({
pageId: () => 'page-1',
fetch: (url) =>
slow
? new Promise((resolve) => (answer = resolve))
Expand Down
Loading
Loading