diff --git a/apps/docs/src/content/contributing/chrome-extension.md b/apps/docs/src/content/contributing/chrome-extension.md
index 82eda1f..d74fbd5 100644
--- a/apps/docs/src/content/contributing/chrome-extension.md
+++ b/apps/docs/src/content/contributing/chrome-extension.md
@@ -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.
diff --git a/apps/docs/src/content/getting-started/chrome-extension.md b/apps/docs/src/content/getting-started/chrome-extension.md
index 5c78bf0..31be5e9 100644
--- a/apps/docs/src/content/getting-started/chrome-extension.md
+++ b/apps/docs/src/content/getting-started/chrome-extension.md
@@ -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.
@@ -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
@@ -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 Allow access to let the extension reach that host. Chrome asks you to confirm.
- 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 Access and redaction.
+ 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 Try again. See Access and redaction.
+
+
+ The server answered 401 or 403. The Vite plugin refuses requests that do not come from your machine. Open the app on localhost, or see Answers only your machine.
+
+
+ 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.
Open the Components tab first, and check that the overlay is loaded. Elements outside any component select nothing.
diff --git a/extension/panel-bridge.js b/extension/panel-bridge.js
index 768c460..8ba09c5 100644
--- a/extension/panel-bridge.js
+++ b/extension/panel-bridge.js
@@ -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 {
@@ -65,22 +75,44 @@ 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',
@@ -88,17 +120,26 @@ async function findConnection(candidates) {
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;
@@ -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);
diff --git a/extension/panel.html b/extension/panel.html
index 8048b8c..5b49a95 100644
--- a/extension/panel.html
+++ b/extension/panel.html
@@ -68,6 +68,7 @@